Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts
T
kdletters fc14190f58
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
图集切片模式改为必须显式声明并补齐决策要求 (#408)
## 背景

切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。

## 变更

- 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。
- 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。
- AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。
- 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。
- 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。
- 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。
- 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。

## 兼容性影响

省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。

## 验证

- 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。
- AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。
- 前端:182 条定向测试与 `typecheck` 通过。
- 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。
- 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408
2026-09-17 18:10:34 +08:00

1543 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设置按钮'],
sliceMode: 'connected-components',
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('背景音乐提交缺少已确认的提示词');
});
});