Files
Genarrative/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts
T
k88936 4c0fdf1806
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Native shell tests (push) Successful in 14m46s
修正画布改造工具恢复链路 (#158)
在文档中明确改造特殊情况的处理

修复fallback到快速编辑的行为:
旧版图片按 assetKind 与 mediaType 选择工具,未知图片回落默认生成工具并保留旧版告警,而不是all in fallback到快速编辑

清理冗余代码:
移除 QuickEditPanelState 的 redraw 模式、旧提交分支及无调用结果层构造器

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/158
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-11 12:36:30 +08:00

747 lines
22 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
CanvasGenerationInputs,
CanvasLayer,
} from './ImageCanvasEditorTypes';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
createCharacterAnimationResultLayer,
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createVideoResultLayer,
} from './ImageCanvasGenerationLayerModel';
function createGenerated(overrides = {}) {
return {
imageSrc: 'data:image/png;base64,generated',
width: 1024,
height: 1024,
sourceType: 'generated' as const,
prompt: '生成提示词',
actualPrompt: '实际提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-generated',
objectKey: 'generated/object.png',
assetObjectId: 'asset-object-generated',
...overrides,
};
}
function createSourceLayer(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: 1536,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
groupId: 'group-a',
assetKind: 'spec',
...overrides,
};
}
function createGenerationInputs(): CanvasGenerationInputs {
return {
fields: [{ title: '生成提示词', value: '生成提示词' }],
references: [],
};
}
describe('ImageCanvasGenerationLayerModel', () => {
it('creates generated result layers centered on the active placeholder', () => {
const layer = createGeneratedResultLayer({
generated: createGenerated({ width: 2048, height: 1152 }),
generatedIndex: 7,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 80,
y: 60,
width: 560,
height: 315,
originalWidth: 2048,
originalHeight: 1152,
},
assetKind: 'spec',
title: '图标规范 7',
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-generated-7',
resourceId: 'local-resource-generated-7',
title: '图标规范 7',
x: -664,
y: -358.5,
width: 2048,
height: 1152,
originalWidth: 2048,
originalHeight: 1152,
zIndex: 17,
sourceType: 'generated',
assetKind: 'spec',
prompt: '生成提示词',
actualPrompt: '实际提示词',
objectKey: 'generated/object.png',
assetObjectId: 'asset-object-generated',
});
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
});
it('keeps publication material result dimensions constrained by the active placeholder', () => {
const layer = createGeneratedResultLayer({
generated: createGenerated({ width: 1024, height: 1024 }),
generatedIndex: 8,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 180,
y: 120,
width: 360,
height: 640,
originalWidth: 720,
originalHeight: 1280,
},
assetKind: 'publication-material',
title: '8 宣发素材',
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-generated-8',
title: '8 宣发素材',
width: 720,
height: 1280,
originalWidth: 720,
originalHeight: 1280,
assetKind: 'publication-material',
});
});
it('keeps publication cover image results at the 4:3 workflow dimensions', () => {
const layer = createGeneratedResultLayer({
generated: createGenerated({ width: 1024, height: 1024 }),
generatedIndex: 9,
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 100,
y: 100,
width: 480,
height: 360,
originalWidth: 720,
originalHeight: 540,
},
assetKind: 'publication-material',
title: '9 宣发素材',
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
width: 720,
height: 540,
originalWidth: 720,
originalHeight: 540,
assetKind: 'publication-material',
});
});
it('uses backend-persisted resource and asset snapshots for generated layers', () => {
const generationInputs = createGenerationInputs();
const layer = createGeneratedResultLayer({
generated: createGenerated({
resource: {
resourceId: 'resource-generated-bff',
projectId: 'editor-project-1',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
asset: {
assetId: 'asset-generated-bff',
folderId: 'user-1:asset-folder:project',
label: '角色形象',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-asset.png',
assetObjectId: 'asset-object-from-asset',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
}),
generatedIndex: 9,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
assetKind: 'spec',
generationInputs: {
fields: [{ title: '旧输入', value: '本地兜底' }],
references: [],
},
});
expect(layer).toMatchObject({
resourceId: 'resource-generated-bff',
sourceAssetId: 'asset-generated-bff',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
assetKind: 'character',
});
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff');
});
it('creates edit result layers beside the source image', () => {
const sourceLayer = createSourceLayer();
const layer = createGeneratedResultLayer({
generated: createGenerated({ prompt: '修改要求' }),
generatedIndex: 8,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
sourceLayer,
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-edit-8',
resourceId: 'local-resource-edit-8',
title: '源图 修改结果',
x: 472,
y: 140,
width: 1024,
height: 1024,
sourceResourceId: 'resource-source',
prompt: '修改要求',
});
});
it('applies quick edits to the source layer in place', () => {
const sourceLayer = createSourceLayer({
width: 320,
height: 240,
originalWidth: 1537,
originalHeight: 1025,
});
const layer = applyImageEditResultToSourceLayer({
generated: createGenerated({
imageSrc: 'data:image/png;base64,edited',
width: 1536,
height: 1024,
resource: {
resourceId: 'resource-edited',
objectKey: 'generated/edited.png',
assetObjectId: 'asset-object-edited',
width: 1536,
height: 1024,
sourceType: 'generated',
generationInputs: createGenerationInputs(),
},
}),
sourceLayer,
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-source',
title: '源图',
src: 'data:image/png;base64,edited',
x: -488,
y: -252,
width: 1536,
height: 1024,
originalWidth: 1536,
originalHeight: 1024,
resourceId: 'resource-edited',
sourceResourceId: 'resource-source',
objectKey: 'generated/edited.png',
});
expect(layer.x + layer.width / 2).toBe(
sourceLayer.x + sourceLayer.width / 2,
);
expect(layer.y + layer.height / 2).toBe(
sourceLayer.y + sourceLayer.height / 2,
);
});
it('creates wrapped icon layers with icon metadata', () => {
const layers = createIconSpritesheetResultLayers({
generated: {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
taskId: 'task-icons',
priceMudPoints: 20,
},
iconResults: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 256,
height: 256,
},
{
name: '设置按钮',
imageSrc: 'data:image/png;base64,settings',
width: 512,
height: 256,
},
{
name: '提示按钮',
imageSrc: 'data:image/png;base64,hint',
width: 128,
height: 128,
},
],
startIndex: 11,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
frame: {
x: 200,
y: 100,
width: 360,
height: 360,
originalWidth: 512,
originalHeight: 512,
},
generationInputs: createGenerationInputs(),
});
expect(layers).toHaveLength(4);
expect(layers[0]).toMatchObject({
id: 'layer-icon-spritesheet-11',
title: '图标素材图集 11',
x: 200,
y: 100,
width: 512,
height: 512,
assetKind: 'icon-spritesheet',
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
});
expect(layers[1]).toMatchObject({
id: 'layer-icon-12',
title: '返回按钮',
x: 744,
y: 100,
width: 256,
height: 256,
assetKind: 'icon',
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
});
expect(layers[2]).toMatchObject({
id: 'layer-icon-13',
title: '设置按钮',
x: 744,
y: 380,
width: 512,
height: 256,
assetKind: 'icon',
});
expect(layers[3]).toMatchObject({
id: 'layer-icon-14',
title: '提示按钮',
x: 744,
y: 660,
width: 128,
height: 128,
assetKind: 'icon',
});
});
it('keeps the spritesheet layer when icon results are missing', () => {
const layers = createIconSpritesheetResultLayers({
generated: {
spritesheetImageSrc: 'data:image/png;base64,sheet',
spritesheetWidth: 512,
spritesheetHeight: 512,
iconImageSrcs: [],
prompt: '图标 prompt',
actualPrompt: '图标 actual prompt',
model: 'gpt-image-2',
taskId: 'task-icons',
priceMudPoints: 20,
},
iconResults: undefined,
startIndex: 11,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
});
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
id: 'layer-icon-spritesheet-11',
title: '图标素材图集 11',
assetKind: 'icon-spritesheet',
src: 'data:image/png;base64,sheet',
});
});
it('creates audio result layers centered on the active placeholder', () => {
const layer = createAudioResultLayer({
generated: {
audioSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
width: 420,
height: 120,
sourceType: 'generated',
prompt: '金币掉落叮当声',
actualPrompt: '金币掉落叮当声',
model: 'audio1.0',
taskId: 'sound-task-1',
priceMudPoints: 10,
audioKind: 'sound-effect',
durationSeconds: 75,
resource: {
resourceId: 'resource-audio-bff',
projectId: 'editor-project-1',
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
width: 420,
height: 120,
sourceType: 'generated',
assetKind: 'sound-effect',
},
asset: {
assetId: 'asset-audio-bff',
folderId: 'user-1:asset-folder:project',
label: '游戏音效 14',
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
width: 420,
height: 120,
sourceType: 'generated',
assetKind: 'sound-effect',
},
},
generatedIndex: 14,
title: '游戏音效 14',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 80,
y: 60,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
},
generationInputs: {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: '时长', value: '75秒' },
],
references: [],
},
});
expect(layer).toMatchObject({
id: 'layer-audio-14',
resourceId: 'resource-audio-bff',
title: '游戏音效 14',
src: '/generated-character-drafts/editor-audios/sfx.mp3',
mediaType: 'audio',
assetKind: 'sound-effect',
x: 80,
y: 60,
width: 420,
height: 120,
originalWidth: 420,
originalHeight: 120,
sourceType: 'generated',
prompt: '金币掉落叮当声',
model: 'audio1.0',
taskId: 'sound-task-1',
objectKey: 'generated-character-drafts/editor-audios/sfx.mp3',
assetObjectId: 'assetobj-audio-1',
sourceAssetId: 'asset-audio-bff',
generationInputs: {
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: '时长', value: '75秒' },
],
references: [],
},
generatedAssetSnapshot: expect.objectContaining({
assetId: 'asset-audio-bff',
}),
});
expect(layer).not.toHaveProperty('durationSeconds');
expect(layer.provider).toBeUndefined();
});
it('creates video result layers with OSS asset identity', () => {
const layer = createVideoResultLayer({
generated: {
videoSrc: '/generated-character-drafts/editor-videos/video.mp4',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
width: 1280,
height: 720,
sourceType: 'generated',
prompt: '开场动画',
actualPrompt: '开场动画',
model: 'seedance2.0-fast',
taskId: 'video-task-1',
priceMudPoints: 40,
durationSeconds: 5,
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
asset: {
assetId: 'asset-video-bff',
folderId: 'user-1:asset-folder:project',
label: '生成视频 15',
imageSrc: '/generated-character-drafts/editor-videos/video.mp4',
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
width: 1280,
height: 720,
sourceType: 'generated',
assetKind: 'video',
},
},
generatedIndex: 15,
title: '生成视频 15',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
generationInputs: createGenerationInputs(),
});
expect(layer).toMatchObject({
id: 'layer-video-15',
src: '/generated-character-drafts/editor-videos/video.mp4',
mediaType: 'video',
assetKind: 'video',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
});
expect(layer).not.toHaveProperty('durationSeconds');
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff');
expect(layer.provider).toBeUndefined();
});
it('creates character animation result layers as playable image sequences', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 1024,
height: 1024,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
width: 1024,
height: 1024,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-1',
model: 'seedance2.0-fast',
prompt: '待机动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
resource: {
resourceId: 'resource-character-animation-15',
projectId: 'project-1',
imageSrc: frames[0]!.imageSrc,
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 15,
title: '角色动作',
canvasSize: { width: 900, height: 640 },
viewport: { x: 10, y: 20, scale: 2 },
frame: {
x: 80,
y: 60,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [],
},
});
expect(layer).toMatchObject({
id: 'layer-character-animation-15',
resourceId: 'resource-character-animation-15',
title: '角色动作',
src: '/generated-character-drafts/editor/frame1.png',
mediaType: 'image-sequence',
thumbnailSrc: '/generated-character-drafts/editor/frame1.png',
imageSequenceFrames: frames,
previewVideoPath: null,
x: -222,
y: -242,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 25,
sourceType: 'generated',
prompt: '待机动作',
actualPrompt: '待机动作',
imageSequenceDurationMs: 4_000,
model: 'seedance2.0-fast',
taskId: 'character-animation-task-1',
assetKind: 'character-animation',
});
expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作');
expect(layer?.provider).toBeUndefined();
});
it('links a character animation result layer to its persisted project resource', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
objectKey: 'generated-character-drafts/editor/frame1.png',
assetObjectId: 'asset-object-frame-1',
width: 512,
height: 768,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
objectKey: 'generated-character-drafts/editor/frame2.png',
assetObjectId: 'asset-object-frame-2',
width: 512,
height: 768,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-persisted',
model: 'seedance2.0-fast',
prompt: '奔跑动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
resource: {
resourceId: 'resource-character-animation-final',
projectId: 'project-1',
imageSrc: frames[0]!.imageSrc,
objectKey: frames[0]!.objectKey,
assetObjectId: frames[0]!.assetObjectId,
width: 512,
height: 768,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 16,
title: '角色奔跑',
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
sourceLayer: createSourceLayer({
resourceId: 'resource-original-character',
}),
});
expect(layer).toMatchObject({
resourceId: 'resource-character-animation-final',
src: frames[0]!.imageSrc,
objectKey: frames[0]!.objectKey,
assetObjectId: frames[0]!.assetObjectId,
sourceResourceId: 'resource-character-animation-preview',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
});
});
it('keeps persisted animation lineage when generation returns only an asset', () => {
const frames = [
{
imageSrc: '/generated-character-drafts/editor/frame1.png',
width: 512,
height: 768,
},
{
imageSrc: '/generated-character-drafts/editor/frame2.png',
width: 512,
height: 768,
},
];
const layer = createCharacterAnimationResultLayer({
generated: {
taskId: 'character-animation-task-asset-only',
model: 'seedance2.0-fast',
prompt: '跳跃动作',
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
frames,
frameCount: 2,
durationSeconds: 4,
fps: 8,
priceMudPoints: 40,
asset: {
assetId: 'asset-character-animation-final',
folderId: 'user-1:asset-folder:project',
label: '角色跳跃',
imageSrc: frames[0]!.imageSrc,
width: 512,
height: 768,
sourceType: 'generated',
sourceResourceId: 'resource-character-animation-preview',
assetKind: 'character-animation',
imageSequenceFrames: frames,
imageSequenceDurationMs: 4_000,
},
},
generatedIndex: 17,
title: '角色跳跃',
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
generationInputs: createGenerationInputs(),
sourceLayer: createSourceLayer({
resourceId: 'resource-original-character',
}),
});
expect(layer).toMatchObject({
resourceId: 'local-resource-asset-character-animation-final',
sourceResourceId: 'resource-character-animation-preview',
sourceAssetId: 'asset-character-animation-final',
});
});
});