对齐编辑器生成占位与面板参数
统一画布生成占位图标,按图片、规范、角色、图标、UI、视频、音频、宣发和快速编辑区分。 让图片、规范、宣发和视频新建占位按面板尺寸或固定输出尺寸创建,并在参数变化时同步占位大小。 完善快速编辑、音频生成、宣发素材和生成类面板相关契约与测试。 更新生成类面板 Lovart 改造文档和项目决策记录。
This commit is contained in:
@@ -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
Reference in New Issue
Block a user