diff --git a/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md b/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md index 3f3cf17f7..f1c987379 100644 --- a/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md +++ b/docs/project-memory/plans/【前端重构】音频生成面板恢复共享分流方案-2026-08-06.md @@ -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 的人工测试结论冒充本次验证。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7edd5d45a..badcdc5ec 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 独有功能。 diff --git a/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md b/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md index 1e6fab090..ab087fd55 100644 --- a/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md +++ b/docs/【实施记录】BGM生成提示词优化T6测试与发布门禁-2026-08-05.md @@ -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、2–10 秒、默认 5 秒、Prompt 回退、1500 字、价格与提交路径未改;本轮未实现 SFX V2、未修改后端或需求原文,也未执行浏览器视觉测试或创建真实付费任务。 diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.test.tsx deleted file mode 100644 index 43d805a0a..000000000 --- a/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.test.tsx +++ /dev/null @@ -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; - -function createDialog( - overrides: Partial = {}, -): BackgroundMusicGenerationDialogState { - return { - id: 'dialog-bgm', - mode: 'audio-background-music', - prompt: '', - status: 'idle', - composerOpen: true, - ...overrides, - }; -} - -function createPromptAssist( - assistState: AssistStateOverrides = {}, - overrides: Partial = {}, -): 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; - updateCanvasGenerationDialogById?: ReturnType; -} = {}) { - const view = render( - , - ); - - 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, - 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(); - }); - }); -}); diff --git a/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.tsx deleted file mode 100644 index 7ff307d11..000000000 --- a/src/components/image-editor/ImageCanvasBackgroundMusicGenerationComposerView.tsx +++ /dev/null @@ -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 ( -
event.stopPropagation()} - onSubmit={(event) => { - event.preventDefault(); - if (!canGenerate) { - return; - } - onSubmit(dialog); - }} - > - updateBackgroundMusicPrompt(event.target.value)} - /> -
- - {`${promptCodePointCount} / ${BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS}`} - - { - void promptAssist.completePrompt(dialog.id); - }} - > - {assistState.status === 'completing' ? '补全中' : 'AI 补全'} - - { - void promptAssist.simplifyPrompt(dialog.id); - }} - > - {assistState.status === 'simplifying' ? '简化中' : '一键简化'} - - {isUndoVisible ? ( - { - promptAssist.undoPrompt(dialog.id); - }} - > - 撤销 - - ) : null} -
- - {assistState.errorMessage ? ( - - {assistState.errorMessage} - - ) : null} - {dialog.status === 'failed' ? ( - - {dialog.errorMessage} - - ) : null} -
-
- - - - {BACKGROUND_MUSIC_MODEL_LABEL} - - -
- - {isGenerating ? ( - '生成中' - ) : ( - <> - 生成 - - {cost}泥点 - - - )} - -
- - ); -} diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index cb11b3fdb..f503c1c64 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -8,10 +8,19 @@ import { type SetStateAction, useState, } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPresetModel'; +import type { BackgroundMusicGenerationDialogState } from './ImageCanvasBackgroundMusicPromptModel'; +import type { + CanvasGenerationDialogState, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView'; +import type { + BackgroundMusicPromptAssistComposerController, + BackgroundMusicPromptAssistDialogState, +} from './useImageCanvasBackgroundMusicPromptAssist'; function mockStateSetter() { return vi.fn() as unknown as Dispatch>; @@ -116,11 +125,25 @@ function renderComposer( return render(); } -function createBackgroundMusicPromptAssistStub(): NonNullable< - ComponentProps< - typeof ImageCanvasGenerationComposerView - >['backgroundMusicPromptAssist'] -> { +type AssistStateOverrides = Partial; + +function createBackgroundMusicDialog( + overrides: Partial = {}, +): BackgroundMusicGenerationDialogState { + return { + id: 'dialog-bgm', + mode: 'audio-background-music', + prompt: '', + status: 'idle', + composerOpen: true, + ...overrides, + }; +} + +function createBackgroundMusicPromptAssistStub( + assistState: AssistStateOverrides = {}, + overrides: Partial = {}, +): BackgroundMusicPromptAssistComposerController { return { getDialogState: (dialogId: string) => ({ dialogId, @@ -129,24 +152,83 @@ function createBackgroundMusicPromptAssistStub(): NonNullable< undoPromptSnapshot: null, temporaryPromptSnapshot: null, errorMessage: null, + ...assistState, }), completePrompt: vi.fn(async () => ({ - started: false, - applied: false, - reason: 'ineligible' as const, + started: true, + applied: true, + reason: 'applied' as const, errorMessage: null, })), simplifyPrompt: vi.fn(async () => ({ - started: false, - applied: false, - reason: 'ineligible' as const, + started: true, + applied: true, + reason: 'applied' as const, errorMessage: null, })), - preparePreset: vi.fn(() => null), - undoPrompt: vi.fn(() => false), + preparePreset: vi.fn(() => ''), + undoPrompt: vi.fn(() => true), + ...overrides, }; } +function renderBackgroundMusicComposer({ + dialog = createBackgroundMusicDialog(), + promptAssist = createBackgroundMusicPromptAssistStub(), + onSubmit = vi.fn(), + updateCanvasGenerationDialogById = vi.fn(), +}: { + dialog?: BackgroundMusicGenerationDialogState; + promptAssist?: BackgroundMusicPromptAssistComposerController; + onSubmit?: ReturnType; + updateCanvasGenerationDialogById?: ReturnType; +} = {}) { + const view = renderComposer(dialog, { + backgroundMusicPromptAssist: promptAssist, + updateCanvasGenerationDialogById, + onSubmitImageGeneration: onSubmit, + }); + + return { + ...view, + dialog, + promptAssist, + onSubmit, + updateCanvasGenerationDialogById, + }; +} + +function getBackgroundMusicPanel() { + return screen.getByRole('dialog', { name: '生成游戏背景音乐' }); +} + +function getBackgroundMusicPromptTextarea() { + return within(getBackgroundMusicPanel()).getByRole('textbox', { + name: 'gpt_description_prompt', + }) as HTMLTextAreaElement; +} + +function getBackgroundMusicActionButton(name: string) { + return within(getBackgroundMusicPanel()).getByRole('button', { + name, + }) as HTMLButtonElement; +} + +function applyBackgroundMusicDialogUpdate( + updateSpy: ReturnType, + 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('ImageCanvasGenerationComposerView', () => { it.each([ { @@ -854,6 +936,14 @@ describe('ImageCanvasGenerationComposerView', () => { expect(within(panel).queryByText('单次')).toBeNull(); expect(within(panel).queryByText('循环')).toBeNull(); expect(within(panel).queryByText(/BPM/u)).toBeNull(); + expect(within(panel).queryByRole('button', { name: 'AI 补全' })).toBeNull(); + expect( + within(panel).queryByRole('button', { name: '一键简化' }), + ).toBeNull(); + expect( + within(panel).queryByRole('button', { name: '一键优化' }), + ).toBeNull(); + expect(within(panel).queryByRole('button', { name: '展开' })).toBeNull(); expect( within(panel).getByRole('button', { name: '生成游戏音效' }).textContent, ).toBe('生成5泥点'); @@ -922,12 +1012,499 @@ describe('ImageCanvasGenerationComposerView', () => { true, ); expect(within(panel).getByText('Suno')).toBeTruthy(); + expect(within(panel).queryByText('Vidu')).toBeNull(); + expect( + within(panel).queryByRole('button', { name: /音效时长/u }), + ).toBeNull(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); expect( within(panel).getByRole('button', { name: '生成游戏背景音乐' }) .textContent, ).toBe('生成12泥点'); }); + + describe('共享音频 composer 的背景音乐行为', () => { + beforeEach(() => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + it('按 canonical preview 计数但不改写输入框原文', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ + prompt: ' 森林冒险背景音乐 ', + }), + }); + + const textarea = getBackgroundMusicPromptTextarea(); + expect(textarea.value).toBe(' 森林冒险背景音乐 '); + expect( + within(getBackgroundMusicPanel()).getByText('8 / 200'), + ).toBeTruthy(); + expect(textarea.getAttribute('aria-describedby')).toBe( + within(getBackgroundMusicPanel()).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], + ])( + '按 canonical code point 解析第 %# 组动作资格', + (prompt, canComplete, canSimplify, canGenerate) => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt }), + }); + + expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe( + !canComplete, + ); + expect(getBackgroundMusicActionButton('一键简化').disabled).toBe( + !canSimplify, + ); + expect( + getBackgroundMusicActionButton('生成游戏背景音乐').disabled, + ).toBe(!canGenerate); + }, + ); + + it('保留超出接口上限的原文并标记计数无效', () => { + const prompt = '森'.repeat(2001); + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt }), + }); + + const textarea = getBackgroundMusicPromptTextarea(); + expect(textarea.value).toBe(prompt); + expect(textarea.value).toHaveLength(2001); + expect(textarea.getAttribute('aria-invalid')).toBe('true'); + expect( + within(getBackgroundMusicPanel()).getByText('2001 / 200'), + ).toBeTruthy(); + }); + + it('把助手动作路由到当前 dialog ID', () => { + const promptAssist = createBackgroundMusicPromptAssistStub(); + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ + id: 'dialog-second', + prompt: '森林冒险', + }), + promptAssist, + }); + + fireEvent.click(getBackgroundMusicActionButton('AI 补全')); + expect(promptAssist.completePrompt).toHaveBeenCalledWith('dialog-second'); + expect(promptAssist.simplifyPrompt).not.toHaveBeenCalled(); + }); + + it('把预设追加到 preparePreset 返回的 canonical prompt', () => { + const updateCanvasGenerationDialogById = vi.fn(); + const promptAssist = createBackgroundMusicPromptAssistStub( + {}, + { preparePreset: vi.fn(() => '阳光动物园') }, + ); + const dialog = createBackgroundMusicDialog({ prompt: ' 阳光动物园 ' }); + renderBackgroundMusicComposer({ + dialog, + promptAssist, + updateCanvasGenerationDialogById, + }); + + fireEvent.click(getBackgroundMusicActionButton('展开')); + const firstPreset = BACKGROUND_MUSIC_PROMPT_PRESETS[0]; + fireEvent.click(getBackgroundMusicActionButton(firstPreset.label)); + + expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm'); + const { dialogId, nextDialog } = applyBackgroundMusicDialogUpdate( + updateCanvasGenerationDialogById, + dialog, + ); + expect(dialogId).toBe('dialog-bgm'); + expect(nextDialog?.prompt).toBe(`阳光动物园。${firstPreset.prompt}`); + }); + + it('preparePreset 拒绝当前 dialog 时不写回', () => { + const updateCanvasGenerationDialogById = vi.fn(); + const promptAssist = createBackgroundMusicPromptAssistStub( + {}, + { preparePreset: vi.fn(() => null) }, + ); + renderBackgroundMusicComposer({ + promptAssist, + updateCanvasGenerationDialogById, + }); + + fireEvent.click(getBackgroundMusicActionButton('展开')); + fireEvent.click( + getBackgroundMusicActionButton( + BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, + ), + ); + + expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm'); + expect(updateCanvasGenerationDialogById).not.toHaveBeenCalled(); + }); + + it('同时展示助手错误和已有生成错误并保持顺序', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ + prompt: '森林冒险', + status: 'failed', + errorMessage: '生成失败', + }), + promptAssist: createBackgroundMusicPromptAssistStub({ + errorMessage: 'AI 暂时不可用', + }), + }); + + const alerts = within(getBackgroundMusicPanel()).getAllByRole('alert'); + expect(alerts.map((alert) => alert.textContent)).toEqual([ + 'AI 暂时不可用', + '生成失败', + ]); + }); + + it('保留固定 Suno 模型和动态泥点价格', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + }); + + const modelButton = getBackgroundMusicActionButton('背景音乐模型 Suno'); + expect(modelButton.disabled).toBe(true); + expect( + getBackgroundMusicActionButton('生成游戏背景音乐').textContent, + ).toBe('生成12泥点'); + }); + + it('仅在允许生成时提交当前 dialog', () => { + const onSubmit = vi.fn(); + const dialog = createBackgroundMusicDialog({ prompt: '森林冒险' }); + renderBackgroundMusicComposer({ dialog, onSubmit }); + + fireEvent.submit(getBackgroundMusicPanel()); + expect(onSubmit).toHaveBeenCalledWith(dialog); + + onSubmit.mockClear(); + const blocked = renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ 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], + ])('%s 状态锁定面板中的全部控件', (status) => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + promptAssist: createBackgroundMusicPromptAssistStub({ + status, + operationId: 'operation-1', + temporaryPromptSnapshot: '森林冒险', + }), + }); + + const panel = getBackgroundMusicPanel(); + expect(panel.getAttribute('aria-busy')).toBe('true'); + expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true); + expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true); + expect(getBackgroundMusicActionButton('一键简化').disabled).toBe(true); + expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); + expect(getBackgroundMusicActionButton('展开').disabled).toBe(true); + expect(getBackgroundMusicActionButton('生成游戏背景音乐').disabled).toBe( + true, + ); + }); + + it('已有生成占位运行时锁定面板', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ + prompt: '森林冒险', + status: 'generating', + }), + }); + + expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true'); + expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true); + expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true); + expect(getBackgroundMusicActionButton('展开').disabled).toBe(true); + expect( + getBackgroundMusicActionButton('生成游戏背景音乐').textContent, + ).toBe('生成中'); + }); + + describe('撤销按钮矩阵', () => { + it('没有任何快照时保持隐藏', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + }); + + expect( + within(getBackgroundMusicPanel()).queryByRole('button', { + name: '撤销', + }), + ).toBeNull(); + }); + + it.each([['completing' as const], ['simplifying' as const]])( + '%s 仅持有临时快照时展示禁用的撤销', + (status) => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + promptAssist: createBackgroundMusicPromptAssistStub({ + status, + operationId: 'operation-1', + undoPromptSnapshot: null, + temporaryPromptSnapshot: '森林冒险', + }), + }); + + expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); + }, + ); + + it('助手成功后及手动编辑后允许撤销', () => { + const promptAssist = createBackgroundMusicPromptAssistStub({ + undoPromptSnapshot: '森林冒险', + }); + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ + prompt: '完整的森林冒险背景音乐,手动改过', + }), + promptAssist, + }); + + const undoButton = getBackgroundMusicActionButton('撤销'); + expect(undoButton.disabled).toBe(false); + + fireEvent.click(undoButton); + expect(promptAssist.undoPrompt).toHaveBeenCalledWith('dialog-bgm'); + }); + + it('助手失败清空两份快照后重新隐藏撤销', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + promptAssist: createBackgroundMusicPromptAssistStub({ + undoPromptSnapshot: null, + temporaryPromptSnapshot: null, + errorMessage: 'AI 暂时不可用', + }), + }); + + expect( + within(getBackgroundMusicPanel()).queryByRole('button', { + name: '撤销', + }), + ).toBeNull(); + }); + + it('有快照且正在提交时保持可见但禁用', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + promptAssist: createBackgroundMusicPromptAssistStub({ + status: 'submitting', + operationId: 'operation-1', + undoPromptSnapshot: '森林冒险', + }), + }); + + expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true); + }); + + it('没有快照且正在提交时保持隐藏', () => { + renderBackgroundMusicComposer({ + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + promptAssist: createBackgroundMusicPromptAssistStub({ + status: 'submitting', + operationId: 'operation-1', + undoPromptSnapshot: null, + }), + }); + + expect( + within(getBackgroundMusicPanel()).queryByRole('button', { + name: '撤销', + }), + ).toBeNull(); + }); + }); + }); + + describe('共享音频 composer 的 mode 与 dialog 隔离', () => { + beforeEach(() => { + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 1), + ); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + }); + + it.each([ + { + label: '缺少稳定 dialog ID', + dialog: { + mode: 'audio-background-music' as const, + prompt: '森林冒险', + status: 'idle' as const, + composerOpen: true, + }, + overrides: { + backgroundMusicPromptAssist: createBackgroundMusicPromptAssistStub(), + }, + }, + { + label: '缺少助手 controller', + dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }), + overrides: {}, + }, + ])('$label 的 BGM 失败关闭', ({ dialog, overrides }) => { + renderComposer(dialog, overrides); + + expect( + screen.queryByRole('dialog', { name: '生成游戏背景音乐' }), + ).toBeNull(); + }); + + it('缺少稳定 ID 和 BGM controller 不影响 SFX 正常渲染及默认时长', () => { + renderComposer({ + mode: 'audio-sound-effect', + prompt: '', + status: 'idle', + composerOpen: true, + soundModel: 'audio1.0', + }); + + const panel = screen.getByRole('dialog', { name: '生成游戏音效' }); + expect( + within(panel).getByRole('button', { name: '音效时长 5秒' }), + ).toBeTruthy(); + expect( + within(panel).queryByRole('button', { name: 'AI 补全' }), + ).toBeNull(); + }); + + it('切换 BGM dialog ID 时不继承预设展开状态', () => { + const promptAssist = createBackgroundMusicPromptAssistStub(); + const firstDialog = createBackgroundMusicDialog({ + id: 'dialog-bgm-a', + prompt: '森林冒险', + }); + const view = renderBackgroundMusicComposer({ + dialog: firstDialog, + promptAssist, + }); + + fireEvent.click(getBackgroundMusicActionButton('展开')); + expect(getBackgroundMusicActionButton('收起')).toBeTruthy(); + expect( + getBackgroundMusicActionButton( + BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, + ), + ).toBeTruthy(); + + const secondDialog = createBackgroundMusicDialog({ + id: 'dialog-bgm-b', + prompt: '海边追逐', + }); + view.rerender( + , + ); + + expect(getBackgroundMusicActionButton('展开')).toBeTruthy(); + expect( + within(getBackgroundMusicPanel()).queryByRole('button', { + name: BACKGROUND_MUSIC_PROMPT_PRESETS[0].label, + }), + ).toBeNull(); + }); + + it('SFX 与 BGM 切换时不泄漏菜单或 BGM 助手锁', () => { + const promptAssist = createBackgroundMusicPromptAssistStub({ + status: 'submitting', + operationId: 'operation-1', + }); + const soundEffectDialog: GenerateDialogState = { + mode: 'audio-sound-effect', + prompt: '开门声', + status: 'idle', + composerOpen: true, + soundModel: 'audio1.0', + soundDurationSeconds: 5, + }; + const view = renderComposer(soundEffectDialog, { + backgroundMusicPromptAssist: promptAssist, + }); + + fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' })); + expect(screen.getByRole('menu', { name: '音效时长选项' })).toBeTruthy(); + + const backgroundMusicDialog = createBackgroundMusicDialog({ + id: 'dialog-bgm-locked', + prompt: '森林冒险', + }); + view.rerender( + , + ); + + expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); + expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true'); + + view.rerender( + , + ); + + const soundEffectPanel = screen.getByRole('dialog', { + name: '生成游戏音效', + }); + expect( + ( + within(soundEffectPanel).getByRole('textbox', { + name: 'prompt', + }) as HTMLTextAreaElement + ).disabled, + ).toBe(false); + expect( + ( + within(soundEffectPanel).getByRole('button', { + name: '生成游戏音效', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull(); + }); + }); + it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); const setIsGenerationReferenceMenuOpen = vi.fn(); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index d99612c0e..b13979dcd 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -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(null); const soundModelButtonRef = useRef(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 ( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + if (!canGenerate) { + return; + } + onSubmit(backgroundMusicDialog); + }} + > + updateBackgroundMusicPrompt(event.target.value)} + /> +
+ + {`${promptCodePointCount} / ${BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS}`} + + { + void backgroundMusicPromptAssist.completePrompt( + backgroundMusicDialog.id, + ); + }} + > + {assistState.status === 'completing' ? '补全中' : 'AI 补全'} + + { + void backgroundMusicPromptAssist.simplifyPrompt( + backgroundMusicDialog.id, + ); + }} + > + {assistState.status === 'simplifying' ? '简化中' : '一键简化'} + + {isUndoVisible ? ( + { + backgroundMusicPromptAssist.undoPrompt( + backgroundMusicDialog.id, + ); + }} + > + 撤销 + + ) : null} +
+ + {assistState.errorMessage ? ( + + {assistState.errorMessage} + + ) : null} + {backgroundMusicDialog.status === 'failed' ? ( + + {backgroundMusicDialog.errorMessage} + + ) : null} +
+
+ + + + {BACKGROUND_MUSIC_MODEL_LABEL} + + +
+ + {isGenerating ? ( + '生成中' + ) : ( + <> + 生成 + + {cost}泥点 + + + )} + +
+ + ); + } + 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) => { 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 ? ( - - ) : null} - - {backgroundMusicDialog && - backgroundMusicDialog.composerOpen !== false && - generationComposerStyle && - backgroundMusicPromptAssist ? ( - diff --git a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts index cc6b2c4d0..e5ed4bf58 100644 --- a/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts +++ b/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.ts @@ -59,7 +59,7 @@ export type BackgroundMusicPromptAssistController = ReturnType< >; /** - * T4 composer 只需要读状态和触发四个用户动作。`beginSubmission` / `finishSubmission` + * 共享音频 composer 的 BGM 分支只需要读状态和触发四个用户动作。`beginSubmission` / `finishSubmission` * 留给 T5 的正式提交链路,不进入视图 props。 */ export type BackgroundMusicPromptAssistComposerController = Pick<