Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
T
k88936 d901945c77
Project CI / Repository checks (push) Successful in 1m20s
Project CI / Backend tests (push) Successful in 3m56s
Project CI / Native shell tests (push) Successful in 14m1s
Project CI / Frontend tests (push) Successful in 2m53s
移除视频快速编辑支持 (#157)
当前实现: 视频快速编辑错误地复用了图片快速编辑的panel, 然后发起一个实际是生成视频的请求

策划建议删除.

因为后端没有实际意义上的视频快速编辑, 所以api 后端代码没有改动, 只在前端删除入口和处理

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/157
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-14 18:52:56 +08:00

1542 lines
44 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 { describe, expect, it } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
describe('ImageCanvasGenerationSubmissionModel', () => {
it('resolves registered image-edit sources by resource ID first', () => {
expect(
resolveRegisteredEditorReferenceId({
resourceId: ' resource-source ',
sourceAssetId: 'asset-source',
}),
).toBe('resource-source');
});
it('falls back to a registered asset ID for image edits', () => {
expect(
resolveRegisteredEditorReferenceId({
resourceId: 'local-resource-source',
sourceAssetId: ' asset-source ',
}),
).toBe('asset-source');
});
it('rejects unregistered image-edit sources', () => {
expect(() =>
resolveRegisteredEditorReferenceId({
resourceId: 'generation-dialog:source',
sourceAssetId: 'upload-source',
}),
).toThrow('原图尚未登记为项目资源或素材');
});
it('builds normal image generation submission plans', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: ' 一张发光主视觉 ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '一张发光主视觉',
input: {
prompt: '一张发光主视觉',
model: 'gemini-3.1-flash-image-preview',
style: 'none',
aspectRatio: '1:1',
imageSize: '1K',
},
result: {
title: '生成图片 3',
generationInputs: expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '生成提示词', value: '一张发光主视觉' },
]),
references: [],
}),
},
rememberImageModel: 'gemini-3.1-flash-image-preview',
});
});
it('builds structured scene generation plans without assembling backend prompts', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'scene',
prompt: ' 雨夜中的欧洲小镇街道 ',
status: 'idle',
sceneStylePreset: 'watercolor',
sceneCustomStyle: '',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
generationReferences: [
{
id: 'scene-reference-1',
label: '场景参考一',
src: '/scene-reference-1.png',
objectKey: 'users/user-1/scene-reference-1.png',
resourceId: 'scene-resource-1',
},
{
id: 'scene-reference-2',
label: '场景参考二',
src: '/scene-reference-2.png',
objectKey: 'users/user-1/scene-reference-2.png',
resourceId: 'scene-resource-2',
},
],
},
layers: [],
nextGeneratedIndex: 2,
});
expect(plan).toMatchObject({
kind: 'scene',
normalizedPrompt: '雨夜中的欧洲小镇街道',
input: {
sceneContent: '雨夜中的欧洲小镇街道',
stylePreset: 'watercolor',
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '1K',
referenceImageSrcs: [
'users/user-1/scene-reference-1.png',
'users/user-1/scene-reference-2.png',
],
},
result: {
assetKind: 'scene',
title: '游戏场景 2',
generationInputs: {
version: 2,
action: 'scene.generate',
fields: [
{
id: 'prompt',
title: '画面内容',
value: '雨夜中的欧洲小镇街道',
},
{ id: 'stylePreset', title: '视觉风格', value: 'watercolor' },
{
id: 'model',
title: '模型',
value: IMAGE_MODEL_NANOBANANA2,
},
{ id: 'aspectRatio', title: '比例', value: '16:9' },
{ id: 'imageSize', title: '尺寸', value: '1K' },
],
references: [
{
id: 'reference',
title: '场景参考图 1',
label: '场景参考一',
refType: 'project-resource',
refId: 'scene-resource-1',
},
{
id: 'reference',
title: '场景参考图 2',
label: '场景参考二',
refType: 'project-resource',
refId: 'scene-resource-2',
},
],
},
},
});
expect(JSON.stringify(plan)).not.toContain('仅生成环境背景');
});
it('requires custom style content for scene generation', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'scene',
prompt: '海边车站',
status: 'idle',
sceneStylePreset: 'custom',
sceneCustomStyle: ' ',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('请填写自定义画风');
});
it('trims custom asset names and limits them to 80 characters', () => {
const customName = ` ${'名'.repeat(81)} `;
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: '一张发光主视觉',
assetLabel: customName,
status: 'idle',
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toMatchObject({
kind: 'image',
result: { title: '名'.repeat(80) },
});
});
it('normalizes legacy nanobanana aliases before submitting image generation', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: '一张明亮图片',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '2K',
},
layers: [],
nextGeneratedIndex: 5,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '16:9',
imageSize: '2K',
},
rememberImageModel: IMAGE_MODEL_NANOBANANA2,
});
});
it('builds edit submission plans with the source layer snapshot', () => {
const sourceLayer = createLayer();
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: sourceLayer.id,
imageModel: 'unknown-model',
aspectRatio: '7:5',
imageSize: '8K',
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
});
expect(plan).toMatchObject({
kind: 'edit',
normalizedPrompt: '修改当前图片',
sourceLayer,
editInput: {
model: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '4:3',
imageSize: '1K',
size: '1024x768',
},
generationInputs: 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',
},
],
}),
});
});
it('builds edit submission plans from persisted image-edit inputs', () => {
const sourceLayer = createLayer({
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '修改要求', value: 'persisted' },
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
],
references: [
{
id: 'source',
title: '参考图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
model: IMAGE_MODEL_GPT_IMAGE_2,
originalWidth: 2048,
originalHeight: 1152,
});
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: sourceLayer.id,
imageModel: 'unknown-model',
aspectRatio: '7:5',
imageSize: '8K',
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
});
expect(plan).toMatchObject({
kind: 'edit',
editInput: {
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
size: '1365x2048',
},
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
{ id: 'model', title: '模型', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '比例', value: '2:3' },
{ id: 'imageSize', title: '尺寸', value: '2K' },
]),
}),
});
});
it('builds quick-edit canvas generator plans as direct source edits', () => {
const sourceLayer = createLayer({
objectKey: 'generated-character-drafts/editor/source.png',
assetKind: 'character',
originalWidth: 1537,
originalHeight: 1025,
});
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'quick-edit',
prompt: '把当前图改成雨天',
status: 'idle',
sourceLayerId: sourceLayer.id,
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '4:3',
imageSize: '1K',
generationReferences: [
{
id: 'style-reference',
label: '风格参考',
src: 'data:image/png;base64,style',
resourceId: 'resource-style-reference',
},
],
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
});
expect(plan).toMatchObject({
kind: 'quick-edit',
normalizedPrompt: '把当前图改成雨天',
sourceLayer,
editInput: {
size: '1024x768',
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,style'],
},
result: {
title: '源图 快速编辑',
assetKind: 'character',
generationInputs: 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',
},
{
id: 'reference',
title: '参考图 1',
label: '风格参考',
refType: 'project-resource',
refId: 'resource-style-reference',
},
],
}),
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
});
it('rejects persisted video quick-edit dialogs during submission planning', () => {
const sourceLayer = createLayer({
title: '历史视频',
src: 'https://assets.example.test/history.mp4',
mediaType: 'video',
assetKind: 'video',
originalWidth: 854,
originalHeight: 480,
});
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'quick-edit',
prompt: '把当前画面改成雨天',
status: 'idle',
sourceLayerId: sourceLayer.id,
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
}),
).toThrow('当前素材类型不支持快速编辑');
});
it('does not send image style from an incomplete quick-edit snapshot', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'quick-edit',
prompt: '把当前图改成雨天',
status: 'idle',
style: 'pixelArt',
},
layers: [],
nextGeneratedIndex: 4,
});
expect(plan.kind).toBe('image');
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
'style',
);
});
it('builds icon spec generation plans without a client prompt or backend kind', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'icon',
specValues: {
playSetting: '休闲消除',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
specReference: {
id: 'spec-ref',
label: '参考.png',
src: 'data:image/png;base64,ref',
resourceId: 'resource-spec-ref',
},
},
layers: [],
nextGeneratedIndex: 8,
});
expect(plan).toMatchObject({
kind: 'icon-spec',
normalizedPrompt: 'AI 生成图片',
input: {
playSetting: '休闲消除',
artStyle: '清爽卡通',
referenceId: 'resource-spec-ref',
generationInputs: {
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'icon' },
{ id: 'playSetting', title: '玩法设定', value: '休闲消除' },
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
],
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
result: {
assetKind: 'icon-spec',
title: '图标规范 8',
generationInputs: {
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'icon' },
{ id: 'playSetting', title: '玩法设定', value: '休闲消除' },
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
],
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
},
});
expect(
plan.kind === 'icon-spec' ? plan.result.generationInputs : null,
).toEqual(
expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: expect.arrayContaining([
{ id: 'playSetting', title: '玩法设定', value: '休闲消除' },
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
]),
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
}),
);
});
it('does not add optional reference fields to an icon spec without a reference', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'icon',
specValues: {
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
},
layers: [],
nextGeneratedIndex: 2,
});
expect(plan).toMatchObject({
kind: 'icon-spec',
input: {
playSetting: '消除类派对玩法',
artStyle: '糖果玻璃拟物',
generationInputs: {
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'icon' },
{
id: 'playSetting',
title: '玩法设定',
value: '消除类派对玩法',
},
{ id: 'artStyle', title: '美术风格', value: '糖果玻璃拟物' },
],
references: [],
},
},
result: {
assetKind: 'icon-spec',
title: '图标规范 2',
},
});
expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty(
'referenceImageSrcs',
);
expect(plan.kind === 'icon-spec' ? plan.input : null).not.toHaveProperty(
'prompt',
);
});
it('builds character plans with references and remembered model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'character',
prompt: ' 白发骑士 ',
status: 'idle',
imageModel: 'gpt-image-2',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '2K',
characterSpecReference: {
id: 'spec',
label: '角色规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-character-spec',
},
characterReferences: [
{
id: 'ref-1',
label: '盔甲参考',
src: 'data:image/png;base64,armor',
resourceId: 'resource-armor',
},
],
},
layers: [],
nextGeneratedIndex: 9,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '白发骑士',
input: {
prompt: '白发骑士',
kind: 'character',
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '2K',
referenceImageSrcs: [
'data:image/png;base64,spec',
'data:image/png;base64,armor',
],
},
result: {
assetKind: 'character',
title: '角色形象 9',
},
rememberImageModel: 'gpt-image-2',
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).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-character-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
}),
);
});
it('locks UI design generation submissions to gpt-image-2', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'ui-design',
prompt: ' 设计移动端商城首页 ',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '0.5K',
uiDesignSpecReference: {
id: 'ui-spec',
label: 'UI图标规范',
src: 'data:image/png;base64,ui-spec',
resourceId: 'resource-ui-spec',
},
generationReferences: [
{
id: 'ui-ref',
label: '界面风格参考',
src: 'data:image/png;base64,ui-ref',
resourceId: 'resource-ui-ref',
},
],
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '设计移动端商城首页',
input: {
kind: 'ui-design',
model: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '16:9',
imageSize: '1K',
referenceImageSrcs: [
'data:image/png;base64,ui-spec',
'data:image/png;base64,ui-ref',
],
},
result: {
assetKind: 'ui-design',
title: 'UI设计图 6',
generationInputs: expect.objectContaining({
version: 2,
action: 'ui-design.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '用户输入', value: '设计移动端商城首页' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: 'UI图标规范',
refType: 'project-resource',
refId: 'resource-ui-spec',
},
{
id: 'reference',
title: '参考图 1',
label: '界面风格参考',
refType: 'project-resource',
refId: 'resource-ui-ref',
},
],
}),
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
});
it('builds publication material plans with structured game fields', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
style: 'pixelArt',
publicationGameInfo: {
gameName: ' 重庆洪崖洞火锅 ',
gameCategories: '抓大鹅、休闲、治愈、手绘风',
gameDescription: '在一堆物品里点击,找到三个一样的物品将其消除',
},
publicationReferences: [
{
id: 'ref-1',
label: '参考图',
src: 'data:image/png;base64,ref',
resourceId: 'resource-publication-ref',
},
],
},
layers: [],
nextGeneratedIndex: 10,
});
expect(plan).toEqual({
kind: 'image',
normalizedPrompt: [
'游戏名:重庆洪崖洞火锅',
'游戏分类:抓大鹅、休闲、治愈、手绘风',
'一句话描述游戏:在一堆物品里点击,找到三个一样的物品将其消除',
].join('\n'),
input: {
prompt: expect.stringContaining('【宣发素材类型】游戏首图'),
size: '720x540',
kind: 'publication-material',
model: 'gpt-image-2',
aspectRatio: '4:3',
imageSize: '1K',
referenceImageSrcs: ['data:image/png;base64,ref'],
},
result: {
assetKind: 'publication-material',
title: '10 宣发素材',
generationInputs: expect.objectContaining({
version: 2,
action: 'publication.generate',
fields: expect.arrayContaining([
{ id: 'gameName', title: '游戏名', value: '重庆洪崖洞火锅' },
{
id: 'gameCategories',
title: '游戏分类',
value: '抓大鹅、休闲、治愈、手绘风',
},
{
id: 'gameDescription',
title: '一句话描述游戏',
value: '在一堆物品里点击,找到三个一样的物品将其消除',
},
]),
references: [
{
id: 'reference',
title: '宣发参考图 1',
label: '参考图',
refType: 'project-resource',
refId: 'resource-publication-ref',
},
],
}),
},
});
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
expect(prompt).toContain('游戏名:重庆洪崖洞火锅');
expect(prompt).toContain('标题文字必须直接进入画面');
expect(prompt).toContain('输出检查:720 x 5404:3');
});
it('builds detail gallery publication plans as one 720p portrait image', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-detail-gallery',
publicationGameInfo: {
gameName: '云朵消消乐',
gameCategories: '',
gameDescription: '',
},
},
layers: [],
nextGeneratedIndex: 11,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
prompt: expect.stringContaining('【宣发素材类型】详情页单图'),
size: '720x1280',
kind: 'publication-material',
aspectRatio: '9:16',
imageSize: '1K',
},
result: {
assetKind: 'publication-material',
title: '11 宣发素材',
},
});
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'本次只生成一张 720 x 1280 竖版详情图',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'不是五图合集或拼贴',
);
});
it('builds promo poster publication plans as 720p landscape images', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '火锅节活动',
gameCategories: '',
gameDescription: '',
},
},
layers: [],
nextGeneratedIndex: 12,
});
expect(plan).toMatchObject({
kind: 'image',
input: {
prompt: expect.stringContaining('【宣发素材类型】运营海报'),
size: '1280x720',
kind: 'publication-material',
aspectRatio: '16:9',
imageSize: '1K',
},
result: {
title: '12 宣发素材',
},
});
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'16:9 横版 720p,建议 1280 x 720',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'此处换上你的游戏二维码',
);
expect(plan.kind === 'image' ? plan.input.prompt : '').toContain(
'方块内部上方用黑色小字',
);
});
it('locks publication material plans to gpt-image-2 and does not remember the fixed model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'publication',
prompt: '',
status: 'idle',
publicationWorkflowId: 'publication-promo-poster',
publicationGameInfo: {
gameName: '星际塔防',
gameCategories: '',
gameDescription: '',
},
publicationReferences: [],
imageModel: 'nanobanana2',
},
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',
aspectRatio: '16:9',
imageSize: '1K',
}),
);
expect(plan.rememberImageModel).toBeUndefined();
});
it('throws when edit source layer is missing', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '修图',
status: 'idle',
sourceLayerId: 'missing-layer',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('未找到要修改的图片');
});
it('returns an icon spritesheet error when the spec reference is missing', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '',
status: 'idle',
iconDescriptions: ['返回按钮'],
});
expect(plan).toEqual({
ok: false,
errorMessage: '请选择图标规范',
});
});
it('returns an icon spritesheet error when the spec reference is not registered', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
iconSpecReference: {
id: 'local-icon-spec',
label: '本地图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'local-resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: '参考图尚未登记为项目资源或素材,请重新选择或上传后再试',
});
});
it('returns an icon spritesheet error when descriptions are empty', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '',
status: 'idle',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
iconDescriptions: [' ', '\n'],
});
expect(plan).toEqual({
ok: false,
errorMessage: '请填写素材描述',
});
});
it('returns an icon spritesheet error when the complete prompt is too long', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
status: 'idle',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
});
});
it('builds icon spritesheet plans with the complete prompt and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: ' 返回按钮\n\n设置按钮 ',
status: 'idle',
imageModel: 'gpt-image-2',
style: 'pixelArt',
aspectRatio: '3:2',
imageSize: '2K',
assetLabel: ' 冒险游戏图标 ',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
generationReferences: [
{
id: 'icon-ref',
label: '按钮风格参考',
src: 'data:image/png;base64,ref',
resourceId: 'resource-icon-ref',
},
],
iconDescriptions: ['旧返回', '旧设置'],
});
expect(plan).toEqual({
ok: true,
iconDescriptions: ['返回按钮\n\n设置按钮'],
input: {
referenceId: 'resource-icon-spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
iconDescriptions: ['返回按钮\n\n设置按钮'],
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
style: 'pixelArt',
aspectRatio: '3:2',
imageSize: '2K',
},
generationInputs: expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
{
id: 'reference',
title: '参考图 1',
label: '按钮风格参考',
refType: 'project-resource',
refId: 'resource-icon-ref',
},
],
}),
rememberImageModel: 'gpt-image-2',
resultTitle: '冒险游戏图标',
});
});
it('builds character animation plans with trimmed prompt and object key source', () => {
const sourceLayer = createLayer({
id: 'character-layer',
title: '角色图',
objectKey: 'generated/character.png',
assetKind: 'character',
originalWidth: 960,
originalHeight: 1280,
});
const plan = buildCharacterAnimationSubmissionPlan({
panel: {
sourceLayerId: 'character-layer',
promptText: ' 循环奔跑动作 ',
assetLabel: ' 勇者奔跑 ',
resolution: '720p',
ratio: 'same',
frameCount: 48,
durationSeconds: 6,
status: 'idle',
},
sourceLayer,
});
expect(plan).toEqual({
promptText: '循环奔跑动作',
resultTitle: '勇者奔跑',
input: {
sourceLayerId: 'character-layer',
sourceImageSrc: 'generated/character.png',
sourceWidth: 960,
sourceHeight: 1280,
promptText: '循环奔跑动作',
screenColor: 'auto',
resolution: '720p',
ratio: 'same',
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
assetLabel: '勇者奔跑',
},
});
});
it('builds seedance video plans with multimodal references', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: ' 根据参考生成角色宣传片 ',
status: 'idle',
videoModel: 'seedance2.0-fast',
videoResolution: '720p',
videoDurationSeconds: 5,
videoWebSearchEnabled: false,
generationReferences: [
{
id: 'image-ref',
label: '参考图.png',
src: 'data:image/png;base64,image',
mediaType: 'image',
mimeType: 'image/png',
sizeBytes: 1024,
resourceId: 'resource-video-image-ref',
},
{
id: 'video-ref',
label: '参考视频.mp4',
src: 'https://signed.example.com/video.mp4',
mediaType: 'video',
mimeType: 'video/mp4',
sizeBytes: 2048,
durationSeconds: 4,
objectKey:
'generated-character-drafts/editor/seedance-references/video/video.mp4',
resourceId: 'resource-video-ref',
},
{
id: 'audio-ref',
label: '参考音频.mp3',
src: 'https://signed.example.com/audio.mp3',
mediaType: 'audio',
mimeType: 'audio/mpeg',
sizeBytes: 512,
durationSeconds: 3,
objectKey:
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
resourceId: 'resource-audio-ref',
},
],
},
layers: [],
nextGeneratedIndex: 6,
});
expect(plan).toMatchObject({
kind: 'video',
input: {
model: 'seedance2.0-fast',
webSearchEnabled: false,
referenceImageSrcs: ['data:image/png;base64,image'],
referenceVideoSrcs: [
'generated-character-drafts/editor/seedance-references/video/video.mp4',
],
referenceAudioSrcs: [
'generated-character-drafts/editor/seedance-references/audio/audio.mp3',
],
},
result: {
generationInputs: expect.objectContaining({
version: 2,
action: 'video.generate',
fields: expect.arrayContaining([
{
id: 'webSearchEnabled',
title: '联网搜索',
value: false,
},
]),
references: [
{
id: 'imageReference',
title: '参考图 1',
label: '参考图.png',
refType: 'project-resource',
refId: 'resource-video-image-ref',
},
{
id: 'videoReference',
title: '参考视频 1',
label: '参考视频.mp4',
refType: 'project-resource',
refId: 'resource-video-ref',
},
{
id: 'audioReference',
title: '参考音频 1',
label: '参考音频.mp3',
refType: 'project-resource',
refId: 'resource-audio-ref',
},
],
}),
},
});
});
it('does not submit references to non seedance video models', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: '镜头推进',
status: 'idle',
videoModel: 'kling3.0',
generationReferences: [
{
id: 'image-ref',
label: '参考图.png',
src: 'data:image/png;base64,image',
mediaType: 'image',
},
],
},
layers: [],
nextGeneratedIndex: 2,
});
expect(plan.kind).toBe('video');
if (plan.kind === 'video') {
expect(plan.input).not.toHaveProperty('referenceImageSrcs');
expect(plan.input).not.toHaveProperty('referenceVideoSrcs');
expect(plan.input).not.toHaveProperty('referenceAudioSrcs');
}
});
it('rejects seedance video plans that only contain audio references', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: '根据音频生成视频',
status: 'idle',
videoModel: 'seedance2.0',
generationReferences: [
{
id: 'audio-ref',
label: '旁白.wav',
src: 'data:audio/wav;base64,audio',
mediaType: 'audio',
durationSeconds: 4,
},
],
},
layers: [],
nextGeneratedIndex: 2,
}),
).toThrow('参考音频必须搭配参考图片或参考视频');
});
it('rejects seedance video plans that exceed reference count limits', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: '生成视频',
status: 'idle',
videoModel: 'seedance2.0-fast',
generationReferences: Array.from({ length: 10 }, (_, index) => ({
id: `image-${index}`,
label: `参考图${index}.png`,
src: `data:image/png;base64,${index}`,
mediaType: 'image' as const,
})),
},
layers: [],
nextGeneratedIndex: 2,
}),
).toThrow('参考图片最多 9 张');
});
it('builds video generation plans with seedance fast as the fallback model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: ' 角色绕场一周 ',
status: 'idle',
generationReferences: [],
videoResolution: '480p',
videoDurationSeconds: 4,
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toMatchObject({
kind: 'video',
normalizedPrompt: '角色绕场一周',
input: {
model: 'seedance2.0-fast',
},
result: {
title: '生成视频 3',
},
});
});
it('builds video generation plans with explicit seedance standard model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'video',
prompt: ' 生成电影感开场动画 ',
status: 'idle',
videoModel: 'seedance2.0',
videoResolution: '720p',
videoDurationSeconds: 5,
},
layers: [],
nextGeneratedIndex: 7,
});
expect(plan).toMatchObject({
kind: 'video',
normalizedPrompt: '生成电影感开场动画',
input: {
prompt: '生成电影感开场动画',
model: 'seedance2.0',
resolution: '720p',
durationSeconds: 5,
},
result: {
title: '生成视频 7',
},
});
});
it('builds game sound effect audio submission plans', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: ' 金币掉落叮当声 ',
status: 'idle',
soundDurationSeconds: 7,
soundDurationMode: 'manual',
},
layers: [],
nextGeneratedIndex: 4,
});
expect(plan).toEqual({
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt: '金币掉落叮当声',
input: {
prompt: '金币掉落叮当声',
model: 'eleven_text_to_sound_v2',
duration: 7,
loop: false,
},
result: {
title: '游戏音效 4',
generationInputs: {
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: false },
],
references: [],
},
},
});
});
it('preserves frozen auto duration, loop, and manual decimal precision', () => {
const automatic = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '环境循环音',
status: 'idle',
soundDurationMode: 'auto',
soundDurationSeconds: 1.23456789,
soundLoop: true,
},
layers: [],
nextGeneratedIndex: 1,
});
expect(automatic).toMatchObject({
input: { duration: null, loop: true },
result: {
generationInputs: {
fields: expect.arrayContaining([
{ id: 'durationMode', title: '时长模式', value: 'auto' },
{ id: 'durationSeconds', title: '请求时长', value: '自动' },
{ id: 'loop', title: 'Loop', value: true },
]),
},
},
});
const manual = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: '精确时长音效',
status: 'idle',
soundDurationMode: 'manual',
soundDurationSeconds: 1.23456789,
soundLoop: false,
},
layers: [],
nextGeneratedIndex: 2,
});
expect(manual).toMatchObject({
input: { duration: 1.23456789, loop: false },
});
});
it('rejects an all-whitespace sound effect prompt without a fallback', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-sound-effect',
prompt: ' \t\r\n ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 6,
}),
).toThrow('音效描述不能为空');
});
it('builds game background music audio submission plans with instrumental fixed to true', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-background-music',
prompt: '不会进入正式请求的旧值',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 5,
canonicalBackgroundMusicPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
});
expect(plan).toEqual({
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
input: {
gptDescriptionPrompt: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
makeInstrumental: true,
},
result: {
title: '游戏背景音乐 5',
generationInputs: {
version: 2,
action: 'audio.background-music.generate',
fields: [
{
id: 'prompt',
title: 'gpt_description_prompt',
value: '\uFEFF温暖轻快的森林冒险背景音乐\uFEFF',
},
],
references: [],
},
},
});
});
it('does not build a background music plan without a claimed canonical prompt', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'audio-background-music',
prompt: ' 不应原生 trim 或回退默认值 ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('背景音乐提交缺少已确认的提示词');
});
});