Files
Genarrative/src/components/image-editor/ImageCanvasGenerationModel.test.ts
T
suzmii 2c85ea0b83 修复序列帧后处理结果应用与存储幂等
视频直接转换和序列帧去背景按 operation 隔离对象存储路径
队列后处理失败统一返回脱敏用户文案
inline 转换和去背景结果直接应用服务端项目快照
修复去背景加载状态和相关前端回归测试
2026-08-15 14:27:55 +08:00

1284 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
applyEditorGenerationPricingConfig,
BACKGROUND_MUSIC_MODEL_SUNO,
buildCharacterAnimationGenerationInputs,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditGenerationInputs,
buildQuickEditModelOptions,
buildSceneGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignAssetExtractionGenerationInputs,
buildVideoGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorImageGenerationPrice,
calculateEditorImageModelPrice,
calculateEditorSoundEffectPrice,
calculateEditorSpecGenerationPrice,
calculateEditorUiDesignPrice,
calculateEditorVideoPrice,
canOpenRedrawPanel,
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
decodeCanvasGenerationInputs,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_VIDEO_MODEL,
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG,
EDITOR_IMAGE_MODEL_OPTIONS,
EDITOR_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG,
EDITOR_SOUND_EFFECT_MODEL_OPTIONS,
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG,
EDITOR_VIDEO_MODEL_OPTIONS,
formatEditorGenerationInputFieldValue,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
isNormalizedCanvasGenerationInputs,
isQuickEditSupportedLayer,
resolveCharacterAnimationSourceImageSrc,
resolveEditorCharacterAnimationFramePixelSize,
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
VIDEO_MODEL_KLING_3,
VIDEO_MODEL_KLING_3_OMNI,
VIDEO_MODEL_SEEDANCE_2,
VIDEO_MODEL_VEO_3_1,
VIDEO_MODEL_VEO_3_1_FAST,
} from './ImageCanvasGenerationModel';
describe('ImageCanvasGenerationModel', () => {
it('uses a character animation preview video when a video reference is requested', () => {
const reference = createCanvasLayerReference(
{
id: 'layer-sequence',
resourceId: 'resource-sequence',
sourceResourceId: 'resource-motion-video',
title: '序列帧动作',
src: '/frames/frame-001.png',
previewVideoPath: '/motion/preview.mp4',
mediaType: 'image-sequence',
assetKind: 'character-animation',
x: 0,
y: 0,
width: 480,
height: 480,
originalWidth: 480,
originalHeight: 480,
zIndex: 1,
sourceType: 'generated',
},
{ preferSequenceVideo: true },
);
expect(reference).toMatchObject({
mediaType: 'video',
src: '/motion/preview.mp4',
objectKey: '/motion/preview.mp4',
resourceId: 'resource-motion-video',
});
});
it('resolves character animation pixel and display sizes from ratio and resolution', () => {
expect(
resolveEditorCharacterAnimationFramePixelSize({
ratio: '16:9',
resolution: '480p',
}),
).toEqual({ width: 480, height: 270 });
expect(
resolveEditorCharacterAnimationFramePixelSize({
ratio: '16:9',
resolution: '720p',
}),
).toEqual({ width: 720, height: 405 });
const sameRatioSize = resolveEditorCharacterAnimationFramePixelSize({
ratio: 'same',
resolution: '720p',
referenceWidth: 768,
referenceHeight: 1024,
});
expect(sameRatioSize).toEqual({ width: 540, height: 720 });
});
it('persists character animation reference dimensions for same-ratio redraws', () => {
const inputs = buildCharacterAnimationGenerationInputs(
'原地待机',
null,
{
id: 'uploaded-character',
label: '竖版角色.png',
src: '/uploads/character.png',
mediaType: 'image',
width: 768,
height: 1024,
},
{
referenceSrc: 'users/user-1/character.png',
referenceMediaType: 'image',
ratio: 'same',
},
);
expect(inputs.fields).toEqual(
expect.arrayContaining([
{ id: 'referenceWidth', title: '参考宽度', value: 768 },
{ id: 'referenceHeight', title: '参考高度', value: 1024 },
{ id: 'ratio', title: '比例', value: 'same' },
]),
);
expect(
inputs.fields.find((field) => field.id === 'backgroundColor'),
).toBeUndefined();
});
it('persists motion and appearance slots for video-guided character animation', () => {
const inputs = buildCharacterAnimationGenerationInputs(
'复刻动作并替换外观',
null,
{
id: 'motion-video',
label: '奔跑动作.mov',
src: 'users/user-1/motion.mov',
mediaType: 'video',
resourceId: 'resource-motion',
width: 1920,
height: 1080,
},
{
referenceSrc: 'users/user-1/motion.mov',
referenceMediaType: 'video',
appearanceReferences: [
{
id: 'appearance-image',
label: '骑士外观.png',
src: 'users/user-1/appearance.png',
mediaType: 'image',
sourceAssetId: 'asset-appearance',
},
],
appearanceReferenceSrcs: ['users/user-1/appearance.png'],
},
);
expect(inputs.references).toEqual([
expect.objectContaining({
id: 'motion',
refType: 'project-resource',
refId: 'resource-motion',
}),
expect.objectContaining({
id: 'appearance',
refType: 'asset',
refId: 'asset-appearance',
}),
]);
expect(inputs.fields).toEqual(
expect.arrayContaining([
{ id: 'inputMode', title: '输入模式', value: 'video' },
{
id: 'appearanceReferenceSrc',
title: '参考图',
value: 'users/user-1/appearance.png',
},
]),
);
});
afterEach(() => {
applyEditorGenerationPricingConfig({
models: EDITOR_MODEL_MUD_POINT_CONFIG,
});
});
it('展示时长时收敛到两位小数且不改动其他字段', () => {
const format = (title: string, value: string) =>
formatEditorGenerationInputFieldValue({ title, value });
expect(format('时长', '8.306938775510204秒')).toBe('8.31秒');
expect(format('时长', '3.4512秒')).toBe('3.45秒');
// 整数与一位小数不补零,"自动"这类无数字取值原样保留。
expect(format('时长', '5秒')).toBe('5秒');
expect(format('时长', '2.5秒')).toBe('2.5秒');
expect(format('时长', '自动')).toBe('自动');
expect(format('duration', '8.306938775510204s')).toBe('8.31s');
// 非时长字段一律原样返回,不能误伤提示词里的数字。
expect(format('用户描述', '持续 8.306938775510204 秒的低鸣')).toBe(
'持续 8.306938775510204 秒的低鸣',
);
});
it('为所有编辑器生成模型提供显式定价配置', () => {
expect(
EDITOR_IMAGE_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value).filter(
(model) => !(model in EDITOR_MODEL_MUD_POINT_CONFIG),
),
).toEqual([]);
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K'],
).toBe(3);
expect(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
).toBe(5);
expect(
EDITOR_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL],
).toBeTruthy();
expect(
EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL],
).toBe(5);
expect(
EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[
BACKGROUND_MUSIC_MODEL_SUNO
],
).toBe(12);
// 中文注释: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.each([
[IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 683],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024],
[IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1365],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048],
[IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152],
] as const)(
'%s 的 %s %s 占位尺寸与统一业务交付尺寸一致',
(model, imageSize, aspectRatio, width, height) => {
expect(
resolveEditorImageGenerationPixelSize({
model,
aspectRatio,
imageSize,
}),
).toEqual({ width, height });
},
);
it('所有生成按钮价格都从模型定价配置推导', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
3,
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
5,
);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
model: IMAGE_MODEL_GPT_IMAGE_2,
imageSize: '1K',
}),
).toBe(EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['1K']);
expect(calculateEditorIconSpritesheetPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'],
);
expect(calculateEditorUiDesignPrice(IMAGE_MODEL_GPT_IMAGE_2, '2K')).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2]['2K'],
);
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, '720p', 5)).toBe(150);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
200,
);
expect(
calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6),
).toBe(
EDITOR_VIDEO_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('支持用后端下发的模型定价覆盖按钮泥点', () => {
applyEditorGenerationPricingConfig({
models: {
...EDITOR_MODEL_MUD_POINT_CONFIG,
[IMAGE_MODEL_GPT_IMAGE_2]: {
unit: 'perGeneration',
prices: {
'1K': 29,
'2K': 58,
},
},
[VIDEO_MODEL_SEEDANCE_2]: {
unit: 'perSecond',
prices: {
...EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2],
'720p': 26,
},
},
[DEFAULT_SOUND_EFFECT_MODEL]: {
unit: 'perGeneration',
price: 15,
},
[BACKGROUND_MUSIC_MODEL_SUNO]: {
unit: 'perGeneration',
price: 9,
},
},
});
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2, '1K')).toBe(
29,
);
expect(calculateEditorSpecGenerationPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(
58,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_SEEDANCE_2, '720p', 5)).toBe(
130,
);
expect(calculateEditorSoundEffectPrice(DEFAULT_SOUND_EFFECT_MODEL)).toBe(
15,
);
expect(
calculateEditorBackgroundMusicPrice(BACKGROUND_MUSIC_MODEL_SUNO),
).toBe(9);
});
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '一张明亮主视觉' },
{
id: 'model',
title: '模型',
value: 'gemini-3.1-flash-image-preview',
},
{ id: 'style', title: '风格', value: 'none' },
{ id: 'aspectRatio', title: '比例', value: '1:1' },
{ id: 'imageSize', title: '尺寸', value: '1K' },
],
references: [],
});
expect(
buildSpecGenerationInputs('character', {
playSetting: '平台跳跃',
artStyle: '像素风',
bodyRatio: '3',
characterView: '右向三分之二侧身',
customPrompt: '',
}),
).toEqual(
expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'character' },
{ id: 'playSetting', title: '玩法设定', value: '平台跳跃' },
{ id: 'artStyle', title: '美术风格', value: '像素风' },
{ id: 'bodyRatio', title: '头身比', value: '3' },
{ id: 'characterView', title: '角色视角', value: '右向三分之二侧身' },
],
references: [],
}),
);
expect(
buildSpecGenerationInputs('icon', {
playSetting: '休闲消除',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
}),
).toEqual(
expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'icon' },
{ id: 'playSetting', title: '玩法设定', value: '休闲消除' },
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
],
references: [],
}),
);
});
it('builds character, icon and edit reference snapshots', () => {
const sourceLayer = buildSourceLayer();
expect(
buildCharacterGenerationInputs(
'主角骑士',
{
id: 'spec',
label: '角色规范',
src: '/spec.png',
resourceId: 'resource-spec',
},
[
{
id: 'ref-1',
label: '盔甲参考',
src: '/armor.png',
resourceId: 'resource-armor',
},
],
),
).toEqual(
expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '主角骑士' },
]),
references: [
{
id: 'specReference',
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
}),
);
expect(
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
id: 'icon-spec',
label: '图标规范',
src: '/icon-spec.png',
resourceId: 'resource-icon-spec',
}),
).toEqual(
expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
}),
);
expect(
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
).toEqual(
expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '修改要求', value: '换成夜晚' },
]),
references: [
{
id: 'source',
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
}),
);
expect(
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
{
id: 'ref-ui',
label: 'UI风格参考',
src: '/ui-style.png',
resourceId: 'resource-ui-style',
},
]),
).toEqual(
expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '提取提示词',
}),
]),
references: [
{
id: 'source',
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
}),
);
expect(
buildQuickEditGenerationInputs(
'快速编辑提示词',
'让图2下雨',
sourceLayer,
[
{
id: 'ref-1',
label: '天空',
src: '/sky.png',
resourceId: 'resource-sky',
},
],
),
).toEqual(
expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' },
]),
references: [
{
id: 'source',
title: '原图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
],
}),
);
});
it('only exposes redraw for executable generation metadata', () => {
const generatedLayer = {
...buildSourceLayer(),
sourceType: 'generated' as const,
};
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: buildImageGenerationInputs('生成城堡'),
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'scene',
generationInputs: buildSceneGenerationInputs(
'雨夜车站',
'watercolor',
'',
[],
{
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
},
),
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
assetKind: 'scene',
generationInputs: {
fields: [{ title: '画面内容', value: '历史场景' }],
references: [],
},
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
fields: [{ title: '生成提示词', value: '旧版城堡' }],
references: [],
},
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: null,
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
fields: [{ title: '生成提示词', value: '公开展示文本' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
action: 'unknown.generate' as never,
fields: [{ id: 'prompt', title: '提示词', value: '未知动作' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
sourceType: 'uploaded',
generationInputs: buildImageGenerationInputs('上传文件'),
}),
).toBe(true);
for (const action of [
'image.edit',
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const) {
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
action,
fields: [],
references: [],
},
}),
).toBe(false);
}
expect(
canOpenRedrawPanel({
...generatedLayer,
taskId: 'pixel-art-snap-legacy-dialog',
generationInputs: buildImageGenerationInputs('历史继承配方'),
}),
).toBe(false);
});
it('rejects malformed persisted generation metadata without throwing', () => {
expect(
isNormalizedCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [{ id: 42, title: '生成提示词', value: '城堡' } as never],
references: [],
}),
).toBe(false);
expect(
isNormalizedCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '城堡' }],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 7,
} as never,
],
}),
).toBe(false);
expect(() =>
canOpenRedrawPanel({
...buildSourceLayer(),
sourceType: 'generated',
generationInputs: {
fields: [{ title: 42, value: '旧数据' } as never],
references: [],
},
}),
).not.toThrow();
expect(
canOpenRedrawPanel({
...buildSourceLayer(),
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 42, title: '生成提示词', value: '损坏数据' } as never],
references: [],
},
}),
).toBe(true);
});
it('stores the video web-search setting in reusable metadata', () => {
expect(
buildVideoGenerationInputs('镜头推进', [], {
webSearchEnabled: false,
}).fields,
).toContainEqual({
id: 'webSearchEnabled',
title: '联网搜索',
value: false,
});
});
it('keeps valid V2 recipes and supported image aliases canonical', () => {
const valid = decodeCanvasGenerationInputs(
buildImageGenerationInputs('森林城堡', [], {
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '2K',
}),
);
expect(valid).toMatchObject({ ok: true, warnings: [] });
const alias = decodeCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '像素村庄' },
{ id: 'model', title: '模型', value: 'nanobanana2' },
{ id: 'aspectRatio', title: '比例', value: '4:3' },
{ id: 'imageSize', title: '尺寸', value: '0.5K' },
],
references: [],
});
expect(alias).toMatchObject({
ok: true,
warnings: [expect.objectContaining({ fieldIds: ['style'] })],
});
expect(
alias.ok
? alias.inputs.fields.find((field) => field.id === 'model')?.value
: null,
).toBe(IMAGE_MODEL_NANOBANANA2);
});
it('falls invalid image and workflow parameters back within V2', () => {
const image = decodeCanvasGenerationInputs({
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '森林城堡' },
{ id: 'model', title: '模型', value: 'removed-image-model' },
{ id: 'style', title: '风格', value: 'oil' },
{ id: 'aspectRatio', title: '比例', value: '7:5' },
{ id: 'imageSize', title: '尺寸', value: '8K' },
],
references: [],
});
expect(image.ok).toBe(true);
if (!image.ok) {
throw new Error('图片 V2 解码失败');
}
expect(
Object.fromEntries(
image.inputs.fields.map((field) => [field.id, field.value]),
),
).toMatchObject({
model: DEFAULT_IMAGE_MODEL,
style: 'none',
aspectRatio: '1:1',
imageSize: '1K',
});
expect(image.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({ fieldIds: ['model'] }),
expect.objectContaining({ fieldIds: ['style'] }),
expect.objectContaining({ fieldIds: ['aspectRatio'] }),
expect.objectContaining({ fieldIds: ['imageSize'] }),
]),
);
expect(image.warnings[0]?.message).toBe(
CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING,
);
const workflow = decodeCanvasGenerationInputs({
version: 2,
action: 'publication.generate',
fields: [
{ id: 'workflowId', title: '宣发类型', value: 'retired-workflow' },
],
references: [],
});
expect(workflow).toMatchObject({
ok: true,
inputs: {
version: 2,
action: 'publication.generate',
fields: [
{
id: 'workflowId',
title: '宣发类型',
value: 'publication-cover-image',
},
],
},
warnings: [expect.objectContaining({ fieldIds: ['workflowId'] })],
});
});
it('falls retired and incompatible video parameters back to current defaults', () => {
const decoded = decodeCanvasGenerationInputs({
version: 2,
action: 'video.generate',
fields: [
{ id: 'prompt', title: '视频描述', value: '追逐镜头' },
{ id: 'model', title: '模型', value: 'veo3.1' },
{ id: 'aspectRatio', title: '比例', value: '7:5' },
{ id: 'resolution', title: '清晰度', value: '1080p' },
{ id: 'durationSeconds', title: '时长', value: 99 },
{ id: 'sound', title: '声音', value: 'surround' },
{ id: 'webSearchEnabled', title: '联网搜索', value: 'yes' },
],
references: [],
});
expect(decoded.ok).toBe(true);
if (!decoded.ok) {
throw new Error('视频 V2 解码失败');
}
expect(
Object.fromEntries(
decoded.inputs.fields.map((field) => [field.id, field.value]),
),
).toMatchObject({
model: DEFAULT_VIDEO_MODEL,
aspectRatio: '16:9',
resolution: '480p',
durationSeconds: 4,
sound: 'on',
webSearchEnabled: true,
});
expect(decoded.warnings.map((warning) => warning.fieldIds)).toEqual(
expect.arrayContaining([
['model'],
['aspectRatio'],
['resolution'],
['durationSeconds'],
['sound'],
['webSearchEnabled'],
]),
);
});
it('materializes and warns about missing action parameters', () => {
const decoded = decodeCanvasGenerationInputs({
version: 2,
action: 'video.generate',
fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }],
references: [],
});
expect(decoded).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({ fieldIds: ['model'] }),
expect.objectContaining({ fieldIds: ['durationSeconds'] }),
]),
});
const animation = decodeCanvasGenerationInputs({
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
references: [],
});
expect(animation).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'frameCount', value: 32 }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({
fieldIds: ['frameCount', 'durationSeconds'],
}),
]),
});
});
it('falls invalid sound and character animation parameters back to complete defaults', () => {
const sound = decodeCanvasGenerationInputs({
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ id: 'prompt', title: 'prompt', value: '点击音' },
{ id: 'model', title: 'model', value: 'retired-audio-model' },
{ id: 'durationSeconds', title: 'duration', value: 31 },
],
references: [],
});
expect(sound).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({
id: 'model',
value: 'eleven_text_to_sound_v2',
}),
expect.objectContaining({ id: 'durationSeconds', value: 5 }),
expect.objectContaining({ id: 'durationMode', value: 'manual' }),
expect.objectContaining({ id: 'loop', value: false }),
]),
},
});
const animation = decodeCanvasGenerationInputs({
version: 2,
action: 'character-animation.generate',
fields: [
{ id: 'prompt', title: '动作描述', value: '挥手' },
{ id: 'resolution', title: '清晰度', value: '1080p' },
{ id: 'ratio', title: '比例', value: '7:5' },
{ id: 'frameCount', title: '帧数', value: 40 },
{ id: 'durationSeconds', title: '时长', value: 6 },
],
references: [],
});
expect(animation).toMatchObject({
ok: true,
inputs: {
fields: expect.arrayContaining([
expect.objectContaining({ id: 'resolution', value: '480p' }),
expect.objectContaining({ id: 'ratio', value: 'same' }),
expect.objectContaining({ id: 'frameCount', value: 32 }),
expect.objectContaining({ id: 'durationSeconds', value: 4 }),
]),
},
warnings: expect.arrayContaining([
expect.objectContaining({ fieldIds: ['resolution'] }),
expect.objectContaining({ fieldIds: ['ratio'] }),
expect.objectContaining({
fieldIds: ['frameCount', 'durationSeconds'],
}),
]),
});
});
it('keeps custom prompts and quick edit options stable', () => {
expect(
buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }),
).toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(buildQuickEditModelOptions('nanobanana2')).toEqual([
{
label: 'nanobanana2',
value: 'gemini-3.1-flash-image-preview',
},
{ label: 'gpt-image-2', value: 'gpt-image-2' },
]);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'image',
assetKind: null,
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'icon-spritesheet',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
assetKind: 'scene',
resourceAssetKind: 'icon',
}),
).toBe(true);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'image-sequence',
assetKind: 'character-animation',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'audio',
assetKind: 'sound-effect',
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'video',
assetKind: 'video',
}),
).toBe(true);
});
it('adds reference image semantics and snapshots for spec generation references', () => {
expect(
buildSpecPrompt(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
true,
),
).toContain('生成一张怪兽规范图');
expect(
buildSpecGenerationInputs(
'custom',
{ ...blankSpecValues, customPrompt: '生成一张怪兽规范图' },
{
id: 'spec-ref',
label: '参考.png',
src: '/ref.png',
resourceId: 'resource-ref',
},
),
).toEqual({
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'custom' },
{
id: 'customPrompt',
title: '自定义规范提示词',
value: '生成一张怪兽规范图',
},
],
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-ref',
},
],
});
});
it('uses objectKey for character animation references before falling back to src', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
objectKey: 'generated/character.png',
}),
).toBe('generated/character.png');
expect(resolveCharacterAnimationSourceImageSrc(buildSourceLayer())).toBe(
'/source.png',
);
});
it('uses the extracted transparent frame when reusing a character animation sequence layer', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
assetKind: 'character-animation',
mediaType: 'image-sequence',
src: '/generated-character-drafts/editor/frame01.png',
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
imageSequenceFrames: [
{
imageSrc: '/generated-character-drafts/editor/frame01.png',
objectKey: 'generated-character-drafts/editor/frame01.png',
width: 512,
height: 512,
},
{
imageSrc: '/generated-character-drafts/editor/frame02.png',
width: 512,
height: 512,
},
],
}),
).toBe('generated-character-drafts/editor/frame01.png');
});
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
const iconDialog: CanvasGenerationDialogState = {
id: 'dialog-icon',
mode: 'icon',
prompt: '',
status: 'idle',
};
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
getGenerationFrameAriaLabel({
...iconDialog,
mode: 'quick-edit',
}),
).toBe('快速编辑生成占位图');
expect(getGenerationFrameLabel({ ...iconDialog, mode: 'quick-edit' })).toBe(
'Quick Edit Generator',
);
expect(
buildSoundEffectGenerationInputs(
'金币掉落叮当声',
'eleven_text_to_sound_v2',
7,
true,
),
).toEqual({
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ id: 'prompt', title: '用户描述', value: '金币掉落叮当声' },
{ id: 'model', title: 'model', value: 'eleven_text_to_sound_v2' },
{ id: 'durationMode', title: '时长模式', value: 'manual' },
{ id: 'durationSeconds', title: '请求时长', value: 7 },
{ id: 'loop', title: 'Loop', value: true },
],
references: [],
});
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '未授权访问(requestId: one',
status: 401,
code: 'UNAUTHORIZED',
}),
),
).toBe('请先登录后再生成图片');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '请求冲突(requestId: wallet-one',
status: 409,
code: 'CONFLICT',
details: { provider: 'profile-wallet' },
}),
),
).toBe('泥点余额不足,请充值后再生成');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '请求冲突(requestId: conflict-one',
status: 409,
code: 'CONFLICT',
}),
),
).toBe('生成任务未完成,请稍后重试');
});
});
const blankSpecValues = {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView: '',
customPrompt: '',
};
function buildSourceLayer(): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '原图',
src: '/source.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'uploaded',
};
}