BGM音频模块迁移回isSoundEffect
This commit is contained in:
@@ -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、2–10 秒、默认 5 秒、Prompt 回退、1500 字、价格与提交路径未改;本轮未实现 SFX V2、未修改后端或需求原文,也未执行浏览器视觉测试或创建真实付费任务。
|
||||
|
||||
-391
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user