修复画布音频改造面板

修复音效素材点击改造后生成面板被选中逻辑关闭的问题

补充音频改造集成回归测试,确保不显示参考图且恢复提示词和时长

统一重绘按钮泥点展示并补充价格配置约束文档

收紧 Vidu 音效生成时长校验,避免静默夹取非法时长
This commit is contained in:
2026-06-19 12:40:10 +08:00
parent 0e47a54a91
commit 4af8ab22d0
12 changed files with 168 additions and 19 deletions
@@ -16,6 +16,14 @@
---
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动画、图标素材、音效和背景音乐也使用同一函数。后端用 `server-rs/crates/api-server/src/editor_generation_config.rs` 的同名语义配置重新计算并校验 / 扣费。当前正式模型均已覆盖定价;`nanobanana2``nano-banana` 只作为 UI 显示名或历史别名,不作为正式模型 ID 写入定价表。
- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config` 和 Lovart 生成类面板文档。
- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`,并执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
@@ -35,7 +43,7 @@
## 2026-06-18 图片画布生成音乐入口作为音频图层接入
- 背景:图片画布底部生成工具需要补齐游戏音效和游戏背景音乐生成,既要复用现有 Lovart 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。
- 决策:`/editor/canvas` 新增底部 `生成音乐` 入口,点击后先弹出“生成游戏音效 / 生成游戏背景音乐”选项框,再分别创建 `audio-sound-effect``audio-background-music` 生成器;生成结果作为 `mediaType="audio"` 的画布音频卡保存,`assetKind` 分别为 `sound-effect` / `background-music`。音效请求字段固定映射 `sound/type/tempo`背景音乐请求字段固定映射 `gpt_description_prompt``make_instrumental=true`
- 决策:`/editor/canvas` 新增底部 `生成音乐` 入口,点击后先弹出“生成游戏音效 / 生成游戏背景音乐”选项框,再分别创建 `audio-sound-effect``audio-background-music` 生成器;生成结果作为 `mediaType="audio"` 的画布音频卡保存,`assetKind` 分别为 `sound-effect` / `background-music`。音效请求字段固定映射 Vidu `prompt/model/duration`,模型固定 `audio1.0`、时长严格 `2-10` 秒;背景音乐请求字段固定映射 `gpt_description_prompt``make_instrumental=true`
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts``platform-audio``api-server` 编辑器音频 BFF、图片画布技术方案和音乐生成入口设计文档。
- 验证方式:运行编辑器生成入口 / 提交 / 音频图层相关前端测试,`platform-audio` 请求体测试,`shared-contracts` editor audio 序列化测试,`api-server` editor audio 归一化测试,并执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画板音乐生成入口设计-2026-06-18.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
@@ -71,8 +71,13 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点``生成40泥点`;不使用泥点图标替代文字。
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发
- 画板内所有会提交外部生成任务的生成按钮,价格都必须从模型定价配置函数推导,不允许在按钮文案或提交 payload 中散落固定泥点数字;修改 `ImageCanvasGenerationModel.ts``api-server/src/editor_generation_config.rs` 的同名定价配置后,按钮展示、前端提交的 `priceMudPoints` 和后端校验 / 扣费应同步变化
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动画走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。当前不是运行时动态配置接口,若要后台改价实时影响前端,需要新增配置下发能力。
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。
- 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。
- `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。
- 历史 / 测试中出现的 `nanobanana2``nano-banana` 只是 UI 显示名或旧别名,不是正式上游模型 ID;正式图片模型 ID 以 `gemini-3.1-flash-image-preview``gpt-image-2` 为准。历史别名若继续进入计算函数,只能回落到默认图片模型价格,不能作为新增正式模型绕过定价表。
## 画布占位落点
@@ -121,6 +126,17 @@
生成背景音乐:5 泥点
```
当前必须显式覆盖的正式模型定价配置:
- 图片类:`gemini-3.1-flash-image-preview``gpt-image-2`
- 规范:`gpt-image-2`
- 视频:`seedance2.0-fast``seedance2.0``kling3.0``kling3.0-omni`;兼容旧布局回放的 `veo3.1``veo3.1-fast` 也要保留定价配置,但前端模型菜单不展示。
- 角色动画:`seedance2.0-fast`
- 音效:`audio1.0`
- 背景音乐:`chirp-v5`
本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、前端提交计算、后端校验配置和对应测试,再出现在任一模型选择框中。
## 生成视频模型与接口
- 底部 `生成视频` 面板提交到 `POST /api/editor/videos/generations`
@@ -87,6 +87,11 @@ fn normalize_editor_sound_duration(value: u8) -> Result<u8, AppError> {
Err(editor_audio_bad_request("音效 duration 必须在 2-10 秒之间"))
}
pub(super) fn normalize_creation_sound_effect_duration(value: Option<u8>) -> Result<u8, AppError> {
let duration = value.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS);
normalize_editor_sound_duration(duration)
}
pub(super) fn normalize_editor_background_music_request(
payload: assets::EditorBackgroundMusicGenerateRequest,
) -> Result<NormalizedEditorBackgroundMusicRequest, AppError> {
@@ -14,6 +14,7 @@ use crate::{
use super::{
errors::{map_platform_audio_error, parse_json_payload},
generation::normalize_creation_sound_effect_duration,
publish::publish_generated_audio_asset,
settings::require_vector_engine_audio_settings,
targets::{
@@ -82,10 +83,8 @@ pub async fn create_visual_novel_sound_effect_task(
&settings,
SoundEffectTaskRequest {
prompt: payload.prompt,
duration: payload
.duration
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10),
duration: normalize_creation_sound_effect_duration(payload.duration)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?,
seed: payload.seed,
},
)
@@ -3,7 +3,10 @@ use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings};
use super::{
errors::map_platform_audio_error, generation::normalize_creation_sound_effect_duration,
settings::require_vector_engine_audio_settings,
};
pub(super) async fn create_sound_effect_task_response(
state: &AppState,
@@ -19,9 +22,7 @@ pub(super) async fn create_sound_effect_task_response(
&settings,
SoundEffectTaskRequest {
prompt,
duration: duration
.unwrap_or(platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS)
.clamp(2, 10),
duration: normalize_creation_sound_effect_duration(duration)?,
seed,
},
)
@@ -4,7 +4,8 @@ use shared_contracts::{assets, creation_audio};
use super::{
generation::{
normalize_editor_background_music_request, normalize_editor_sound_effect_request,
normalize_creation_sound_effect_duration, normalize_editor_background_music_request,
normalize_editor_sound_effect_request,
},
publish::resolve_creation_audio_points_cost,
targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target},
@@ -138,6 +139,28 @@ fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
}
}
#[test]
fn creation_sound_effect_duration_rejects_outside_vidu_range() {
assert_eq!(
normalize_creation_sound_effect_duration(None).expect("default duration should pass"),
platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS
);
assert_eq!(
normalize_creation_sound_effect_duration(Some(2)).expect("2 seconds should pass"),
2
);
assert_eq!(
normalize_creation_sound_effect_duration(Some(10)).expect("10 seconds should pass"),
10
);
for duration in [Some(1), Some(11)] {
let error = normalize_creation_sound_effect_duration(duration)
.expect_err("duration outside Vidu 2-10 seconds should fail");
assert!(error.to_string().contains("2-10"));
}
}
#[test]
fn editor_background_music_request_forces_instrumental_and_price() {
let normalized =
@@ -1,6 +1,6 @@
use platform_audio::{
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest, SUNO_DEFAULT_MODEL,
EditorSoundEffectTaskRequest, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
AudioTaskKind, BackgroundMusicTaskRequest, EditorBackgroundMusicTaskRequest,
EditorSoundEffectTaskRequest, SUNO_DEFAULT_MODEL, VIDU_AUDIO_MODEL, VIDU_PROMPT_MAX_CHARS,
audio_mime_to_extension, build_background_music_task_body,
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
build_sound_effect_task_body, extract_audio_urls, is_failed_task_status,
@@ -135,7 +135,10 @@ fn vidu_sound_effect_request_body_uses_text2audio_contract() {
})
.expect("Vidu sound effect body should be valid");
assert_eq!(AudioTaskKind::SoundEffect.submit_path(), "/ent/v2/text2audio");
assert_eq!(
AudioTaskKind::SoundEffect.submit_path(),
"/ent/v2/text2audio"
);
assert_eq!(AudioTaskKind::SoundEffect.provider(), "vector-engine-vidu");
assert_eq!(body["model"], VIDU_AUDIO_MODEL);
assert_eq!(body["prompt"], "金币掉落叮当声");
@@ -610,6 +610,65 @@ describe('ImageCanvasEditorView', () => {
expect(screen.getByAltText('画布图片:大鱼素材')).toBeTruthy();
});
it('opens the matching audio generation panel when remodeling an existing sound effect layer', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-audio-remodel',
title: '音频改造画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-sound-effect',
resourceId: 'resource-sound-effect',
title: '游戏音效',
src: '/generated-character-drafts/editor-audios/sound.wav',
mediaType: 'audio',
assetKind: 'sound-effect',
x: 120,
y: 140,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
zIndex: 2,
sourceType: 'generated',
prompt: '金币跳出音',
model: 'audio1.0',
generationInputs: {
fields: [
{ title: '音效提示词', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
],
references: [
{ label: '历史参考图', src: '/creation-type-references/puzzle.webp' },
],
},
},
],
resources: [],
updatedAt: '2026-06-19T00:00:00.000Z',
});
render(<ImageCanvasEditorView />);
const audioLayer = await screen.findByRole('button', {
name: '选择游戏音效',
});
fireEvent.pointerDown(audioLayer, {
button: 0,
pointerId: 54,
clientX: 160,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByDisplayValue('金币跳出音')).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '音效时长 8秒' }),
).toBeTruthy();
expect(panel.querySelector('.image-canvas-editor__reference-strip')).toBeNull();
});
it('deletes the selected audio layer with Backspace from the audio control focus', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-audio-delete',
@@ -136,6 +136,25 @@ describe('ImageCanvasQuickEditPanelView', () => {
expect(screen.getByLabelText('面板状态').textContent).toBe('closed');
});
it('shows configured mud point price inside redraw submit button', () => {
render(
<QuickEditPanelHarness
initialPanel={{
sourceLayerId: 'layer-a',
prompt: '重绘提示',
size: '1024x1024',
model: 'gpt-image-2',
status: 'idle',
mode: 'redraw',
}}
/>,
);
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
);
});
it('renders extra references before the source image and removes references from the slot', () => {
render(
<QuickEditPanelHarness
@@ -225,8 +225,6 @@ export function ImageCanvasQuickEditPanelView({
>
{isGenerating ? (
'生成中'
) : isRedraw ? (
'生成'
) : (
<>
<span></span>
@@ -191,6 +191,9 @@ function GenerationWorkflowHarness({
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
: '-'}
</span>
<span data-testid="character-animation-price">
{workflow.characterAnimationPrice}
</span>
<span data-testid="fit-count">
{fitLayersMockRef.current.mock.calls.length}
</span>
@@ -424,6 +427,19 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('calculates character animation button price from the model pricing config', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开角色动画' }));
expect(screen.getByTestId('character-animation').textContent).toBe(
'layer-character:idle',
);
expect(screen.getByTestId('character-animation-price').textContent).toBe(
'40',
);
});
it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => {
render(
<GenerationWorkflowHarness
@@ -56,10 +56,12 @@ import {
} from './ImageCanvasGenerationDialogModel';
import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_MODEL,
CHARACTER_ANIMATION_DURATION_OPTIONS,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
ICON_DESCRIPTION_LIMIT,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
} from './ImageCanvasGenerationModel';
import {
@@ -282,7 +284,7 @@ export function useImageCanvasGenerationWorkflow({
: null;
const characterAnimationPrice = effectiveCharacterAnimationPanel
? calculateCharacterAnimationPrice(
'seedance2.0-fast',
CHARACTER_ANIMATION_MODEL,
effectiveCharacterAnimationPanel.resolution,
effectiveCharacterAnimationPanel.durationSeconds,
)
@@ -485,7 +487,7 @@ export function useImageCanvasGenerationWorkflow({
createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
imageModel: 'gpt-image-2',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
}),
);
activateCanvasGenerationEntry('ui-design');
@@ -594,8 +596,8 @@ export function useImageCanvasGenerationWorkflow({
return;
}
setQuickEditPanel(null);
openCanvasGenerationDialog(audioDraft);
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(audioDraft);
setActiveTool('music');
return;
}