308d440816
新抠图算法(轻量版)上线,生图背景色同步改为多颜色+自动决策 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/73 Reviewed-by: kdletters <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
1054 lines
29 KiB
TypeScript
1054 lines
29 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
|
||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||
import {
|
||
buildCharacterAnimationSubmissionPlan,
|
||
buildIconSpritesheetGenerationSubmissionPlan,
|
||
buildImageGenerationSubmissionPlan,
|
||
} from './ImageCanvasGenerationSubmissionModel';
|
||
import {
|
||
IMAGE_MODEL_GPT_IMAGE_2,
|
||
IMAGE_MODEL_NANOBANANA2,
|
||
} from './ImageCanvasGenerationModel';
|
||
|
||
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('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',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
},
|
||
result: {
|
||
title: '生成图片 3',
|
||
generationInputs: {
|
||
fields: [{ title: '生成提示词', value: '一张发光主视觉' }],
|
||
references: [],
|
||
},
|
||
},
|
||
rememberImageModel: 'gemini-3.1-flash-image-preview',
|
||
});
|
||
});
|
||
|
||
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,
|
||
},
|
||
layers: [sourceLayer],
|
||
nextGeneratedIndex: 4,
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
kind: 'edit',
|
||
normalizedPrompt: '修改当前图片',
|
||
sourceLayer,
|
||
generationInputs: {
|
||
fields: [{ title: '修改要求', value: '修改当前图片' }],
|
||
references: [
|
||
{
|
||
title: '参考图',
|
||
label: '源图',
|
||
refType: 'project-resource',
|
||
refId: 'resource-source',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
});
|
||
|
||
it('builds quick-edit canvas generator plans as direct source edits', () => {
|
||
const sourceLayer = createLayer({
|
||
objectKey: 'generated-character-drafts/editor/source.png',
|
||
assetKind: 'character',
|
||
});
|
||
|
||
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',
|
||
},
|
||
],
|
||
},
|
||
layers: [sourceLayer],
|
||
nextGeneratedIndex: 4,
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
kind: 'quick-edit',
|
||
normalizedPrompt: '把当前图改成雨天',
|
||
sourceLayer,
|
||
editInput: {
|
||
size: '1024x768',
|
||
model: IMAGE_MODEL_GPT_IMAGE_2,
|
||
},
|
||
result: {
|
||
title: '源图 快速编辑',
|
||
assetKind: 'character',
|
||
generationInputs: {
|
||
fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }],
|
||
references: [
|
||
{
|
||
title: '原图',
|
||
label: '源图',
|
||
refType: 'project-resource',
|
||
refId: 'resource-source',
|
||
},
|
||
],
|
||
},
|
||
},
|
||
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||
});
|
||
});
|
||
|
||
it('builds spec generation plans with reference prompt semantics', () => {
|
||
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: 'image',
|
||
normalizedPrompt: 'AI 生成图片',
|
||
input: {
|
||
size: '2048x1152',
|
||
model: 'gpt-image-2',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
kind: 'spec',
|
||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||
prompt: expect.stringContaining('参考图生成规范'),
|
||
},
|
||
result: {
|
||
assetKind: 'icon-spec',
|
||
title: '图标规范 8',
|
||
},
|
||
});
|
||
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
|
||
{
|
||
fields: [
|
||
{ title: '玩法设定', value: '休闲消除' },
|
||
{ title: '美术风格', value: '清爽卡通' },
|
||
],
|
||
references: [
|
||
{
|
||
title: '参考图',
|
||
label: '参考.png',
|
||
refType: 'project-resource',
|
||
refId: 'resource-spec-ref',
|
||
},
|
||
],
|
||
},
|
||
);
|
||
});
|
||
|
||
it('treats the renamed UI spec entry as an icon spec asset', () => {
|
||
const plan = buildImageGenerationSubmissionPlan({
|
||
dialog: {
|
||
mode: 'spec',
|
||
prompt: '',
|
||
status: 'idle',
|
||
specType: 'ui',
|
||
specValues: {
|
||
playSetting: '消除类派对玩法',
|
||
artStyle: '糖果玻璃拟物',
|
||
bodyRatio: '3',
|
||
characterView: '',
|
||
customPrompt: '',
|
||
},
|
||
},
|
||
layers: [],
|
||
nextGeneratedIndex: 2,
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
kind: 'image',
|
||
result: {
|
||
assetKind: 'icon-spec',
|
||
title: '图标规范 2',
|
||
},
|
||
});
|
||
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
|
||
'referenceImageSrcs',
|
||
);
|
||
expect(plan.kind === 'image' ? plan.input.prompt : '').not.toContain(
|
||
'参考图生成规范',
|
||
);
|
||
});
|
||
|
||
it('builds character plans with references and remembered model', () => {
|
||
const plan = buildImageGenerationSubmissionPlan({
|
||
dialog: {
|
||
mode: 'character',
|
||
prompt: ' 白发骑士 ',
|
||
status: 'idle',
|
||
imageModel: 'gpt-image-2',
|
||
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',
|
||
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(
|
||
{
|
||
fields: [
|
||
{ title: '角色设定', value: '白发骑士' },
|
||
],
|
||
references: [
|
||
{
|
||
title: '角色规范',
|
||
label: '角色规范',
|
||
refType: 'project-resource',
|
||
refId: 'resource-character-spec',
|
||
},
|
||
{
|
||
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: {
|
||
fields: [{ title: '用户输入', value: '设计移动端商城首页' }],
|
||
references: [
|
||
{
|
||
title: '图标规范',
|
||
label: 'UI图标规范',
|
||
refType: 'project-resource',
|
||
refId: 'resource-ui-spec',
|
||
},
|
||
{
|
||
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',
|
||
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: {
|
||
title: '10 宣发素材',
|
||
generationInputs: {
|
||
fields: [
|
||
{ title: '游戏名', value: '重庆洪崖洞火锅' },
|
||
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
|
||
{
|
||
title: '一句话描述游戏',
|
||
value: '在一堆物品里点击,找到三个一样的物品将其消除',
|
||
},
|
||
],
|
||
references: [
|
||
{
|
||
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 540,4: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: {
|
||
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 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('builds icon spritesheet plans with trimmed descriptions and references', () => {
|
||
const plan = buildIconSpritesheetGenerationSubmissionPlan({
|
||
mode: 'icon',
|
||
prompt: ' 返回按钮 \n\n设置按钮',
|
||
status: 'idle',
|
||
imageModel: 'gpt-image-2',
|
||
aspectRatio: '3:2',
|
||
imageSize: '2K',
|
||
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: ['返回按钮', '设置按钮'],
|
||
input: {
|
||
referenceImageSrc: 'data:image/png;base64,spec',
|
||
referenceImageSrcs: ['data:image/png;base64,ref'],
|
||
iconDescriptions: ['返回按钮', '设置按钮'],
|
||
model: 'gpt-image-2',
|
||
screenColor: 'auto',
|
||
segModel: 'birefnet',
|
||
aspectRatio: '3:2',
|
||
imageSize: '2K',
|
||
},
|
||
generationInputs: {
|
||
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
|
||
references: [
|
||
{
|
||
title: '图标规范',
|
||
label: '图标规范',
|
||
refType: 'project-resource',
|
||
refId: 'resource-icon-spec',
|
||
},
|
||
{
|
||
title: '参考图 1',
|
||
label: '按钮风格参考',
|
||
refType: 'project-resource',
|
||
refId: 'resource-icon-ref',
|
||
},
|
||
],
|
||
},
|
||
rememberImageModel: 'gpt-image-2',
|
||
});
|
||
});
|
||
|
||
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: ' 循环奔跑动作 ',
|
||
resolution: '720p',
|
||
ratio: 'same',
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
status: 'idle',
|
||
},
|
||
sourceLayer,
|
||
});
|
||
|
||
expect(plan).toEqual({
|
||
promptText: '循环奔跑动作',
|
||
input: {
|
||
sourceLayerId: 'character-layer',
|
||
sourceImageSrc: 'generated/character.png',
|
||
sourceWidth: 960,
|
||
sourceHeight: 1280,
|
||
promptText: '循环奔跑动作',
|
||
resolution: '720p',
|
||
ratio: 'same',
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
model: 'seedance2.0-fast',
|
||
},
|
||
});
|
||
});
|
||
|
||
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,
|
||
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',
|
||
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: {
|
||
references: [
|
||
{
|
||
title: '参考图 1',
|
||
label: '参考图.png',
|
||
refType: 'project-resource',
|
||
refId: 'resource-video-image-ref',
|
||
},
|
||
{
|
||
title: '参考视频 1',
|
||
label: '参考视频.mp4',
|
||
refType: 'project-resource',
|
||
refId: 'resource-video-ref',
|
||
},
|
||
{
|
||
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,
|
||
},
|
||
layers: [],
|
||
nextGeneratedIndex: 4,
|
||
});
|
||
|
||
expect(plan).toEqual({
|
||
kind: 'audio',
|
||
audioKind: 'sound-effect',
|
||
normalizedPrompt: '金币掉落叮当声',
|
||
input: {
|
||
prompt: '金币掉落叮当声',
|
||
model: 'audio1.0',
|
||
duration: 7,
|
||
},
|
||
result: {
|
||
title: '游戏音效 4',
|
||
generationInputs: {
|
||
fields: [
|
||
{ title: 'prompt', value: '金币掉落叮当声' },
|
||
{ title: 'model', value: 'audio1.0' },
|
||
{ title: 'duration', value: '7秒' },
|
||
],
|
||
references: [],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
it('uses 5 seconds as default game sound effect duration', () => {
|
||
const plan = buildImageGenerationSubmissionPlan({
|
||
dialog: {
|
||
mode: 'audio-sound-effect',
|
||
prompt: '按钮确认短促音',
|
||
status: 'idle',
|
||
},
|
||
layers: [],
|
||
nextGeneratedIndex: 6,
|
||
});
|
||
|
||
expect(plan).toMatchObject({
|
||
kind: 'audio',
|
||
audioKind: 'sound-effect',
|
||
input: {
|
||
prompt: '按钮确认短促音',
|
||
model: 'audio1.0',
|
||
duration: 5,
|
||
},
|
||
result: {
|
||
generationInputs: {
|
||
fields: [
|
||
{ title: 'prompt', value: '按钮确认短促音' },
|
||
{ title: 'model', value: 'audio1.0' },
|
||
{ title: 'duration', value: '5秒' },
|
||
],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
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,
|
||
});
|
||
|
||
expect(plan).toEqual({
|
||
kind: 'audio',
|
||
audioKind: 'background-music',
|
||
normalizedPrompt: '温暖轻快的森林冒险背景音乐',
|
||
input: {
|
||
gptDescriptionPrompt: '温暖轻快的森林冒险背景音乐',
|
||
makeInstrumental: true,
|
||
},
|
||
result: {
|
||
title: '游戏背景音乐 5',
|
||
generationInputs: {
|
||
fields: [
|
||
{
|
||
title: 'gpt_description_prompt',
|
||
value: '温暖轻快的森林冒险背景音乐',
|
||
},
|
||
],
|
||
references: [],
|
||
},
|
||
},
|
||
});
|
||
});
|
||
});
|