修复音频生成面板布局

对齐生成游戏音效面板底部参数、模型与生成按钮布局。

固定展示音效 Vidu 模型和背景音乐 Suno 模型。

补充音频生成面板布局回归测试并更新设计文档。
This commit is contained in:
2026-06-19 13:30:40 +08:00
parent fdcccec385
commit 23a6662c62
4 changed files with 104 additions and 63 deletions
@@ -12,6 +12,8 @@
- `生成图标素材`
- `生成UI设计图`
- `生成视频`
- `生成游戏音效`
- `生成游戏背景音乐`
- `生成角色动作`
- `快速编辑`
@@ -49,8 +51,8 @@
```
- 面板内不显示 `画面比例``大小尺寸``模型` 等字段标题,只显示当前选择值。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 type 和 BPM 合并成一个无标题左下角选项框。
- 模型选项框和生成按钮位于右下角。
- 图片类面板把画面比例和大小尺寸合并成一个左下角选项框;视频面板把比例、时长、清晰度合并成一个左下角选项框;音效面板把 Vidu duration 合并成一个无标题左下角选项框。
- 模型选项框和生成按钮位于右下角,模型胶囊紧贴生成按钮;音效固定显示 `Vidu`,背景音乐固定显示 `Suno`,两者均沿用模型按钮样式但不可点击
- 点击后以页面级浮层向上弹出独立选项面板;父级面板隐藏或销毁时,选项面板同步销毁。
- 点击选项只更新字段和选中样式,不收起选项面板;再次点击当前参数按钮可收起。
- 选项面板打开后,点击父级生成面板内任意非选项框、非触发按钮区域必须收起;点击选项面板自身仍保持打开,便于连续修改。
@@ -58,7 +60,7 @@
- 模型子面板每行固定一个模型,不用方框包裹模型名;模型名不换行,前置对应模型类型图标,选中项在模型名后用对号标记。
- 比例选项卡片内展示对应比例的线框。
- 父级面板、底部选项框、弹出子面板字号保持一致。
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p``单次·120BPM`
- 底部组合值使用 `·` 分隔,例如 `16:9 · 4秒 · 480p``16:9 · 1K`
- 底部参数热区与生成按钮等高,默认不显示阴影;悬停显示轻量阴影;箭头默认向下,展开后旋转向上。
- 底边栏中会在上方弹出二级选项的入口不再依赖点击展开。鼠标悬停到入口即可打开二级面板,鼠标离开入口和二级面板后自动收起;当前范围包括 `生成规范``生成音乐`
- 底边栏二级选项面板必须锚定到对应入口按钮本身,不使用屏幕居中或固定底部偏移;移动端窄屏下也应保持跟随入口位置。
@@ -185,7 +187,8 @@
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后固定用 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成 spritesheet,再复用图标素材拆分流程,把 spritesheet 图集和拆分素材都放到画布。
- 生成游戏音效面板底部不显示 `type` / `tempo` 字段标题,只有一个音效参数按钮type 只支持 `one-shot` / `loop`BPM 为 `1-300` 的数字或 `null`,选择后按钮显示 `单次·120BPM` 这类组合值
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
- Seedance 2.0 参考视频 / 音频上传不产生 `data:video/*` / 大体积音频 JSON;提交请求优先携带 `objectKey`,后端 Ark body 含 `generate_audio:false` 且不含未确认的 `mode` 字段。
@@ -13,7 +13,7 @@
- `生成游戏音效`
- `生成游戏背景音乐`
3. 选择某一项后创建独立 `generation-dialog` 画布生成对象,并通过现有 placement 模型避让已有图层和占位。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 生成按钮区,不写规则说明类文案。
4. 面板 UI 复用 `生成角色形象` 的紧凑结构:上方为字段区,底部为参数 / 模型 / 生成按钮区,不写规则说明类文案。音效参数按钮靠左下角,固定模型胶囊紧贴生成按钮;背景音乐同样在右下角显示固定模型胶囊并紧贴生成按钮。
5. 生成中隐藏设置面板,只保留画布中的音频生成占位;失败后恢复面板并展示短错误。
## 面板字段
@@ -21,7 +21,7 @@
### 生成游戏音效
- `prompt`:用户输入的音效提示词,直接作为 Vidu 文生音频请求的 `prompt` 字段。
- `model`:固定默认 `audio1.0`UI 显示为 `Vidu`;暂不展示 Suno 文生音效模型入口。
- `model`:固定默认 `audio1.0`UI 以禁用态模型胶囊显示为 `Vidu`,位置紧贴生成按钮;暂不展示 Suno 文生音效模型入口。
- `duration`:Vidu 音频时长,UI 以一个无标题参数按钮展示,选项范围 `2-10` 秒,默认 `5` 秒。
- 提交到 VectorEngine 时映射为 Vidu 文生音频字段:`model: "audio1.0"``prompt``duration`、可选 `seed`。当前编辑器音效固定使用 Vidu `audio1.0`,不再走 Suno `task: "sound"` / `metadata_params` 文生音效契约。
@@ -29,7 +29,7 @@
- `gpt_description_prompt`:用户输入的背景音乐提示词。
- `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv``gpt_description_prompt``make_instrumental: true``mv` 后端固定使用默认 Suno 模型,不在 UI 中展示
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv``gpt_description_prompt``make_instrumental: true``mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`
- `gpt_description_prompt` 按 Apifox 契约限制 200 字,超出时由 BFF 返回参数错误。
## 画布数据
@@ -113,10 +113,10 @@ POST /api/editor/audios/background-music/generations
- 底部工具栏显示 `生成音乐`
- 点击 `生成音乐` 只出现选项框,不立刻创建占位。
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部只有一个无标题时长参数按钮。
- 点击 `生成游戏音效` 后出现音效面板,文本字段为 `prompt`;底部左侧只有一个无标题时长参数按钮,右侧为固定 `Vidu` 模型胶囊和生成按钮
- 音效时长只提供 `2-10` 秒选项,默认 `5` 秒,提交到 BFF 的字段为 `duration`
- 音效面板模型显示 `Vidu`,提交 `model: "audio1.0"`;不显示 Suno 文生音效模型或 Suno 音效入口。
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,不展示 `make_instrumental`
- 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`
- 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`
- 成功后画布新增音频卡,能通过 `<audio controls>` 播放。
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,播放控件固定在卡片底部。
@@ -517,8 +517,25 @@ describe('ImageCanvasGenerationComposerView', () => {
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
const footer = panel.querySelector(
'.image-canvas-editor__generation-composer-footer',
);
expect(footer).toBeTruthy();
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__option-popover-anchor--dimensions',
);
expect(footer?.children[1]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[2]?.className).toContain(
'image-canvas-editor__generation-submit',
);
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
const soundModelButton = within(panel).getByRole('button', {
name: '音效模型 Vidu',
});
expect((soundModelButton as HTMLButtonElement).disabled).toBe(true);
expect(within(panel).getByText('Vidu')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
@@ -570,6 +587,23 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(
within(panel).getByRole('textbox', { name: 'gpt_description_prompt' }),
).toBeTruthy();
const footer = panel.querySelector(
'.image-canvas-editor__generation-composer-footer',
);
expect(footer).toBeTruthy();
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[1]?.className).toContain(
'image-canvas-editor__generation-submit',
);
const backgroundMusicModelButton = within(panel).getByRole('button', {
name: '背景音乐模型 Suno',
});
expect((backgroundMusicModelButton as HTMLButtonElement).disabled).toBe(
true,
);
expect(within(panel).getByText('Suno')).toBeTruthy();
expect(within(panel).queryByText('make_instrumental')).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
'生成5泥点',
@@ -670,6 +670,10 @@ function ImageCanvasAudioGenerationComposerView({
const soundDuration = normalizeSoundDuration(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}`;
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const fixedModelLabel = isSoundEffect ? currentSoundModel.label : 'Suno';
const fixedModelAriaLabel = isSoundEffect
? `音效模型 ${fixedModelLabel}`
: `背景音乐模型 ${fixedModelLabel}`;
const cost = isSoundEffect
? calculateEditorSoundEffectPrice(soundModel)
: calculateEditorBackgroundMusicPrice(DEFAULT_BACKGROUND_MUSIC_MODEL);
@@ -737,65 +741,65 @@ function ImageCanvasAudioGenerationComposerView({
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`音效模型 ${currentSoundModel.label}`}
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{currentSoundModel.label}</span>
</span>
{soundOptionLabel}
</PlatformInlineOptionButton>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}`}
onClick={() => updateSoundDuration(option)}
>
{option}
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
</>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}`}
onClick={() => updateSoundDuration(option)}
>
{option}
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={fixedModelAriaLabel}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{fixedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"