对齐编辑器生成占位与面板参数

统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。

让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。

完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。

更新生成类面板 Lovart 改造文档和项目决策记录。
This commit is contained in:
2026-06-19 12:15:57 +08:00
parent 160c96a014
commit 0e47a54a91
49 changed files with 2106 additions and 758 deletions
@@ -21,7 +21,7 @@ import type {
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import {
EDITOR_GENERATION_MUD_POINT_CONFIG,
calculateEditorImageGenerationPrice,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -172,7 +172,9 @@ export function ImageCanvasBasicGenerationComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.image}
cost={calculateEditorImageGenerationPrice({
model: dialog.imageModel,
})}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -114,6 +114,43 @@ describe('ImageCanvasBottomToolbarView', () => {
}
});
it('把宣发素材入口放在生成UI设计图右侧', () => {
const specToolWrapRef = createRef<HTMLSpanElement>();
const musicToolWrapRef = createRef<HTMLSpanElement>();
const publicationToolWrapRef = createRef<HTMLSpanElement>();
render(
<ImageCanvasBottomToolbarView
specToolWrapRef={specToolWrapRef}
musicToolWrapRef={musicToolWrapRef}
publicationToolWrapRef={publicationToolWrapRef}
effectiveTool="select"
onSwitchTool={vi.fn()}
onOpenToolOptions={vi.fn()}
onCloseToolOptions={vi.fn()}
/>,
);
const toolNames = within(
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label'));
expect(toolNames).toEqual([
'选择工具',
'抓手工具',
'上传工具',
'生成图片',
'生成视频',
'生成音乐',
'生成规范',
'生成角色形象',
'生成图标素材',
'生成UI设计图',
'宣发素材',
]);
});
it('opens and closes bottom option tools on hover', () => {
const openToolOptions = vi.fn();
const closeToolOptions = vi.fn();
@@ -45,7 +45,6 @@ const canvasTools: Array<{
},
{ id: 'video', label: '生成视频', icon: Clapperboard },
{ id: 'music', label: '生成音乐', icon: Music },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
{
id: 'spec',
label: '生成规范',
@@ -55,6 +54,7 @@ const canvasTools: Array<{
{ id: 'character', label: '生成角色形象', icon: UserRound },
{ id: 'icon', label: '生成图标素材', icon: Grid2X2 },
{ id: 'ui-design', label: '生成UI设计图', icon: AppWindow },
{ id: 'publication', label: '宣发素材', icon: Megaphone },
];
function isToolbarOptionTool(tool: CanvasTool): tool is ToolbarOptionTool {
@@ -21,7 +21,7 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import { EDITOR_GENERATION_MUD_POINT_CONFIG } from './ImageCanvasGenerationModel';
import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
type ImageCanvasCharacterGenerationComposerViewProps = {
@@ -291,7 +291,10 @@ export function ImageCanvasCharacterGenerationComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.character}
cost={calculateEditorImageGenerationPrice({
kind: 'character',
model: dialog.imageModel,
})}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -2818,7 +2818,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
expect(editDialog).toBeTruthy();
const editPrompt = within(editDialog).getByLabelText('重绘提示词');
@@ -2914,7 +2914,7 @@ describe('ImageCanvasEditorView generation integration', () => {
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '重绘' }));
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), {
target: { value: '改成雨夜灯光' },
@@ -173,8 +173,7 @@ describe('ImageCanvasEditorModel', () => {
prompt: '金币掉落叮当声',
status: 'idle',
composerOpen: true,
soundType: 'loop',
soundTempo: 120,
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
placeholder: {
x: 100,
@@ -198,8 +197,7 @@ describe('ImageCanvasEditorModel', () => {
id: 'generation-dialog-audio',
mode: 'audio-sound-effect',
prompt: '金币掉落叮当声',
soundType: 'loop',
soundTempo: 120,
soundDurationSeconds: 8,
generatedLayerId: 'layer-audio',
});
});
@@ -337,11 +337,11 @@ export function hydrateCanvasGenerationDialog(
characterAnimationResult: hydrateCharacterAnimationResult(
snapshot.characterAnimationResult,
),
soundType:
snapshot.soundType === 'loop' || snapshot.soundType === 'one-shot'
? snapshot.soundType
: undefined,
soundTempo: bpmOrNull(snapshot.soundTempo),
soundModel: snapshot.soundModel === 'audio1.0' ? 'audio1.0' : undefined,
soundDurationSeconds: soundDurationOrDefault(
snapshot.soundDurationSeconds,
snapshot.audioDurationSeconds,
),
makeInstrumental:
typeof snapshot.makeInstrumental === 'boolean'
? snapshot.makeInstrumental
@@ -511,6 +511,17 @@ export function bpmOrNull(value: unknown) {
return roundedValue >= 1 && roundedValue <= 300 ? roundedValue : undefined;
}
export function soundDurationOrDefault(
value: unknown,
fallbackValue?: unknown,
) {
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
if (!resolvedValue) {
return undefined;
}
return Math.min(10, Math.max(2, Math.round(resolvedValue)));
}
export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
@@ -202,9 +202,9 @@ export type GenerateDialogState = {
characterAnimationFrameCount?: EditorCharacterAnimationFrameCount;
characterAnimationDurationSeconds?: 4 | 5 | 6;
characterAnimationResult?: EditorCharacterAnimationGenerationResult;
soundType?: 'one-shot' | 'loop';
// 中文注释:音效 tempo 改为可空 BPM,null 表示用户不指定节奏。
soundTempo?: number | null;
soundModel?: 'audio1.0';
// 中文注释:Vidu 文生音频 duration 只允许 2-10 秒,默认 5 秒。
soundDurationSeconds?: number;
makeInstrumental?: boolean;
audioDurationSeconds?: number | null;
aspectRatio?: string;
@@ -1,4 +1,4 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import {
act,
@@ -510,7 +510,7 @@ describe('ImageCanvasEditorView', () => {
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
@@ -895,7 +895,7 @@ describe('ImageCanvasEditorView', () => {
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
@@ -231,6 +231,53 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBe(true);
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
});
it.each([
['publication-cover-image', '游戏首图', '720 x 540'],
['publication-detail-gallery', '详情五图', '720 x 1280'],
['publication-promo-poster', '运营海报', '1280 x 720'],
] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => {
renderComposer({
mode: 'publication',
prompt: '',
status: 'idle',
composerOpen: true,
publicationWorkflowId: workflowId,
publicationGameInfo: {
gameName: '',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
});
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
expect(panel.className).toContain('image-canvas-editor__character-composer');
expect(panel.className).toContain('image-canvas-editor__publication-composer');
expect(
panel.firstElementChild?.className.includes(
'image-canvas-editor__reference-strip',
),
).toBe(true);
expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
).toBeTruthy();
expect(panel.textContent).toContain(sizeLabel);
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
);
});
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
@@ -361,6 +408,14 @@ describe('ImageCanvasGenerationComposerView', () => {
videoResolution: '480p',
videoMode: 'std',
videoSound: 'off',
placeholder: {
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
return (
<>
@@ -371,6 +426,18 @@ describe('ImageCanvasGenerationComposerView', () => {
<output aria-label="当前视频模型">{dialog.videoModel}</output>
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
<output aria-label="当前视频占位">
{dialog.placeholder
? [
dialog.placeholder.x,
dialog.placeholder.y,
dialog.placeholder.width,
dialog.placeholder.height,
dialog.placeholder.originalWidth,
dialog.placeholder.originalHeight,
].join(':')
: '-'}
</output>
</>
);
}
@@ -402,6 +469,9 @@ describe('ImageCanvasGenerationComposerView', () => {
).toBeTruthy();
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
expect(screen.getByLabelText('当前视频占位').textContent).toBe(
'-160:0:1280:720:1280:720',
);
expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy();
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
'生成100泥点',
@@ -417,7 +487,7 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
});
it('生成游戏音效面板把类型和 BPM 合并到一个无标题参数按钮', () => {
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
const onSubmitImageGeneration = vi.fn();
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
@@ -425,8 +495,8 @@ describe('ImageCanvasGenerationComposerView', () => {
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
soundModel: 'audio1.0',
soundDurationSeconds: 5,
});
return (
<>
@@ -436,8 +506,10 @@ describe('ImageCanvasGenerationComposerView', () => {
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
/>
<output aria-label="当前音效类型">{dialog.soundType}</output>
<output aria-label="当前BPM">{dialog.soundTempo ?? 'null'}</output>
<output aria-label="当前音效模型">{dialog.soundModel}</output>
<output aria-label="当前音效时长">
{dialog.soundDurationSeconds ?? 'null'}
</output>
</>
);
}
@@ -445,46 +517,43 @@ describe('ImageCanvasGenerationComposerView', () => {
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByRole('textbox', { name: 'sound提示词' })).toBeTruthy();
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
expect(within(panel).getByText('Vidu')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
within(panel).getByRole('button', { name: '音效参数 单次' }),
within(panel).getByRole('button', { name: '音效时长 5秒' }),
).toBeTruthy();
expect(within(panel).queryByRole('button', { name: /type/u })).toBeNull();
expect(within(panel).queryByRole('button', { name: /tempo/u })).toBeNull();
expect(within(panel).queryByText('单次')).toBeNull();
expect(within(panel).queryByText('循环')).toBeNull();
expect(within(panel).queryByText(/BPM/u)).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
'生成10泥点',
);
fireEvent.click(
within(panel).getByRole('button', { name: '音效参数 单次' }),
within(panel).getByRole('button', { name: '音效时长 5秒' }),
);
const optionPanel = screen.getByRole('menu', { name: '音效参数选项' });
fireEvent.click(within(optionPanel).getByRole('button', { name: '循环' }));
fireEvent.change(within(optionPanel).getByLabelText('BPM数值'), {
target: { value: '120' },
});
expect(screen.getByLabelText('当前音效类型').textContent).toBe('loop');
expect(screen.getByLabelText('当前BPM').textContent).toBe('120');
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
expect(
screen.getByRole('button', { name: '音效参数 循环·120BPM' }),
within(optionPanel).getByRole('button', { name: '时长 2秒' }),
).toBeTruthy();
fireEvent.change(within(optionPanel).getByLabelText('BPM拖拉条'), {
target: { value: '88' },
});
expect(screen.getByLabelText('当前BPM').textContent).toBe('88');
expect(
screen.getByRole('button', { name: '音效参数 循环·88BPM' }),
within(optionPanel).getByRole('button', { name: '时长 10秒' }),
).toBeTruthy();
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
fireEvent.click(within(optionPanel).getByRole('button', { name: '时长 8秒' }));
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
expect(
screen.getByRole('button', { name: '音效时长 8秒' }),
).toBeTruthy();
fireEvent.submit(panel);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
soundType: 'loop',
soundTempo: 88,
soundDurationSeconds: 8,
}),
);
});
@@ -36,10 +36,17 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import {
calculateEditorBackgroundMusicPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
EDITOR_GENERATION_MUD_POINT_CONFIG,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
SOUND_EFFECT_DURATION_OPTIONS,
EDITOR_VIDEO_DURATION_OPTIONS,
EDITOR_VIDEO_MODEL_OPTIONS,
resizeGenerationPlaceholderToVideoSelection,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
@@ -278,7 +285,11 @@ function ImageCanvasVideoGenerationComposerView({
(item) => item.value === dialog.videoModel,
) ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
const supportsReferences = isSeedanceVideoModel(currentModel.value);
const price = calculateEditorVideoPrice(resolution, durationSeconds);
const price = calculateEditorVideoPrice(
currentModel.value,
resolution,
durationSeconds,
);
const isGenerating = dialog.status === 'generating';
const closeVideoFloatingPanels = useCallback(() => {
setOpenPanel(null);
@@ -298,10 +309,10 @@ function ImageCanvasVideoGenerationComposerView({
const updateVideoDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'video'
? {
? resizeGenerationPlaceholderToVideoSelection({
...resetFailedVideoDialogStatus(currentDialog),
...patch,
}
})
: currentDialog,
);
};
@@ -613,35 +624,11 @@ function ImageCanvasVideoGenerationComposerView({
);
}
const SOUND_TYPE_OPTIONS = [
{ label: '单次', value: 'one-shot' },
{ label: '循环', value: 'loop' },
] as const;
const SOUND_BPM_MIN = 1;
const SOUND_BPM_MAX = 300;
const SOUND_BPM_SLIDER_FALLBACK = 120;
function clampSoundBpm(value: number) {
return Math.min(
SOUND_BPM_MAX,
Math.max(SOUND_BPM_MIN, Math.round(value)),
);
}
function parseSoundBpmInput(value: string) {
const normalizedValue = value.trim();
if (!normalizedValue) {
return null;
function normalizeSoundDuration(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? clampSoundBpm(parsedValue) : null;
}
function formatSoundEffectOptionLabel(
typeLabel: string,
soundTempo: number | null | undefined,
) {
return soundTempo ? `${typeLabel}·${soundTempo}BPM` : typeLabel;
return Math.min(10, Math.max(2, Math.round(value)));
}
function resetFailedAudioDialogStatus(dialog: GenerateDialogState) {
@@ -672,21 +659,20 @@ function ImageCanvasAudioGenerationComposerView({
}) {
const [isSoundOptionsOpen, setIsSoundOptionsOpen] = useState(false);
const soundOptionsButtonRef = useRef<HTMLButtonElement | null>(null);
const soundModelButtonRef = useRef<HTMLButtonElement | null>(null);
const isSoundEffect = dialog.mode === 'audio-sound-effect';
const isGenerating = dialog.status === 'generating';
const currentType =
SOUND_TYPE_OPTIONS.find((option) => option.value === dialog.soundType) ??
SOUND_TYPE_OPTIONS[0];
const currentTempo =
typeof dialog.soundTempo === 'number' ? dialog.soundTempo : null;
const soundOptionLabel = formatSoundEffectOptionLabel(
currentType.label,
currentTempo,
);
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const currentSoundModel =
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find(
(option) => option.value === soundModel,
) ?? EDITOR_SOUND_EFFECT_MODEL_OPTIONS[0];
const soundDuration = normalizeSoundDuration(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}秒`;
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const cost = isSoundEffect
? EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect
: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic;
? calculateEditorSoundEffectPrice(soundModel)
: calculateEditorBackgroundMusicPrice(DEFAULT_BACKGROUND_MUSIC_MODEL);
useImageCanvasFloatingOptionDismiss({
isOpen: isSoundOptionsOpen,
@@ -705,8 +691,8 @@ function ImageCanvasAudioGenerationComposerView({
);
};
const updateSoundTempo = (nextTempo: number | null) => {
updateAudioDialog({ soundTempo: nextTempo });
const updateSoundDuration = (durationSeconds: number) => {
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
};
return (
@@ -725,7 +711,7 @@ function ImageCanvasAudioGenerationComposerView({
>
<PlatformTextField
variant="textarea"
aria-label={isSoundEffect ? 'sound提示词' : 'gpt_description_prompt'}
aria-label={isSoundEffect ? 'prompt' : 'gpt_description_prompt'}
value={dialog.prompt}
disabled={isGenerating}
placeholder={
@@ -751,77 +737,64 @@ function ImageCanvasAudioGenerationComposerView({
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<>
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效参数 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`音效模型 ${currentSoundModel.label}`}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{currentSoundModel.label}</span>
</span>
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效参数选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_TYPE_OPTIONS.map((option) => (
<VideoOptionChoice
key={option.value}
selected={currentType.value === option.value}
disabled={isGenerating}
ariaLabel={option.label}
onClick={() =>
updateAudioDialog({ soundType: option.value })
}
>
{option.label}
</VideoOptionChoice>
))}
</div>
<div className="image-canvas-editor__audio-bpm-control">
<input
type="range"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? SOUND_BPM_SLIDER_FALLBACK}
disabled={isGenerating}
aria-label="BPM拖拉条"
onChange={(event) =>
updateSoundTempo(
clampSoundBpm(Number(event.target.value)),
)
}
/>
<input
type="number"
min={SOUND_BPM_MIN}
max={SOUND_BPM_MAX}
value={currentTempo ?? ''}
placeholder="BPM"
disabled={isGenerating}
aria-label="BPM数值"
onChange={(event) =>
updateSoundTempo(
parseSoundBpmInput(event.target.value),
)
}
/>
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}秒`}
onClick={() => updateSoundDuration(option)}
>
{option}秒
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
</>
) : null}
<PlatformActionButton
type="submit"
@@ -1009,6 +982,7 @@ export function ImageCanvasGenerationComposerView({
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
onRequestUpload={onRequestUpload}
onRememberImageModel={onRememberImageModel}
onSubmit={onSubmitImageGeneration}
/>
) : null}
@@ -12,6 +12,7 @@ import {
assignIconSpecReference,
assignUiDesignSpecReference,
closeGenerateComposerDialog,
createAudioRedrawGenerationDialogDraft,
createBackgroundMusicGenerationDialogDraft,
createCharacterAnimationGenerationDialogDraft,
createCharacterAnimationPanelDraft,
@@ -19,6 +20,7 @@ import {
createEditDialogDraft,
createGenerateDialogDraft,
createIconGenerationDialogDraft,
createPublicationGenerationDialogDraft,
createQuickEditPanelDraft,
createSoundEffectGenerationDialogDraft,
createSpecDialogDraft,
@@ -60,12 +62,12 @@ describe('ImageCanvasGenerationDialogModel', () => {
status: 'idle',
composerOpen: true,
placeholder: {
x: -10,
y: -30,
width: 420,
height: 420,
originalWidth: 2048,
originalHeight: 2048,
x: -312,
y: -332,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
},
});
expect(
@@ -78,10 +80,36 @@ describe('ImageCanvasGenerationDialogModel', () => {
artStyle: '清爽卡通',
},
placeholder: {
x: -80,
y: 22.5,
width: 560,
height: 315,
x: -824,
y: -396,
width: 2048,
height: 1152,
originalWidth: 2048,
originalHeight: 1152,
},
});
});
it('creates publication material drafts with the workflow output size', () => {
const canvasSize = { width: 960, height: 720 };
const viewport = { x: 0, y: 0, scale: 1 };
expect(
createPublicationGenerationDialogDraft({
canvasSize,
viewport,
workflowId: 'publication-promo-poster',
}),
).toMatchObject({
mode: 'publication',
publicationWorkflowId: 'publication-promo-poster',
placeholder: {
x: -160,
y: 0,
width: 1280,
height: 720,
originalWidth: 1280,
originalHeight: 720,
},
});
});
@@ -104,10 +132,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
characterSpecReference: null,
characterReferences: [],
placeholder: {
x: 270,
y: 150,
width: 420,
height: 420,
x: -32,
y: -152,
width: 1024,
height: 1024,
},
});
expect(
@@ -132,10 +160,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
'冻结按钮',
],
placeholder: {
x: 300,
y: 180,
width: 360,
height: 360,
x: -32,
y: -152,
width: 1024,
height: 1024,
},
});
});
@@ -157,12 +185,12 @@ describe('ImageCanvasGenerationDialogModel', () => {
videoMode: 'std',
videoSound: 'off',
placeholder: {
x: 200,
y: 202.5,
width: 560,
height: 315,
originalWidth: 1280,
originalHeight: 720,
x: 53,
y: 120,
width: 854,
height: 480,
originalWidth: 854,
originalHeight: 480,
},
});
expect(
@@ -178,10 +206,10 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageSize: '1K',
uiDesignSpecReference: null,
placeholder: {
x: 200,
y: 202.5,
width: 560,
height: 315,
x: -32,
y: 72,
width: 1024,
height: 576,
},
});
});
@@ -195,8 +223,8 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'audio-sound-effect',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
soundModel: 'audio1.0',
soundDurationSeconds: 5,
placeholder: {
x: 270,
y: 300,
@@ -221,6 +249,64 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('creates audio remodel drafts from generated audio layers without references', () => {
const soundLayer = createLayer({
id: 'layer-sfx',
title: '游戏音效',
mediaType: 'audio',
assetKind: 'sound-effect',
prompt: '金币收集音',
generationInputs: {
fields: [
{ title: 'prompt', value: '金币收集音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
],
references: [],
},
});
const musicLayer = createLayer({
id: 'layer-music',
title: '游戏背景音乐',
mediaType: 'audio',
assetKind: 'background-music',
prompt: '',
generationInputs: {
fields: [{ title: 'gpt_description_prompt', value: '地牢循环音乐' }],
references: [],
},
});
expect(createAudioRedrawGenerationDialogDraft(soundLayer)).toMatchObject({
mode: 'audio-sound-effect',
sourceLayerId: 'layer-sfx',
prompt: '金币收集音',
soundModel: 'audio1.0',
soundDurationSeconds: 7,
composerOpen: true,
placeholder: {
x: 472,
y: 140,
width: 420,
height: 120,
},
});
expect(createAudioRedrawGenerationDialogDraft(musicLayer)).toMatchObject({
mode: 'audio-background-music',
sourceLayerId: 'layer-music',
prompt: '地牢循环音乐',
makeInstrumental: true,
composerOpen: true,
placeholder: {
x: 472,
y: 140,
width: 420,
height: 120,
},
});
expect(createAudioRedrawGenerationDialogDraft(createLayer())).toBeNull();
});
it('creates edit, quick-edit, and character animation panel drafts', () => {
const sourceLayer = createLayer({
prompt: '原图提示',
@@ -16,27 +16,21 @@ import {
CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE,
CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_FRAME_DISPLAY_SIZE,
CHARACTER_FRAME_ORIGINAL_SIZE,
appendLimitedQuickEditReferences,
createCanvasLayerReference,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_PUBLICATION_GAME_INFO,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
ICON_DESCRIPTION_LIMIT,
ICON_FRAME_DISPLAY_SIZE,
ICON_FRAME_ORIGINAL_SIZE,
PUBLICATION_FRAME_DISPLAY_SIZE,
PUBLICATION_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
resolveEditorImageGenerationPixelSize,
resolveEditorVideoGenerationPixelSize,
SPEC_FRAME_ORIGINAL_SIZE,
UI_DESIGN_FRAME_DISPLAY_SIZE,
UI_DESIGN_FRAME_ORIGINAL_SIZE,
VIDEO_FRAME_DISPLAY_SIZE,
VIDEO_FRAME_ORIGINAL_SIZE,
} from './ImageCanvasGenerationModel';
type CanvasSize = { width: number; height: number };
@@ -151,10 +145,13 @@ export function createGenerateDialogDraft({
canvasSize: CanvasSize;
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const placeholderWidth = 420;
const placeholderHeight = 420;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(DEFAULT_IMAGE_MODEL);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: DEFAULT_IMAGE_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'generate',
prompt: '',
@@ -164,12 +161,12 @@ export function createGenerateDialogDraft({
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - placeholderWidth / 2,
y: worldCenter.y - placeholderHeight / 2,
width: placeholderWidth,
height: placeholderHeight,
originalWidth: 2048,
originalHeight: 2048,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -192,10 +189,10 @@ export function createSpecDialogDraft({
specType,
specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] },
placeholder: {
x: worldCenter.x - SPEC_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - SPEC_FRAME_DISPLAY_SIZE.height / 2,
width: SPEC_FRAME_DISPLAY_SIZE.width,
height: SPEC_FRAME_DISPLAY_SIZE.height,
x: worldCenter.x - SPEC_FRAME_ORIGINAL_SIZE.width / 2,
y: worldCenter.y - SPEC_FRAME_ORIGINAL_SIZE.height / 2,
width: SPEC_FRAME_ORIGINAL_SIZE.width,
height: SPEC_FRAME_ORIGINAL_SIZE.height,
originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width,
originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height,
},
@@ -213,6 +210,11 @@ export function createCharacterGenerationDialogDraft({
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'character',
prompt: '',
@@ -224,12 +226,12 @@ export function createCharacterGenerationDialogDraft({
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2,
width: CHARACTER_FRAME_DISPLAY_SIZE.width,
height: CHARACTER_FRAME_DISPLAY_SIZE.height,
originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width,
originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -245,6 +247,11 @@ export function createIconGenerationDialogDraft({
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'icon',
prompt: formatIconDescriptionsText(DEFAULT_ICON_DESCRIPTIONS),
@@ -256,12 +263,12 @@ export function createIconGenerationDialogDraft({
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2,
width: ICON_FRAME_DISPLAY_SIZE.width,
height: ICON_FRAME_DISPLAY_SIZE.height,
originalWidth: ICON_FRAME_ORIGINAL_SIZE.width,
originalHeight: ICON_FRAME_ORIGINAL_SIZE.height,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -276,7 +283,6 @@ export function createPublicationGenerationDialogDraft({
workflowId: PublicationMaterialsWorkflowId;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const frameDisplaySize = PUBLICATION_FRAME_DISPLAY_SIZE[workflowId];
const frameOriginalSize = PUBLICATION_FRAME_ORIGINAL_SIZE[workflowId];
return {
mode: 'publication',
@@ -287,10 +293,10 @@ export function createPublicationGenerationDialogDraft({
publicationGameInfo: { ...DEFAULT_PUBLICATION_GAME_INFO },
publicationReferences: [],
placeholder: {
x: worldCenter.x - frameDisplaySize.width / 2,
y: worldCenter.y - frameDisplaySize.height / 2,
width: frameDisplaySize.width,
height: frameDisplaySize.height,
x: worldCenter.x - frameOriginalSize.width / 2,
y: worldCenter.y - frameOriginalSize.height / 2,
width: frameOriginalSize.width,
height: frameOriginalSize.height,
originalWidth: frameOriginalSize.width,
originalHeight: frameOriginalSize.height,
},
@@ -305,6 +311,10 @@ export function createVideoGenerationDialogDraft({
viewport: CanvasViewport;
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const placeholderSize = resolveEditorVideoGenerationPixelSize({
aspectRatio: '16:9',
resolution: '480p',
});
return {
mode: 'video',
prompt: '',
@@ -318,12 +328,12 @@ export function createVideoGenerationDialogDraft({
videoMode: 'std',
videoSound: 'off',
placeholder: {
x: worldCenter.x - VIDEO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - VIDEO_FRAME_DISPLAY_SIZE.height / 2,
width: VIDEO_FRAME_DISPLAY_SIZE.width,
height: VIDEO_FRAME_DISPLAY_SIZE.height,
originalWidth: VIDEO_FRAME_ORIGINAL_SIZE.width,
originalHeight: VIDEO_FRAME_ORIGINAL_SIZE.height,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -341,8 +351,8 @@ export function createSoundEffectGenerationDialogDraft({
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
soundModel: DEFAULT_SOUND_EFFECT_MODEL,
soundDurationSeconds: DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
placeholder: {
x: worldCenter.x - AUDIO_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - AUDIO_FRAME_DISPLAY_SIZE.height / 2,
@@ -379,6 +389,105 @@ export function createBackgroundMusicGenerationDialogDraft({
};
}
function findGenerationInputFieldValue(
layer: CanvasLayer,
titles: string[],
) {
const normalizedTitles = new Set(titles.map((title) => title.toLowerCase()));
return (
layer.generationInputs?.fields.find((field) =>
normalizedTitles.has(field.title.trim().toLowerCase()),
)?.value ?? null
);
}
function resolveAudioRedrawPrompt(sourceLayer: CanvasLayer) {
return (
sourceLayer.prompt?.trim() ||
findGenerationInputFieldValue(sourceLayer, [
'prompt',
'sound',
'gpt_description_prompt',
'生成提示词',
'音效提示词',
'背景音乐提示词',
])?.trim() ||
sourceLayer.generationInputs?.fields[0]?.value?.trim() ||
''
);
}
function resolveAudioRedrawMode(sourceLayer: CanvasLayer) {
if (sourceLayer.assetKind === 'background-music') {
return 'audio-background-music' as const;
}
if (sourceLayer.assetKind === 'sound-effect') {
return 'audio-sound-effect' as const;
}
if (sourceLayer.mediaType !== 'audio') {
return null;
}
return sourceLayer.title.includes('背景音乐')
? ('audio-background-music' as const)
: ('audio-sound-effect' as const);
}
function resolveAudioRedrawSoundModel(
sourceLayer: CanvasLayer,
): NonNullable<GenerateDialogState['soundModel']> {
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['model'])
?.trim();
// 中文注释:编辑器音效改造入口当前只暴露 Vidu audio1.0,历史快照里的其他模型统一回落到默认模型。
return fieldValue === DEFAULT_SOUND_EFFECT_MODEL ? 'audio1.0' : 'audio1.0';
}
function resolveAudioRedrawSoundDuration(sourceLayer: CanvasLayer) {
const fieldValue = findGenerationInputFieldValue(sourceLayer, ['duration']);
const matchedValue = fieldValue?.match(/\d+/u)?.[0];
const duration = matchedValue ? Number.parseInt(matchedValue, 10) : null;
if (!duration || !Number.isFinite(duration)) {
return DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
}
return Math.min(10, Math.max(2, duration));
}
export function createAudioRedrawGenerationDialogDraft(
sourceLayer: CanvasLayer,
): Omit<CanvasGenerationDialogState, 'id'> | null {
const mode = resolveAudioRedrawMode(sourceLayer);
if (!mode) {
return null;
}
const baseDraft = {
mode,
sourceLayerId: sourceLayer.id,
prompt: resolveAudioRedrawPrompt(sourceLayer),
status: 'idle' as const,
composerOpen: true,
placeholder: {
x: sourceLayer.x + sourceLayer.width + 32,
y: sourceLayer.y,
width: AUDIO_FRAME_DISPLAY_SIZE.width,
height: AUDIO_FRAME_DISPLAY_SIZE.height,
originalWidth: AUDIO_FRAME_ORIGINAL_SIZE.width,
originalHeight: AUDIO_FRAME_ORIGINAL_SIZE.height,
},
};
if (mode === 'audio-background-music') {
return {
...baseDraft,
makeInstrumental: true,
};
}
return {
...baseDraft,
soundModel: resolveAudioRedrawSoundModel(sourceLayer),
soundDurationSeconds: resolveAudioRedrawSoundDuration(sourceLayer),
};
}
export function createUiDesignGenerationDialogDraft({
canvasSize,
viewport,
@@ -390,6 +499,11 @@ export function createUiDesignGenerationDialogDraft({
}): Omit<CanvasGenerationDialogState, 'id'> {
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const dimensionDefaults = resolveImageDimensionDefaults(imageModel);
const placeholderSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
});
return {
mode: 'ui-design',
prompt: '',
@@ -400,12 +514,12 @@ export function createUiDesignGenerationDialogDraft({
aspectRatio: '16:9',
imageSize: dimensionDefaults.imageSize,
placeholder: {
x: worldCenter.x - UI_DESIGN_FRAME_DISPLAY_SIZE.width / 2,
y: worldCenter.y - UI_DESIGN_FRAME_DISPLAY_SIZE.height / 2,
width: UI_DESIGN_FRAME_DISPLAY_SIZE.width,
height: UI_DESIGN_FRAME_DISPLAY_SIZE.height,
originalWidth: UI_DESIGN_FRAME_ORIGINAL_SIZE.width,
originalHeight: UI_DESIGN_FRAME_ORIGINAL_SIZE.height,
x: worldCenter.x - placeholderSize.width / 2,
y: worldCenter.y - placeholderSize.height / 2,
width: placeholderSize.width,
height: placeholderSize.height,
originalWidth: placeholderSize.width,
originalHeight: placeholderSize.height,
},
};
}
@@ -38,6 +38,18 @@ function ImageOptionsHarness({
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
{dialog.placeholder
? [
dialog.placeholder.x,
dialog.placeholder.y,
dialog.placeholder.width,
dialog.placeholder.height,
dialog.placeholder.originalWidth,
dialog.placeholder.originalHeight,
].join(':')
: '-'}
</output>
</div>
) : null;
}
@@ -174,4 +186,38 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
const submit = screen.getByRole('button', { name: '生成角色形象' });
expect(submit.textContent).toBe('生成12泥点');
});
it('keeps the canvas placeholder centered while syncing its ratio and size selection', () => {
render(
<ImageOptionsHarness
initialDialog={{
mode: 'character',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
placeholder: {
x: 100,
y: 200,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1 · 1K' }),
);
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
fireEvent.click(within(panel).getByRole('button', { name: '2K' }));
expect(screen.getByLabelText('当前占位').textContent).toBe(
'-714:-166:2048:1152:2048:1152',
);
});
});
@@ -17,6 +17,7 @@ import {
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
IMAGE_MODEL_NANOBANANA2,
resizeGenerationPlaceholderToImageSelection,
} from './ImageCanvasGenerationModel';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -136,10 +137,10 @@ export function ImageCanvasGenerationImageOptionsView({
const updateDialog = (patch: Partial<GenerateDialogState>) => {
setGenerateDialog((currentDialog) =>
currentDialog && currentDialog.mode === dialog.mode
? {
? resizeGenerationPlaceholderToImageSelection({
...resetFailedDialogStatus(currentDialog),
...patch,
}
})
: currentDialog,
);
};
@@ -153,7 +154,7 @@ export function ImageCanvasGenerationImageOptionsView({
const nextOptions = getImageDimensionOptions(model);
const nextAspectRatios = nextOptions.aspectRatios as readonly string[];
const nextImageSizes = nextOptions.imageSizes as readonly string[];
return {
return resizeGenerationPlaceholderToImageSelection({
...resetFailedDialogStatus(currentDialog),
imageModel: model,
aspectRatio:
@@ -167,7 +168,7 @@ export function ImageCanvasGenerationImageOptionsView({
? currentDialog.imageSize
: (nextOptions.imageSizes.find((size) => size === '1K') ??
nextOptions.imageSizes[0]),
};
});
});
};
@@ -292,7 +292,7 @@ describe('ImageCanvasGenerationLayerModel', () => {
originalHeight: 120,
},
generationInputs: {
fields: [{ title: 'sound', value: '金币掉落叮当声' }],
fields: [{ title: 'prompt', value: '金币掉落叮当声' }],
references: [],
},
});
@@ -6,6 +6,7 @@ import type {
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
BACKGROUND_MUSIC_MODEL_SUNO,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
@@ -15,15 +16,118 @@ import {
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorImageGenerationPrice,
calculateEditorImageModelPrice,
calculateEditorSoundEffectPrice,
calculateEditorSpecGenerationPrice,
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_OPTIONS,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
normalizeQuickEditPromptSourceReference,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
VIDEO_MODEL_KLING_3_OMNI,
VIDEO_MODEL_SEEDANCE_2,
VIDEO_MODEL_SEEDANCE_2_FAST,
VIDEO_MODEL_VEO_3_1,
VIDEO_MODEL_VEO_3_1_FAST,
} from './ImageCanvasGenerationModel';
describe('ImageCanvasGenerationModel', () => {
it('为所有编辑器生成模型提供显式定价配置', () => {
expect(
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]).toBe(5);
expect(
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
).toBeTruthy();
expect(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
).toBe(5);
// 中文注释:Veo 已从前端入口移除,但客户端类型和后端兼容层仍保留,定价表也必须覆盖,避免旧布局回放或测试请求价格漂移。
expect(EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1]).toBeTruthy();
expect(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST],
).toBeTruthy();
});
it('所有生成按钮价格都从模型定价配置推导', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
model: IMAGE_MODEL_GPT_IMAGE_2,
}),
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]);
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
);
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2],
);
expect(calculateEditorVideoPrice(DEFAULT_VIDEO_MODEL, '720p', 5)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]['720p'] * 5,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI]['720p'] * 5,
);
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
.toBe(
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
CHARACTER_ANIMATION_MODEL
]['720p'] * 6,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
);
expect(calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO)).toBe(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[BACKGROUND_MUSIC_MODEL_SUNO],
);
});
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
@@ -207,11 +311,12 @@ describe('ImageCanvasGenerationModel', () => {
'Quick Edit Generator',
);
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'one-shot', null),
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
).toEqual({
fields: [
{ title: 'sound', value: '金币掉落叮当声' },
{ title: 'type', value: 'one-shot' },
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
],
references: [],
});
@@ -39,7 +39,6 @@ export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
soundEffect: 10,
backgroundMusic: 5,
} as const;
export const SPEC_GENERATION_COST = EDITOR_GENERATION_MUD_POINT_CONFIG.spec;
export const SPEC_GENERATION_SIZE = '2048x1152';
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
@@ -77,6 +76,15 @@ export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
} as const;
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
} as const;
export const SPEC_GENERATION_COST =
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL];
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
@@ -116,10 +124,185 @@ export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
imageSizes: ['1K', '2K'],
},
} as const;
type EditorImageFrameSize = {
width: number;
height: number;
};
function getSafeImageSizeValue(imageSize: string | null | undefined) {
return (imageSize ?? '1K').trim().toUpperCase();
}
function getImageSizeLongEdge(imageSize: string | null | undefined) {
const normalizedImageSize = getSafeImageSizeValue(imageSize);
if (normalizedImageSize === '0.5K') {
return 512;
}
if (normalizedImageSize === '2K') {
return 2048;
}
return 1024;
}
function resolveAspectRatioNumbers(aspectRatio: string | null | undefined) {
const [rawWidth = 1, rawHeight = 1] = (aspectRatio ?? '1:1')
.split(':')
.map((value) => Number(value));
const width = Number.isFinite(rawWidth) && rawWidth > 0 ? rawWidth : 1;
const height = Number.isFinite(rawHeight) && rawHeight > 0 ? rawHeight : 1;
return { width, height };
}
function resolveImageFrameSizeFromRatio({
aspectRatio,
imageSize,
}: {
aspectRatio: string | null | undefined;
imageSize: string | null | undefined;
}): EditorImageFrameSize {
const longEdge = getImageSizeLongEdge(imageSize);
const ratio = resolveAspectRatioNumbers(aspectRatio);
if (ratio.width >= ratio.height) {
return {
width: longEdge,
height: Math.max(1, Math.round((longEdge * ratio.height) / ratio.width)),
};
}
return {
width: Math.max(1, Math.round((longEdge * ratio.width) / ratio.height)),
height: longEdge,
};
}
export function resolveEditorImageGenerationPixelSize({
aspectRatio,
imageSize,
}: {
model: string | null | undefined;
aspectRatio: string | null | undefined;
imageSize: string | null | undefined;
}): EditorImageFrameSize {
return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize });
}
export function resizeGenerationPlaceholderToImageSelection(
dialog: GenerateDialogState,
): GenerateDialogState {
if (!dialog.placeholder) {
return dialog;
}
const size = resolveEditorImageGenerationPixelSize({
model: dialog.imageModel,
aspectRatio: dialog.aspectRatio,
imageSize: dialog.imageSize,
});
const centerX = dialog.placeholder.x + dialog.placeholder.width / 2;
const centerY = dialog.placeholder.y + dialog.placeholder.height / 2;
return {
...dialog,
placeholder: {
...dialog.placeholder,
x: centerX - size.width / 2,
y: centerY - size.height / 2,
width: size.width,
height: size.height,
originalWidth: size.width,
originalHeight: size.height,
},
};
}
function resolveEvenVideoWidth(height: number, aspectRatio: string | null | undefined) {
const ratio = resolveAspectRatioNumbers(aspectRatio ?? '16:9');
const rawWidth = Math.max(1, Math.round((height * ratio.width) / ratio.height));
return rawWidth % 2 === 0 ? rawWidth : rawWidth + 1;
}
export function resolveEditorVideoGenerationPixelSize({
aspectRatio,
resolution,
}: {
aspectRatio: string | null | undefined;
resolution: '480p' | '720p' | string | null | undefined;
}): EditorImageFrameSize {
const height = resolution === '720p' ? 720 : 480;
return {
width: resolveEvenVideoWidth(height, aspectRatio),
height,
};
}
export function resizeGenerationPlaceholderToVideoSelection(
dialog: GenerateDialogState,
): GenerateDialogState {
if (!dialog.placeholder) {
return dialog;
}
const size = resolveEditorVideoGenerationPixelSize({
aspectRatio: dialog.videoAspectRatio,
resolution: dialog.videoResolution,
});
const centerX = dialog.placeholder.x + dialog.placeholder.width / 2;
const centerY = dialog.placeholder.y + dialog.placeholder.height / 2;
return {
...dialog,
placeholder: {
...dialog.placeholder,
x: centerX - size.width / 2,
y: centerY - size.height / 2,
width: size.width,
height: size.height,
originalWidth: size.width,
originalHeight: size.height,
},
};
}
export const VIDEO_MODEL_SEEDANCE_2 = 'seedance2.0';
export const VIDEO_MODEL_SEEDANCE_2_FAST = 'seedance2.0-fast';
export const VIDEO_MODEL_KLING_3 = 'kling3.0';
export const VIDEO_MODEL_KLING_3_OMNI = 'kling3.0-omni';
export const VIDEO_MODEL_VEO_3_1 = 'veo3.1';
export const VIDEO_MODEL_VEO_3_1_FAST = 'veo3.1-fast';
export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const CHARACTER_ANIMATION_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const SOUND_EFFECT_MODEL_VIDU = 'audio1.0';
export const DEFAULT_SOUND_EFFECT_MODEL = SOUND_EFFECT_MODEL_VIDU;
export const DEFAULT_SOUND_EFFECT_DURATION_SECONDS = 5;
export const SOUND_EFFECT_DURATION_OPTIONS = Array.from(
{ length: 9 },
(_, index) => index + 2,
);
export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5';
export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO;
const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
} as const;
export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
[VIDEO_MODEL_SEEDANCE_2_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
} as const;
export const EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = {
[CHARACTER_ANIMATION_MODEL]: {
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'],
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'],
},
} as const;
export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = {
[SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
} as const;
export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = {
[BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
} as const;
export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [
{ label: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU },
] as const;
export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
@@ -152,15 +335,15 @@ export const CHARACTER_ANIMATION_DURATION_OPTIONS = [
{ label: '40帧·5秒', frameCount: 40, durationSeconds: 5 },
{ label: '48帧·6秒', frameCount: 48, durationSeconds: 6 },
] as const;
export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 1280, height: 720 };
export const VIDEO_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 854, height: 480 };
export const VIDEO_FRAME_DISPLAY_SIZE = { width: 854, height: 480 };
export const AUDIO_FRAME_ORIGINAL_SIZE = { width: 420, height: 120 };
export const AUDIO_FRAME_DISPLAY_SIZE = { width: 420, height: 120 };
export const EDITOR_VIDEO_MODEL_OPTIONS = [
{ label: 'Seedance 2.0 Fast', value: VIDEO_MODEL_SEEDANCE_2_FAST },
{ label: 'Seedance 2.0', value: VIDEO_MODEL_SEEDANCE_2 },
{ label: 'Kling 3.0', value: 'kling3.0' },
{ label: 'Kling 3.0 Omni', value: 'kling3.0-omni' },
{ label: 'Kling 3.0', value: VIDEO_MODEL_KLING_3 },
{ label: 'Kling 3.0 Omni', value: VIDEO_MODEL_KLING_3_OMNI },
] as const;
export const EDITOR_VIDEO_DURATION_OPTIONS = [
{ label: '4秒', value: '4' },
@@ -379,22 +562,101 @@ export function formatLayerImageType(layer: CanvasLayer) {
}
export function calculateCharacterAnimationPrice(
model: string | null | undefined,
resolution: EditorCharacterAnimationResolution,
durationSeconds: number,
) {
return (
EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond[resolution] *
durationSeconds
);
const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim();
const modelRates =
EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL];
return modelRates[resolution] * durationSeconds;
}
export function calculateEditorVideoPrice(
model: string | null | undefined,
resolution: '480p' | '720p',
durationSeconds: number,
) {
const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim();
const modelRates =
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL];
return modelRates[resolution] * durationSeconds;
}
export function calculateEditorImageModelPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_IMAGE_MODEL).trim();
return (
EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond[resolution] *
durationSeconds
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image
);
}
export function calculateEditorSpecGenerationPrice(
model: string | null | undefined = SPEC_GENERATION_MODEL,
) {
const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim();
return (
EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]
);
}
export function calculateEditorImageGenerationPrice({
kind,
model,
}: {
kind?: string | null;
model?: string | null;
} = {}) {
const normalizedKind = kind?.trim();
if (normalizedKind === 'spec') {
return calculateEditorSpecGenerationPrice(model);
}
return calculateEditorImageModelPrice(model);
}
export function calculateEditorIconSpritesheetPrice(
model: string | null | undefined,
) {
return calculateEditorImageModelPrice(model);
}
export function calculateEditorUiDesignPrice(
model: string | null | undefined,
) {
return calculateEditorImageModelPrice(model);
}
export function calculateEditorSoundEffectPrice(
model: string | null | undefined,
) {
const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim();
return (
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG
] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL]
);
}
export function calculateEditorBackgroundMusicPrice(
model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL,
) {
const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim();
return (
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG
] ??
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
DEFAULT_BACKGROUND_MUSIC_MODEL
]
);
}
@@ -495,17 +757,15 @@ export function buildVideoGenerationInputs(
}
export function buildSoundEffectGenerationInputs(
sound: string,
soundType: string,
tempo: number | null | undefined,
prompt: string,
model: string,
duration: number,
): CanvasGenerationInputs {
return {
fields: [
...createGenerationInputField('sound', sound),
...createGenerationInputField('type', soundType),
...(tempo
? createGenerationInputField('tempo', `${tempo}BPM`)
: []),
...createGenerationInputField('prompt', prompt),
...createGenerationInputField('model', model),
...createGenerationInputField('duration', `${duration}秒`),
],
references: [],
};
@@ -6,6 +6,16 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
} from './ImageCanvasGenerationSubmissionModel';
import {
BACKGROUND_MUSIC_MODEL_SUNO,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
} from './ImageCanvasGenerationModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
@@ -268,6 +278,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: expect.stringContaining('【宣发素材类型】游戏首图'),
size: '720x540',
kind: 'publication-material',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,ref'],
},
result: {
@@ -290,6 +301,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
],
},
},
rememberImageModel: 'gemini-3.1-flash-image-preview',
});
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
expect(prompt).toContain('游戏名:重庆洪崖洞火锅');
@@ -372,6 +384,39 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
);
});
it('builds publication material plans with selected image model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '星际塔防',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
imageModel: 'gpt-image-2',
},
layers: [],
nextGeneratedIndex: 4,
});
expect(plan.kind).toBe('image');
if (plan.kind !== 'image') {
return;
}
expect(plan.input).toEqual(
expect.objectContaining({
kind: 'publication-material',
model: 'gpt-image-2',
size: '1280x720',
}),
);
expect(plan.rememberImageModel).toBe('gpt-image-2');
});
it('throws when edit source layer is missing', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
@@ -445,7 +490,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'gpt-image-2',
aspectRatio: '3:2',
imageSize: '2K',
priceMudPoints: 12,
priceMudPoints: calculateEditorIconSpritesheetPrice('gpt-image-2'),
},
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
@@ -496,7 +541,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
ratio: 'same',
frameCount: 48,
durationSeconds: 6,
priceMudPoints: 120,
priceMudPoints: calculateCharacterAnimationPrice(
CHARACTER_ANIMATION_MODEL,
'720p',
6,
),
model: 'seedance2.0-fast',
},
});
@@ -665,7 +714,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
normalizedPrompt: '角色绕场一周',
input: {
model: 'seedance2.0-fast',
priceMudPoints: 40,
priceMudPoints: calculateEditorVideoPrice('seedance2.0-fast', '480p', 4),
},
result: {
title: '生成视频 3',
@@ -695,7 +744,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'seedance2.0',
resolution: '720p',
durationSeconds: 5,
priceMudPoints: 100,
priceMudPoints: calculateEditorVideoPrice('seedance2.0', '720p', 5),
},
result: {
title: '生成视频 7',
@@ -709,8 +758,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
mode: 'audio-sound-effect',
prompt: ' 金币掉落叮当声 ',
status: 'idle',
soundType: 'loop',
soundTempo: 120,
soundDurationSeconds: 7,
},
layers: [],
nextGeneratedIndex: 4,
@@ -721,18 +769,18 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
audioKind: 'sound-effect',
normalizedPrompt: '金币掉落叮当声',
input: {
sound: '金币掉落叮当声',
type: 'loop',
tempo: 120,
priceMudPoints: 10,
prompt: '金币掉落叮当声',
model: 'audio1.0',
duration: 7,
priceMudPoints: calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL),
},
result: {
title: '游戏音效 4',
generationInputs: {
fields: [
{ title: 'sound', value: '金币掉落叮当声' },
{ title: 'type', value: 'loop' },
{ title: 'tempo', value: '120BPM' },
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
],
references: [],
},
@@ -740,14 +788,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('keeps game sound effect BPM unset as null by default', () => {
it('uses 5 seconds as default game sound effect duration', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '按钮确认短促音',
status: 'idle',
soundType: 'one-shot',
soundTempo: null,
},
layers: [],
nextGeneratedIndex: 6,
@@ -757,15 +803,16 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
kind: 'audio',
audioKind: 'sound-effect',
input: {
sound: '按钮确认短促音',
type: 'one-shot',
tempo: null,
prompt: '按钮确认短促音',
model: 'audio1.0',
duration: 5,
},
result: {
generationInputs: {
fields: [
{ title: 'sound', value: '按钮确认短促音' },
{ title: 'type', value: 'one-shot' },
{ title: 'prompt', value: '按钮确认短促音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '5秒' },
],
},
},
@@ -790,7 +837,9 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
input: {
gptDescriptionPrompt: '温暖轻快的森林冒险背景音乐',
makeInstrumental: true,
priceMudPoints: 5,
priceMudPoints: calculateEditorBackgroundMusicPrice(
BACKGROUND_MUSIC_MODEL_SUNO,
),
},
result: {
title: '游戏背景音乐 5',
@@ -27,13 +27,18 @@ import {
buildUiDesignGenerationInputs,
buildVideoGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
DEFAULT_BACKGROUND_MUSIC_MODEL,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
ICON_DESCRIPTION_LIMIT,
resolveCharacterAnimationSourceImageSrc,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
@@ -302,6 +307,7 @@ export function buildImageGenerationSubmissionPlan({
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const publicationPrompt =
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
normalizedPrompt;
@@ -316,6 +322,7 @@ export function buildImageGenerationSubmissionPlan({
prompt: generationPrompt,
size: workflow.outputSize,
kind: 'publication-material',
model: imageModel,
...(dialog.publicationReferences?.length
? {
referenceImageSrcs: dialog.publicationReferences.map(
@@ -331,6 +338,7 @@ export function buildImageGenerationSubmissionPlan({
dialog.publicationReferences,
),
},
rememberImageModel: imageModel,
};
}
@@ -357,6 +365,7 @@ export function buildImageGenerationSubmissionPlan({
mode: 'std',
sound: 'off',
priceMudPoints: calculateEditorVideoPrice(
model,
resolution,
durationSeconds,
),
@@ -381,24 +390,27 @@ export function buildImageGenerationSubmissionPlan({
}
if (dialog.mode === 'audio-sound-effect') {
const soundType = dialog.soundType ?? 'one-shot';
const soundTempo = dialog.soundTempo ?? null;
const soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const durationSeconds =
typeof dialog.soundDurationSeconds === 'number'
? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds)))
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
return {
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt,
input: {
sound: normalizedPrompt,
type: soundType,
tempo: soundTempo,
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
prompt: normalizedPrompt,
model: soundModel,
duration: durationSeconds,
priceMudPoints: calculateEditorSoundEffectPrice(soundModel),
},
result: {
title: `游戏音效 ${nextGeneratedIndex}`,
generationInputs: buildSoundEffectGenerationInputs(
normalizedPrompt,
soundType,
soundTempo,
soundModel,
durationSeconds,
),
},
};
@@ -412,7 +424,9 @@ export function buildImageGenerationSubmissionPlan({
input: {
gptDescriptionPrompt: normalizedPrompt,
makeInstrumental: true,
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
priceMudPoints: calculateEditorBackgroundMusicPrice(
DEFAULT_BACKGROUND_MUSIC_MODEL,
),
},
result: {
title: `游戏背景音乐 ${nextGeneratedIndex}`,
@@ -483,7 +497,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.icon,
priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel),
},
generationInputs: buildIconGenerationInputs(
iconDescriptions,
@@ -514,6 +528,7 @@ export function buildCharacterAnimationSubmissionPlan({
frameCount: panel.frameCount,
durationSeconds: panel.durationSeconds,
priceMudPoints: calculateCharacterAnimationPrice(
CHARACTER_ANIMATION_MODEL,
panel.resolution,
panel.durationSeconds,
),
@@ -20,8 +20,8 @@ import type {
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorIconSpritesheetPrice,
DEFAULT_ICON_DESCRIPTIONS,
EDITOR_GENERATION_MUD_POINT_CONFIG,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
@@ -200,7 +200,7 @@ export function ImageCanvasIconSpritesheetComposerView({
includeDimensions
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="生成图片"
cost={EDITOR_GENERATION_MUD_POINT_CONFIG.icon}
cost={calculateEditorIconSpritesheetPrice(dialog.imageModel)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
@@ -7,7 +7,6 @@ import {
type SetStateAction,
} from 'react';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
@@ -15,14 +14,17 @@ import {
} from '../common/PlatformFloatingMenu';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import type {
CharacterReferenceImage,
GenerateDialogState,
PublicationMaterialsGameInfo,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
buildPublicationMaterialsPrompt,
calculateEditorImageModelPrice,
DEFAULT_PUBLICATION_GAME_INFO,
} from './ImageCanvasGenerationModel';
import type { PublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
@@ -42,6 +44,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
placement: 'above' | 'below',
) => CSSProperties;
onRequestUpload: (target: UploadTarget) => void;
onRememberImageModel: (model: string) => void;
onSubmit: (dialog: GenerateDialogState) => void;
};
@@ -76,18 +79,24 @@ function updatePublicationGameInfoField(
function ReferenceThumb({
reference,
index,
onRemove,
}: {
reference: CharacterReferenceImage;
index: number;
onRemove: () => void;
}) {
const label = reference.label || `参考图${index + 1}`;
return (
<span
className="image-canvas-editor__publication-ref-thumb"
<ImageCanvasReferenceSlot
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
imageSrc={reference.src}
label={label}
ariaLabel={label}
title={reference.label}
>
<img src={reference.src} alt={reference.label} />
<span>{index + 1}</span>
</span>
onRemove={onRemove}
removeLabel={`删除${label}`}
/>
);
}
@@ -103,6 +112,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
renderEditorPortal,
buildPortalMenuStyle,
onRequestUpload,
onRememberImageModel,
onSubmit,
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
const references = dialog.publicationReferences ?? [];
@@ -112,26 +122,88 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
};
return (
<form
className="image-canvas-editor__publication-composer"
className="image-canvas-editor__character-composer image-canvas-editor__publication-composer"
style={style}
role="dialog"
aria-label={`${workflow.label}生成卡片`}
onPointerDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
onSubmit(dialog);
if (dialog.status !== 'generating') {
onSubmit(dialog);
}
}}
>
<header className="image-canvas-editor__publication-composer-header">
<div>
<span>{workflow.outputLabel}</span>
<h2>{workflow.englishName}</h2>
</div>
<strong>{workflow.sizeLabel}</strong>
</header>
<div className="image-canvas-editor__reference-strip">
{references.map((reference, index) => (
<ReferenceThumb
key={reference.id}
reference={reference}
index={index}
onRemove={() =>
setGenerateDialog((currentDialog) =>
currentDialog?.mode === 'publication'
? {
...resetFailedDialogStatus(currentDialog),
publicationReferences: (
currentDialog.publicationReferences ?? []
).filter((item) => item.id !== reference.id),
}
: currentDialog,
)
}
/>
))}
<ImageCanvasReferenceSlot
buttonRef={publicationReferenceButtonRef}
tone="default"
icon={<ImagePlus className="h-4 w-4" aria-hidden="true" />}
label="添加参考图"
ariaLabel="添加参考图"
disabled={dialog.status === 'generating'}
isAdd
onClick={() => setIsPublicationReferenceMenuOpen((open) => !open)}
/>
{isPublicationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__publication-reference-menu image-canvas-editor__portal-menu"
label="宣发素材参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
publicationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingPublicationReferenceFromCanvas(true);
setIsPublicationReferenceMenuOpen(false);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPublicationReferenceMenuOpen(false);
onRequestUpload('publication-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
<div className="image-canvas-editor__publication-composer-fields">
<label>
<div className="image-canvas-editor__publication-composer-summary">
<span>{workflow.outputLabel}</span>
<strong>{workflow.sizeLabel}</strong>
</div>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
@@ -157,7 +229,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
}
/>
</label>
<label>
<label className="image-canvas-editor__field-block">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
@@ -183,7 +255,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
}
/>
</label>
<label>
<label className="image-canvas-editor__field-block image-canvas-editor__publication-description-field">
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
@@ -210,69 +282,6 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
}
/>
</label>
<div>
<PlatformFieldLabel
variant="field"
className="image-canvas-editor__field-title"
>
参考图
</PlatformFieldLabel>
<div className="image-canvas-editor__publication-reference-list">
{references.map((reference, index) => (
<ReferenceThumb
key={reference.id}
reference={reference}
index={index}
/>
))}
<button
ref={publicationReferenceButtonRef}
type="button"
className="image-canvas-editor__publication-reference-add image-canvas-editor__reference-tile image-canvas-editor__reference-tile--upload"
disabled={dialog.status === 'generating'}
onClick={() => setIsPublicationReferenceMenuOpen((open) => !open)}
>
<span className="image-canvas-editor__reference-tile-visual">
<ImagePlus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="image-canvas-editor__reference-tile-copy">
添加参考图
</span>
</button>
</div>
{isPublicationReferenceMenuOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__publication-reference-menu image-canvas-editor__portal-menu"
label="宣发素材参考图来源"
placement="top-start"
style={buildPortalMenuStyle(
publicationReferenceButtonRef.current,
'above',
)}
>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPickingPublicationReferenceFromCanvas(true);
setIsPublicationReferenceMenuOpen(false);
}}
>
从画布中选择
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__context-menu-item"
onClick={() => {
setIsPublicationReferenceMenuOpen(false);
onRequestUpload('publication-reference');
}}
>
上传图片
</PlatformFloatingMenuItem>
</PlatformFloatingMenu>,
)
: null}
</div>
</div>
{dialog.status === 'failed' ? (
@@ -287,19 +296,20 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
</PlatformStatusMessage>
) : null}
<footer className="image-canvas-editor__publication-composer-footer">
<span>{workflow.scenario}</span>
<PlatformActionButton
type="submit"
tone="secondary"
size="xs"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={dialog.status === 'generating'}
>
{dialog.status === 'generating' ? '生成中' : '消耗5泥点 · 生成'}
</PlatformActionButton>
</footer>
<div className="image-canvas-editor__generation-composer-footer">
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setGenerateDialog}
includeDimensions={false}
onRememberImageModel={onRememberImageModel}
optionLabelPrefix="宣发素材"
cost={calculateEditorImageModelPrice(dialog.imageModel)}
submitLabel="生成"
submitAriaLabel="生成"
renderEditorPortal={renderEditorPortal}
buildPortalMenuStyle={buildPortalMenuStyle}
/>
</div>
</form>
);
}
@@ -22,7 +22,7 @@ import type {
QuickEditPanelState,
} from './ImageCanvasEditorTypes';
import {
EDITOR_GENERATION_MUD_POINT_CONFIG,
calculateEditorImageModelPrice,
QUICK_EDIT_REFERENCE_LIMIT,
} from './ImageCanvasGenerationModel';
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
@@ -231,7 +231,7 @@ export function ImageCanvasQuickEditPanelView({
<>
<span>生成</span>
<span className="image-canvas-editor__mud-point-inline">
{EDITOR_GENERATION_MUD_POINT_CONFIG.image}泥点
{calculateEditorImageModelPrice(panel.model)}泥点
</span>
</>
)}
@@ -61,7 +61,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'重绘',
'改造',
'下载按钮',
]);
@@ -70,7 +70,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
fireEvent.click(
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '重绘' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(props.selectedLayer);
@@ -108,7 +108,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'裁扩按钮',
'去除背景按钮',
'提取素材',
'重绘',
'改造',
'下载按钮',
]);
@@ -117,6 +117,39 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onExtractUiDesignAssets).toHaveBeenCalledWith(layer);
});
it('keeps only remodel and download for audio layers', () => {
const layer = createLayer({
title: '游戏音效',
src: 'data:audio/mpeg;base64,audio',
mediaType: 'audio',
assetKind: 'sound-effect',
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'改造',
'下载按钮',
]);
expect(within(toolbar).queryByRole('button', { name: '快速编辑' })).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '裁扩按钮' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '生成动画' }),
).toBeNull();
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(layer);
expect(props.onDownloadLayer).toHaveBeenCalledWith(layer);
});
it('renders character animation only for character layers', () => {
const layer = createLayer({
sourceType: 'generated',
@@ -39,6 +39,34 @@ export function ImageCanvasSelectedLayerToolbarView({
return null;
}
if (selectedLayer.mediaType === 'audio') {
return (
<div
className="image-canvas-editor__floating-toolbar"
style={selectedToolbarStyle}
role="toolbar"
aria-label="素材工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span>改造</span>
</PlatformIconButton>
<EditorIconButton
label="下载按钮"
title="下载按钮"
icon={Download}
onClick={() => onDownloadLayer(selectedLayer)}
/>
</div>
);
}
return (
<div
className="image-canvas-editor__floating-toolbar"
@@ -97,12 +125,12 @@ export function ImageCanvasSelectedLayerToolbarView({
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="重绘"
title="重绘"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span>重绘</span>
<span>改造</span>
</PlatformIconButton>
<EditorIconButton
label="下载按钮"

Some files were not shown because too many files have changed in this diff Show More