BGM音频模块迁移回isSoundEffect
Project CI / Repository checks (pull_request) Successful in 1m26s
Project CI / Frontend tests (pull_request) Successful in 3m33s
Project CI / Backend tests (pull_request) Successful in 5m52s
Project CI / Native shell tests (pull_request) Successful in 15m1s

This commit is contained in:
2026-08-06 03:26:20 +00:00
parent 477eb1a003
commit eba8f25995
8 changed files with 890 additions and 707 deletions
@@ -2,7 +2,7 @@
日期:`2026-08-06`
状态:`已规划,尚未实施`
状态:`已实施`
## 一、目标
@@ -169,3 +169,20 @@ git diff --check
- 现有 SFX UI、Prompt、Vidu、时长、价格、校验和提交链路无回归。
- 没有实现任何 SFX V2 独有功能,没有修改需求原文或后端契约。
- 规定的定向测试、typecheck、编码检查和 `git diff --check` 全部通过。
## 十、实施结果
2026-08-06 已按本文边界完成:
- `ImageCanvasGenerationComposerView.tsx` 恢复唯一 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM 继续按稳定 dialog ID 写回,SFX 继续走原 `setGenerateDialog` 路径。
- 删除 `ImageCanvasBackgroundMusicGenerationComposerView.tsx`,保留 BGM Prompt / 预设纯模型、助手 controller 和预设跑马灯。
- 把独立组件全部用例迁入 `ImageCanvasGenerationComposerView.test.tsx` 后删除旧测试文件;总 composer 现有 50 项测试同时覆盖 BGM 完整动作矩阵和 SFX/BGM 控件、状态、dialog 切换隔离。
- 只修正 `useImageCanvasBackgroundMusicPromptAssist.ts` 的组件归属注释;没有修改 controller、surface、submission workflow、样式、后端、契约或需求原文,也没有实现 SFX V2 独有功能。
实际验证:
- Prompt / 预设 / controller / 总 composer`121/121`
- surface 与 submission workflow`72/72`
- `npm run typecheck`、变更文件 ESLint、Prettier、`npm run check:encoding``git diff --check`:全部通过。
本轮未执行浏览器视觉测试,也未创建真实音频生成任务或产生扣费;不复用 2026-08-05 的人工测试结论冒充本次验证。
@@ -6678,4 +6678,4 @@
- 决策:图片画布的 `audio-sound-effect``audio-background-music` 只保留一个 `ImageCanvasAudioGenerationComposerView`,组件内以 `isSoundEffect = dialog.mode === 'audio-sound-effect'` 分流。撤销的是完整 `ImageCanvasBackgroundMusicGenerationComposerView` 这一层视图拆分,不撤销 BGM Prompt 纯模型、助手 controller、预设模型或预设跑马灯的独立职责。
- 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。
- 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。
- 实施状态:当前代码仍保留独立 BGM composer,待按 `docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md` 落地。完成前不得把本决策写成已通过的实现结论;完成时必须迁移全部独立组件测试并同时证明 SFX 与 BGM 两个分支无状态和控件泄漏
- 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能
@@ -149,4 +149,4 @@ API 安全响应摘要:
本文前述 T6 数字与文件范围是当时实施完成后的历史记录,不因后续组件归并而改写。新的权威方向是让 SFX 与 BGM 恢复使用 `ImageCanvasGenerationComposerView.tsx` 内同一个音频 composer,并由 `isSoundEffect` 分流;详见[音频生成 Composer 恢复共享分流方案](./project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md)。
该修订目前只完成规划,尚未实施。落地时必须把 `ImageCanvasBackgroundMusicGenerationComposerView.test.tsx` 的全部覆盖迁入 `ImageCanvasGenerationComposerView.test.tsx` 后再删除旧测试文件,并同时重跑 BGM 完整动作矩阵、SFX 现有 Vidu / 时长 / Prompt 回退回归、surface 接线、submission workflow、typecheck、编码检查差异检查。不得把原 T6 的历史通过数字直接当作共享 composer 已通过的证据
该修订已于 2026-08-06 实施:`ImageCanvasGenerationComposerView.tsx` 恢复唯一共享音频 composer 和 `isSoundEffect` 分流,独立完整 BGM composer 及其测试文件已删除,原用例全部迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查差异检查通过。现有 SFX 的 Vidu、210 秒、默认 5 秒、Prompt 回退、1500 字、价格与提交路径未改;本轮未实现 SFX V2、未修改后端或需求原文,也未执行浏览器视觉测试或创建真实付费任务
@@ -1,391 +0,0 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ImageCanvasBackgroundMusicGenerationComposerView } from './ImageCanvasBackgroundMusicGenerationComposerView';
import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPresetModel';
import type { BackgroundMusicGenerationDialogState } from './ImageCanvasBackgroundMusicPromptModel';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import type {
BackgroundMusicPromptAssistComposerController,
BackgroundMusicPromptAssistDialogState,
} from './useImageCanvasBackgroundMusicPromptAssist';
type AssistStateOverrides = Partial<BackgroundMusicPromptAssistDialogState>;
function createDialog(
overrides: Partial<BackgroundMusicGenerationDialogState> = {},
): BackgroundMusicGenerationDialogState {
return {
id: 'dialog-bgm',
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
...overrides,
};
}
function createPromptAssist(
assistState: AssistStateOverrides = {},
overrides: Partial<BackgroundMusicPromptAssistComposerController> = {},
): BackgroundMusicPromptAssistComposerController {
return {
getDialogState: (dialogId: string) => ({
dialogId,
status: 'idle' as const,
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: null,
...assistState,
}),
completePrompt: vi.fn(async () => ({
started: true,
applied: true,
reason: 'applied' as const,
errorMessage: null,
})),
simplifyPrompt: vi.fn(async () => ({
started: true,
applied: true,
reason: 'applied' as const,
errorMessage: null,
})),
preparePreset: vi.fn(() => ''),
undoPrompt: vi.fn(() => true),
...overrides,
};
}
function renderComposer({
dialog = createDialog(),
promptAssist = createPromptAssist(),
onSubmit = vi.fn(),
updateCanvasGenerationDialogById = vi.fn(),
}: {
dialog?: BackgroundMusicGenerationDialogState;
promptAssist?: BackgroundMusicPromptAssistComposerController;
onSubmit?: ReturnType<typeof vi.fn>;
updateCanvasGenerationDialogById?: ReturnType<typeof vi.fn>;
} = {}) {
const view = render(
<ImageCanvasBackgroundMusicGenerationComposerView
dialog={dialog}
style={{ left: 320, top: 240 }}
promptAssist={promptAssist}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
onSubmit={onSubmit}
/>,
);
return { ...view, dialog, promptAssist, onSubmit, updateCanvasGenerationDialogById };
}
function getPanel() {
return screen.getByRole('dialog', { name: '生成游戏背景音乐' });
}
function getPromptTextarea() {
return within(getPanel()).getByRole('textbox', {
name: 'gpt_description_prompt',
}) as HTMLTextAreaElement;
}
function getActionButton(name: string) {
return within(getPanel()).getByRole('button', { name }) as HTMLButtonElement;
}
function applyDialogUpdate(
updateSpy: ReturnType<typeof vi.fn>,
dialog: BackgroundMusicGenerationDialogState,
) {
const [dialogId, updater] = updateSpy.mock.calls[
updateSpy.mock.calls.length - 1
] as [
string,
(
current: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
];
return { dialogId, nextDialog: updater(dialog) };
}
describe('ImageCanvasBackgroundMusicGenerationComposerView', () => {
beforeEach(() => {
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
});
it('counts the canonical preview without rewriting the textarea', () => {
renderComposer({
dialog: createDialog({ prompt: ' 森林冒险背景音乐 ' }),
});
const textarea = getPromptTextarea();
expect(textarea.value).toBe(' 森林冒险背景音乐 ');
expect(within(getPanel()).getByText('8 / 200')).toBeTruthy();
expect(textarea.getAttribute('aria-describedby')).toBe(
within(getPanel()).getByText('8 / 200').id,
);
expect(textarea.getAttribute('aria-invalid')).toBeNull();
expect(textarea.hasAttribute('maxlength')).toBe(false);
});
it.each([
['', false, false, false],
[' ', false, false, false],
['森', false, false, true],
['森林', true, false, true],
['森'.repeat(200), true, false, true],
['森'.repeat(201), false, true, false],
['森'.repeat(2000), false, true, false],
['森'.repeat(2001), false, false, false],
])(
'resolves the action matrix for %# by canonical code points',
(prompt, canComplete, canSimplify, canGenerate) => {
renderComposer({ dialog: createDialog({ prompt }) });
expect(getActionButton('AI 补全').disabled).toBe(!canComplete);
expect(getActionButton('一键简化').disabled).toBe(!canSimplify);
expect(getActionButton('生成游戏背景音乐').disabled).toBe(!canGenerate);
},
);
it('keeps an over-limit prompt intact and marks the counter invalid', () => {
const prompt = '森'.repeat(2001);
renderComposer({ dialog: createDialog({ prompt }) });
const textarea = getPromptTextarea();
expect(textarea.value).toBe(prompt);
expect(textarea.value).toHaveLength(2001);
expect(textarea.getAttribute('aria-invalid')).toBe('true');
expect(within(getPanel()).getByText('2001 / 200')).toBeTruthy();
});
it('routes assistant actions through the current dialog id', async () => {
const promptAssist = createPromptAssist();
renderComposer({
dialog: createDialog({ id: 'dialog-second', prompt: '森林冒险' }),
promptAssist,
});
fireEvent.click(getActionButton('AI 补全'));
expect(promptAssist.completePrompt).toHaveBeenCalledWith('dialog-second');
expect(promptAssist.simplifyPrompt).not.toHaveBeenCalled();
});
it('appends the preset text to the canonical prompt returned by preparePreset', () => {
const updateCanvasGenerationDialogById = vi.fn();
const promptAssist = createPromptAssist(
{},
{ preparePreset: vi.fn(() => '阳光动物园') },
);
const dialog = createDialog({ prompt: ' 阳光动物园 ' });
renderComposer({
dialog,
promptAssist,
updateCanvasGenerationDialogById,
});
fireEvent.click(getActionButton('展开'));
const firstPreset = BACKGROUND_MUSIC_PROMPT_PRESETS[0];
fireEvent.click(getActionButton(firstPreset.label));
expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm');
const { dialogId, nextDialog } = applyDialogUpdate(
updateCanvasGenerationDialogById,
dialog,
);
expect(dialogId).toBe('dialog-bgm');
expect(nextDialog?.prompt).toBe(`阳光动物园。${firstPreset.prompt}`);
});
it('writes nothing when preparePreset refuses the current dialog', () => {
const updateCanvasGenerationDialogById = vi.fn();
const promptAssist = createPromptAssist(
{},
{ preparePreset: vi.fn(() => null) },
);
renderComposer({ promptAssist, updateCanvasGenerationDialogById });
fireEvent.click(getActionButton('展开'));
fireEvent.click(getActionButton(BACKGROUND_MUSIC_PROMPT_PRESETS[0].label));
expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm');
expect(updateCanvasGenerationDialogById).not.toHaveBeenCalled();
});
it('shows the assistant error and the existing generation error together', () => {
renderComposer({
dialog: createDialog({
prompt: '森林冒险',
status: 'failed',
errorMessage: '生成失败',
}),
promptAssist: createPromptAssist({ errorMessage: 'AI 暂时不可用' }),
});
const alerts = within(getPanel()).getAllByRole('alert');
expect(alerts.map((alert) => alert.textContent)).toEqual([
'AI 暂时不可用',
'生成失败',
]);
});
it('keeps the fixed Suno pill and the dynamic mud point price', () => {
renderComposer({ dialog: createDialog({ prompt: '森林冒险' }) });
const modelButton = getActionButton('背景音乐模型 Suno');
expect(modelButton.disabled).toBe(true);
expect(getActionButton('生成游戏背景音乐').textContent).toBe('生成12泥点');
});
it('submits the current dialog only when generation is allowed', () => {
const onSubmit = vi.fn();
const dialog = createDialog({ prompt: '森林冒险' });
renderComposer({ dialog, onSubmit });
fireEvent.submit(getPanel());
expect(onSubmit).toHaveBeenCalledWith(dialog);
onSubmit.mockClear();
const blocked = renderComposer({
dialog: createDialog({ prompt: ' ' }),
onSubmit,
});
fireEvent.submit(
within(blocked.container).getByRole('dialog', {
name: '生成游戏背景音乐',
}),
);
expect(onSubmit).not.toHaveBeenCalled();
});
it.each([
['completing' as const],
['simplifying' as const],
['submitting' as const],
])('locks every control while the panel is %s', (status) => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险' }),
promptAssist: createPromptAssist({
status,
operationId: 'operation-1',
temporaryPromptSnapshot: '森林冒险',
}),
});
const panel = getPanel();
expect(panel.getAttribute('aria-busy')).toBe('true');
expect(getPromptTextarea().readOnly).toBe(true);
expect(getActionButton('AI 补全').disabled).toBe(true);
expect(getActionButton('一键简化').disabled).toBe(true);
expect(getActionButton('撤销').disabled).toBe(true);
expect(getActionButton('展开').disabled).toBe(true);
expect(getActionButton('生成游戏背景音乐').disabled).toBe(true);
});
it('locks the panel while the existing generation placeholder is running', () => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险', status: 'generating' }),
});
expect(getPanel().getAttribute('aria-busy')).toBe('true');
expect(getPromptTextarea().readOnly).toBe(true);
expect(getActionButton('AI 补全').disabled).toBe(true);
expect(getActionButton('展开').disabled).toBe(true);
expect(getActionButton('生成游戏背景音乐').textContent).toBe('生成中');
});
describe('undo button matrix', () => {
it('stays hidden without any snapshot', () => {
renderComposer({ dialog: createDialog({ prompt: '森林冒险' }) });
expect(
within(getPanel()).queryByRole('button', { name: '撤销' }),
).toBeNull();
});
it.each([['completing' as const], ['simplifying' as const]])(
'shows a disabled undo while %s holds only the temporary snapshot',
(status) => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险' }),
promptAssist: createPromptAssist({
status,
operationId: 'operation-1',
undoPromptSnapshot: null,
temporaryPromptSnapshot: '森林冒险',
}),
});
const undoButton = getActionButton('撤销');
expect(undoButton.disabled).toBe(true);
},
);
it('enables undo after a successful assistant result and after manual edits', () => {
const promptAssist = createPromptAssist({
undoPromptSnapshot: '森林冒险',
});
renderComposer({
dialog: createDialog({ prompt: '完整的森林冒险背景音乐,手动改过' }),
promptAssist,
});
const undoButton = getActionButton('撤销');
expect(undoButton.disabled).toBe(false);
fireEvent.click(undoButton);
expect(promptAssist.undoPrompt).toHaveBeenCalledWith('dialog-bgm');
});
it('hides undo again after a failed assistant round clears both snapshots', () => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险' }),
promptAssist: createPromptAssist({
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: 'AI 暂时不可用',
}),
});
expect(
within(getPanel()).queryByRole('button', { name: '撤销' }),
).toBeNull();
});
it('keeps undo visible but disabled while submitting with a snapshot', () => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险' }),
promptAssist: createPromptAssist({
status: 'submitting',
operationId: 'operation-1',
undoPromptSnapshot: '森林冒险',
}),
});
expect(getActionButton('撤销').disabled).toBe(true);
});
it('hides undo while submitting without a snapshot', () => {
renderComposer({
dialog: createDialog({ prompt: '森林冒险' }),
promptAssist: createPromptAssist({
status: 'submitting',
operationId: 'operation-1',
undoPromptSnapshot: null,
}),
});
expect(
within(getPanel()).queryByRole('button', { name: '撤销' }),
).toBeNull();
});
});
});
@@ -1,264 +0,0 @@
import { Music } from 'lucide-react';
import { type CSSProperties, useId } from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBackgroundMusicPresetMarquee } from './ImageCanvasBackgroundMusicPresetMarquee';
import {
appendBackgroundMusicPromptPreset,
BACKGROUND_MUSIC_PROMPT_PRESETS,
type BackgroundMusicPromptPreset,
} from './ImageCanvasBackgroundMusicPresetModel';
import {
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS,
type BackgroundMusicGenerationDialogState,
canCompleteBackgroundMusicPrompt,
canGenerateBackgroundMusicFromPrompt,
canSimplifyBackgroundMusicPrompt,
countPromptCodePoints,
} from './ImageCanvasBackgroundMusicPromptModel';
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
calculateEditorBackgroundMusicPrice,
DEFAULT_BACKGROUND_MUSIC_MODEL,
} from './ImageCanvasGenerationModel';
import type { BackgroundMusicPromptAssistComposerController } from './useImageCanvasBackgroundMusicPromptAssist';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
const BACKGROUND_MUSIC_COMPOSER_LABEL = '生成游戏背景音乐';
const BACKGROUND_MUSIC_MODEL_LABEL = 'Suno';
export function ImageCanvasBackgroundMusicGenerationComposerView({
dialog,
style,
promptAssist,
updateCanvasGenerationDialogById,
onSubmit,
}: {
dialog: BackgroundMusicGenerationDialogState;
style: CSSProperties;
promptAssist: BackgroundMusicPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
) => void;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const promptCounterId = useId();
const assistState = promptAssist.getDialogState(dialog.id);
const isAiProcessing =
assistState.status === 'completing' || assistState.status === 'simplifying';
const isSubmitting = assistState.status === 'submitting';
const isGenerating = dialog.status === 'generating';
// 7.4:AI 处理、提交锁和现有生成占位都锁当前面板,但都不锁画布或其它 dialog。
const isLocked = isAiProcessing || isSubmitting || isGenerating;
// 计数与动作状态读 canonical preview,不在输入事件里改写输入框。
const promptCodePointCount = countPromptCodePoints(dialog.prompt);
const isPromptOverLimit =
promptCodePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS;
const canComplete =
!isLocked && canCompleteBackgroundMusicPrompt(dialog.prompt);
const canSimplify =
!isLocked && canSimplifyBackgroundMusicPrompt(dialog.prompt);
const canGenerate =
!isLocked && canGenerateBackgroundMusicFromPrompt(dialog.prompt);
// 7.3.1:发起 AI 操作时可撤销快照被本次临时快照取代,因此“处理中没有可撤销快照”
// 不等于“没有快照”,处理期间必须显示并禁用,而不是隐藏。
const isUndoVisible =
assistState.undoPromptSnapshot !== null ||
assistState.temporaryPromptSnapshot !== null;
const canUndo = isUndoVisible && !isLocked;
const cost = calculateEditorBackgroundMusicPrice(
DEFAULT_BACKGROUND_MUSIC_MODEL,
);
const updateBackgroundMusicPrompt = (prompt: string) => {
updateCanvasGenerationDialogById(dialog.id, (currentDialog) => ({
...currentDialog,
prompt,
status: currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}));
};
const handlePresetClick = (preset: BackgroundMusicPromptPreset) => {
// controller 先 canonicalize 并写回,同时清除旧撤销快照;返回 null 表示当前
// dialog 不允许写入(已关闭、已删除或处于提交锁),此时不得继续写 Prompt。
const canonicalPrompt = promptAssist.preparePreset(dialog.id);
if (canonicalPrompt === null) {
return;
}
updateBackgroundMusicPrompt(
appendBackgroundMusicPromptPreset(canonicalPrompt, preset),
);
};
return (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--background-music"
style={style}
role="dialog"
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
// 7.4 把现有 generating 占位也算作锁定状态,忙碌上报必须跟着 isLocked 走。
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!canGenerate) {
return;
}
onSubmit(dialog);
}}
>
<PlatformTextField
variant="textarea"
aria-label="gpt_description_prompt"
aria-describedby={promptCounterId}
aria-invalid={isPromptOverLimit || undefined}
value={dialog.prompt}
readOnly={isLocked}
placeholder="你希望生成什么音乐?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateBackgroundMusicPrompt(event.target.value)}
/>
<div className="image-canvas-editor__background-music-prompt-actions">
<span
id={promptCounterId}
className={
isPromptOverLimit
? 'image-canvas-editor__background-music-prompt-count image-canvas-editor__background-music-prompt-count--over-limit'
: 'image-canvas-editor__background-music-prompt-count'
}
>
{`${promptCodePointCount} / ${BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS}`}
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
// 可访问名称保持稳定,处理中只改可见文案,避免读屏和测试跟着状态漂移。
aria-label="AI 补全"
disabled={!canComplete}
onClick={() => {
void promptAssist.completePrompt(dialog.id);
}}
>
{assistState.status === 'completing' ? '补全中' : 'AI 补全'}
</PlatformActionButton>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
aria-label="一键简化"
disabled={!canSimplify}
onClick={() => {
void promptAssist.simplifyPrompt(dialog.id);
}}
>
{assistState.status === 'simplifying' ? '简化中' : '一键简化'}
</PlatformActionButton>
{isUndoVisible ? (
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
disabled={!canUndo}
onClick={() => {
promptAssist.undoPrompt(dialog.id);
}}
>
</PlatformActionButton>
) : null}
</div>
<ImageCanvasBackgroundMusicPresetMarquee
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
isLocked={isLocked}
onSelectPreset={handlePresetClick}
/>
{assistState.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{assistState.errorMessage}
</PlatformStatusMessage>
) : null}
{dialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{dialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`背景音乐模型 ${BACKGROUND_MUSIC_MODEL_LABEL}`}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{BACKGROUND_MUSIC_MODEL_LABEL}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!canGenerate}
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
>
{isGenerating ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{cost}
</span>
</>
)}
</PlatformActionButton>
</div>
</form>
);
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import {
type RefObject,
type SetStateAction,
useCallback,
useId,
useRef,
useState,
} from 'react';
@@ -18,8 +19,21 @@ import {
} from '../common/PlatformFloatingMenu';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ImageCanvasBackgroundMusicGenerationComposerView } from './ImageCanvasBackgroundMusicGenerationComposerView';
import { toBackgroundMusicGenerationDialog } from './ImageCanvasBackgroundMusicPromptModel';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasBackgroundMusicPresetMarquee } from './ImageCanvasBackgroundMusicPresetMarquee';
import {
appendBackgroundMusicPromptPreset,
BACKGROUND_MUSIC_PROMPT_PRESETS,
type BackgroundMusicPromptPreset,
} from './ImageCanvasBackgroundMusicPresetModel';
import {
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS,
canCompleteBackgroundMusicPrompt,
canGenerateBackgroundMusicFromPrompt,
canSimplifyBackgroundMusicPrompt,
countPromptCodePoints,
toBackgroundMusicGenerationDialog,
} from './ImageCanvasBackgroundMusicPromptModel';
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
@@ -38,8 +52,10 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateEditorBackgroundMusicPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_ASPECT_RATIO,
@@ -761,16 +777,21 @@ function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
};
}
const BACKGROUND_MUSIC_COMPOSER_LABEL = '生成游戏背景音乐';
const BACKGROUND_MUSIC_MODEL_LABEL = 'Suno';
/**
* 音效 composer。BGM 已经拆到 `ImageCanvasBackgroundMusicGenerationComposerView`
* 本组件只服务 `audio-sound-effect`Vidu 契约、时长、默认 Prompt 和价格行为保持不变
* 音效与背景音乐共用同一个 composer 边界;视图内按 mode 分流,业务规则、
* Prompt 写回和锁定语义仍分别保持
*/
function ImageCanvasSoundEffectGenerationComposerView({
function ImageCanvasAudioGenerationComposerView({
dialog,
style,
setGenerateDialog,
renderEditorPortal,
buildPortalMenuStyle,
promptAssist,
updateCanvasGenerationDialogById,
onSubmit,
}: {
dialog: GenerateDialogState;
@@ -781,12 +802,256 @@ function ImageCanvasSoundEffectGenerationComposerView({
anchor: HTMLElement | null,
placement: 'above' | 'below',
) => CSSProperties;
promptAssist?: BackgroundMusicPromptAssistComposerController;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => void;
onSubmit: (dialog: GenerateDialogState) => void;
}) {
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
const promptCounterId = useId();
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundEffect && isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
});
if (!isSoundEffect) {
const backgroundMusicDialog = toBackgroundMusicGenerationDialog(dialog);
const backgroundMusicPromptAssist = promptAssist;
if (!backgroundMusicDialog || !backgroundMusicPromptAssist) {
return null;
}
const assistState = backgroundMusicPromptAssist.getDialogState(
backgroundMusicDialog.id,
);
const isAiProcessing =
assistState.status === 'completing' ||
assistState.status === 'simplifying';
const isSubmitting = assistState.status === 'submitting';
// AI 处理、提交锁和现有生成占位都只锁当前 BGM 面板。
const isLocked = isAiProcessing || isSubmitting || isGenerating;
// 计数与动作状态读取 canonical preview,不在输入事件里改写输入框。
const promptCodePointCount = countPromptCodePoints(
backgroundMusicDialog.prompt,
);
const isPromptOverLimit =
promptCodePointCount > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS;
const canComplete =
!isLocked &&
canCompleteBackgroundMusicPrompt(backgroundMusicDialog.prompt);
const canSimplify =
!isLocked &&
canSimplifyBackgroundMusicPrompt(backgroundMusicDialog.prompt);
const canGenerate =
!isLocked &&
canGenerateBackgroundMusicFromPrompt(backgroundMusicDialog.prompt);
// AI 处理中临时快照仍需让撤销按钮留在可访问树中,但必须禁用。
const isUndoVisible =
assistState.undoPromptSnapshot !== null ||
assistState.temporaryPromptSnapshot !== null;
const canUndo = isUndoVisible && !isLocked;
const cost = calculateEditorBackgroundMusicPrice(
DEFAULT_BACKGROUND_MUSIC_MODEL,
);
const updateBackgroundMusicPrompt = (prompt: string) => {
updateCanvasGenerationDialogById(
backgroundMusicDialog.id,
(currentDialog) => ({
...currentDialog,
prompt,
status:
currentDialog.status === 'failed' ? 'idle' : currentDialog.status,
errorMessage:
currentDialog.status === 'failed'
? undefined
: currentDialog.errorMessage,
}),
);
};
const handlePresetClick = (preset: BackgroundMusicPromptPreset) => {
const canonicalPrompt = backgroundMusicPromptAssist.preparePreset(
backgroundMusicDialog.id,
);
if (canonicalPrompt === null) {
return;
}
updateBackgroundMusicPrompt(
appendBackgroundMusicPromptPreset(canonicalPrompt, preset),
);
};
return (
<form
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--image image-canvas-editor__generation-composer--audio image-canvas-editor__generation-composer--background-music"
style={style}
role="dialog"
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
aria-busy={isLocked}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
if (!canGenerate) {
return;
}
onSubmit(backgroundMusicDialog);
}}
>
<PlatformTextField
variant="textarea"
aria-label="gpt_description_prompt"
aria-describedby={promptCounterId}
aria-invalid={isPromptOverLimit || undefined}
value={backgroundMusicDialog.prompt}
readOnly={isLocked}
placeholder="你希望生成什么音乐?"
size="sm"
density="compact"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateBackgroundMusicPrompt(event.target.value)}
/>
<div className="image-canvas-editor__background-music-prompt-actions">
<span
id={promptCounterId}
className={
isPromptOverLimit
? 'image-canvas-editor__background-music-prompt-count image-canvas-editor__background-music-prompt-count--over-limit'
: 'image-canvas-editor__background-music-prompt-count'
}
>
{`${promptCodePointCount} / ${BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS}`}
</span>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
aria-label="AI 补全"
disabled={!canComplete}
onClick={() => {
void backgroundMusicPromptAssist.completePrompt(
backgroundMusicDialog.id,
);
}}
>
{assistState.status === 'completing' ? '补全中' : 'AI 补全'}
</PlatformActionButton>
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
aria-label="一键简化"
disabled={!canSimplify}
onClick={() => {
void backgroundMusicPromptAssist.simplifyPrompt(
backgroundMusicDialog.id,
);
}}
>
{assistState.status === 'simplifying' ? '简化中' : '一键简化'}
</PlatformActionButton>
{isUndoVisible ? (
<PlatformActionButton
type="button"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__background-music-prompt-action"
disabled={!canUndo}
onClick={() => {
backgroundMusicPromptAssist.undoPrompt(
backgroundMusicDialog.id,
);
}}
>
</PlatformActionButton>
) : null}
</div>
<ImageCanvasBackgroundMusicPresetMarquee
presets={BACKGROUND_MUSIC_PROMPT_PRESETS}
isLocked={isLocked}
onSelectPreset={handlePresetClick}
/>
{assistState.errorMessage ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{assistState.errorMessage}
</PlatformStatusMessage>
) : null}
{backgroundMusicDialog.status === 'failed' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role="alert"
>
{backgroundMusicDialog.errorMessage}
</PlatformStatusMessage>
) : null}
<div className="image-canvas-editor__generation-composer-footer">
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`背景音乐模型 ${BACKGROUND_MUSIC_MODEL_LABEL}`}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{BACKGROUND_MUSIC_MODEL_LABEL}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!canGenerate}
aria-label={BACKGROUND_MUSIC_COMPOSER_LABEL}
>
{isGenerating ? (
'生成中'
) : (
<>
<span></span>
<span className="image-canvas-editor__mud-point-inline">
{cost}
</span>
</>
)}
</PlatformActionButton>
</div>
</form>
);
}
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const currentSoundModel =
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
@@ -799,12 +1064,6 @@ function ImageCanvasSoundEffectGenerationComposerView({
const fixedModelAriaLabel = `音效模型 ${fixedModelLabel}`;
const cost = calculateEditorSoundEffectPrice(soundModel);
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundOptionsOpen,
boundaryRefs: [soundOptionsButtonRef],
onDismiss: () => setIsSoundOptionsOpen(false),
});
const updateAudioDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === dialog.mode
@@ -840,9 +1099,7 @@ function ImageCanvasSoundEffectGenerationComposerView({
aria-label="prompt"
value={dialog.prompt}
disabled={isGenerating}
placeholder={
isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?'
}
placeholder="你希望生成什么音效?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
@@ -1019,8 +1276,6 @@ export function ImageCanvasGenerationComposerView({
onSpecMenuPointerEnter,
onSpecMenuPointerLeave,
}: ImageCanvasGenerationComposerViewProps) {
const backgroundMusicDialog =
toBackgroundMusicGenerationDialog(generateDialog);
const isPerfectPixelDialog = Boolean(
generateDialog?.perfectPixelOperation ||
generateDialog?.perfectPixelOperationInvalid,
@@ -1182,30 +1437,19 @@ export function ImageCanvasGenerationComposerView({
/>
) : null}
{generateDialog?.mode === 'audio-sound-effect' &&
{(generateDialog?.mode === 'audio-sound-effect' ||
generateDialog?.mode === 'audio-background-music') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<ImageCanvasSoundEffectGenerationComposerView
<ImageCanvasAudioGenerationComposerView
// dialog ID 与 mode 共同隔离 BGM 预设轨道和 SFX 时长菜单的局部状态。
key={`${generateDialog.mode}:${generateDialog.id ?? 'without-id'}`}
dialog={generateDialog}
style={generationComposerStyle}
promptAssist={backgroundMusicPromptAssist}
setGenerateDialog={setGenerateDialog}
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onSubmit={onSubmitImageGeneration}
/>
) : null}
{backgroundMusicDialog &&
backgroundMusicDialog.composerOpen !== false &&
generationComposerStyle &&
backgroundMusicPromptAssist ? (
<ImageCanvasBackgroundMusicGenerationComposerView
// dialog ID 作为 key:连续激活两个 BGM dialog 时不复用展开、滚动、
// hover 或触摸状态。
key={backgroundMusicDialog.id}
dialog={backgroundMusicDialog}
style={generationComposerStyle}
promptAssist={backgroundMusicPromptAssist}
updateCanvasGenerationDialogById={updateCanvasGenerationDialogById}
onSubmit={onSubmitImageGeneration}
/>
@@ -59,7 +59,7 @@ export type BackgroundMusicPromptAssistController = ReturnType<
>;
/**
* T4 composer 只需要读状态和触发四个用户动作。`beginSubmission` / `finishSubmission`
* 共享音频 composer 的 BGM 分支只需要读状态和触发四个用户动作。`beginSubmission` / `finishSubmission`
* 留给 T5 的正式提交链路,不进入视图 props。
*/
export type BackgroundMusicPromptAssistComposerController = Pick<