72acd0ec05
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/120 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
997 lines
28 KiB
TypeScript
997 lines
28 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
CANVAS_WORLD_ORIGIN,
|
|
canvasDisplayScaleToViewportScale,
|
|
canvasDisplayViewportToViewport,
|
|
createLayerFromAsset,
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
formatCanvasDisplayScalePercent,
|
|
hydrateCanvasGenerationDialog,
|
|
hydrateLayer,
|
|
normalizeAssetLibrary,
|
|
normalizeCanvasBackgroundHex,
|
|
resolveSnappedLayerPosition,
|
|
serializeCanvasLayout,
|
|
serializeLayer,
|
|
splitCanvasLayoutItems,
|
|
viewportScaleToCanvasDisplayScale,
|
|
viewportToCanvasDisplayViewport,
|
|
} from './ImageCanvasEditorModel';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
EditorAsset,
|
|
} from './ImageCanvasEditorTypes';
|
|
|
|
describe('ImageCanvasEditorModel', () => {
|
|
it('maps viewport scale to the user-facing canvas zoom scale', () => {
|
|
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
|
|
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
|
|
expect(canvasDisplayScaleToViewportScale(2)).toBe(1);
|
|
expect(canvasDisplayScaleToViewportScale(0.05)).toBe(0.025);
|
|
expect(viewportScaleToCanvasDisplayScale(0.25)).toBe(0.5);
|
|
expect(viewportScaleToCanvasDisplayScale(0.5)).toBe(1);
|
|
expect(viewportScaleToCanvasDisplayScale(1)).toBe(2);
|
|
expect(formatCanvasDisplayScalePercent(0.025)).toBe('5%');
|
|
expect(formatCanvasDisplayScalePercent(0.5)).toBe('100%');
|
|
expect(formatCanvasDisplayScalePercent(Number.NaN)).toBe('100%');
|
|
expect(
|
|
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
|
|
).toEqual({ x: 10, y: 20, scale: 0.5 });
|
|
expect(
|
|
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
|
|
).toEqual({ x: 10, y: 20, scale: 1 });
|
|
});
|
|
|
|
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
|
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
|
|
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
|
|
expect(normalizeCanvasBackgroundHex('white')).toBeNull();
|
|
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
|
|
});
|
|
|
|
it('serializes canvas background settings without treating them as layers', () => {
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [],
|
|
canvasBackgroundColor: ' #ABC ',
|
|
});
|
|
|
|
expect(layout).toEqual([
|
|
expect.objectContaining({
|
|
itemType: 'canvas-settings',
|
|
layerId: 'canvas-settings:default',
|
|
resourceId: 'canvas-settings:default',
|
|
canvasBackgroundColor: '#aabbcc',
|
|
}),
|
|
]);
|
|
|
|
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
|
splitCanvasLayoutItems(layout);
|
|
|
|
expect(layerItems).toEqual([]);
|
|
expect(generationDialogs).toEqual([]);
|
|
expect(canvasBackgroundColor).toBe('#aabbcc');
|
|
});
|
|
|
|
it('drops invalid canvas background settings from serialized layouts', () => {
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [],
|
|
canvasBackgroundColor: '#not-a-color',
|
|
});
|
|
|
|
expect(layout).toEqual([]);
|
|
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
|
|
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
|
});
|
|
|
|
it('keeps only one default asset folder when normalizing the persisted library', () => {
|
|
const library = normalizeAssetLibrary({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'project-duplicate',
|
|
label: '旧项目素材',
|
|
sortOrder: 1,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
|
|
expect(library.folders).toHaveLength(1);
|
|
expect(library.folders[0]?.id).toBe('project');
|
|
});
|
|
|
|
it('infers MP3 and MP4 asset media types from persisted paths', () => {
|
|
const library = normalizeAssetLibrary({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-audio',
|
|
folderId: 'project',
|
|
label: '胜利音效.mp3',
|
|
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
|
width: 420,
|
|
height: 120,
|
|
sourceType: 'uploaded',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
|
},
|
|
{
|
|
assetId: 'asset-video',
|
|
folderId: 'project',
|
|
label: '开场动画.mp4',
|
|
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
|
thumbnailSrc:
|
|
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
|
|
width: 560,
|
|
height: 315,
|
|
sourceType: 'uploaded',
|
|
objectKey:
|
|
'generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
|
|
expect(library.assets[1]).toMatchObject({
|
|
mediaType: 'video',
|
|
thumbnailSrc:
|
|
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
|
|
});
|
|
expect(
|
|
createLayerFromAsset(
|
|
library.assets[0]!,
|
|
1,
|
|
{ x: 0, y: 0, scale: 1 },
|
|
{ x: 300, y: 200 },
|
|
),
|
|
).toMatchObject({
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
});
|
|
});
|
|
|
|
it('creates a cascaded layer from an account asset near the requested screen point', () => {
|
|
const asset: EditorAsset = {
|
|
id: 'asset-1',
|
|
label: '角色草图',
|
|
src: 'data:image/png;base64,one',
|
|
width: 640,
|
|
height: 480,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
objectKey: 'oss/asset-1.png',
|
|
assetObjectId: 'object-1',
|
|
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
|
|
sourceResourceId: 'resource-source-1',
|
|
};
|
|
|
|
const layer = createLayerFromAsset(
|
|
asset,
|
|
3,
|
|
{ x: 20, y: 40, scale: 2 },
|
|
{ x: 420, y: 340 },
|
|
);
|
|
|
|
expect(layer).toMatchObject({
|
|
id: 'layer-asset-1-3',
|
|
title: '角色草图',
|
|
width: 640,
|
|
height: 480,
|
|
originalWidth: 640,
|
|
originalHeight: 480,
|
|
objectKey: 'oss/asset-1.png',
|
|
assetObjectId: 'object-1',
|
|
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
|
|
sourceAssetId: 'asset-1',
|
|
sourceResourceId: 'resource-source-1',
|
|
});
|
|
expect(layer.x).toBe(-18);
|
|
expect(layer.y).toBe(12);
|
|
});
|
|
|
|
it('centers a dropped asset exactly at the requested screen point', () => {
|
|
const asset: EditorAsset = {
|
|
id: 'asset-drop',
|
|
label: '投放素材',
|
|
src: 'data:image/png;base64,drop',
|
|
width: 640,
|
|
height: 480,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
};
|
|
|
|
const layer = createLayerFromAsset(
|
|
asset,
|
|
3,
|
|
{ x: 20, y: 40, scale: 2 },
|
|
{ x: 420, y: 340 },
|
|
{ applyCascadeOffset: false },
|
|
);
|
|
|
|
expect(layer.x + layer.width / 2).toBe(200);
|
|
expect(layer.y + layer.height / 2).toBe(150);
|
|
});
|
|
|
|
it('preserves source resource ids from the persisted asset library', () => {
|
|
const library = normalizeAssetLibrary({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
{
|
|
assetId: 'asset-generated',
|
|
folderId: 'project',
|
|
label: '生成素材',
|
|
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-generated',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(library.assets[0]).toMatchObject({
|
|
id: 'asset-generated',
|
|
sourceResourceId: 'resource-generated',
|
|
});
|
|
});
|
|
|
|
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
|
|
const layer: CanvasLayer = {
|
|
id: 'layer-generated',
|
|
resourceId: 'resource-generated',
|
|
title: '生成图',
|
|
src: 'data:image/png;base64,heavy',
|
|
x: 10,
|
|
y: 20,
|
|
width: 1024,
|
|
height: 768,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 9,
|
|
sourceType: 'generated',
|
|
objectKey: 'generated/object.png',
|
|
model: 'birefnet',
|
|
sourceResourceId: 'resource-provider-source',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '骑士' }],
|
|
references: [],
|
|
},
|
|
locked: true,
|
|
};
|
|
|
|
const snapshot = serializeLayer(layer);
|
|
expect(snapshot).not.toHaveProperty('src');
|
|
expect(snapshot).not.toHaveProperty('assetKind');
|
|
expect(snapshot).not.toHaveProperty('generationInputs');
|
|
|
|
const hydrated = hydrateLayer(
|
|
snapshot,
|
|
new Map([
|
|
[
|
|
'resource-generated',
|
|
{
|
|
imageSrc: '/read/generated.png',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '骑士' }],
|
|
references: [],
|
|
},
|
|
model: 'birefnet',
|
|
sourceResourceId: 'resource-provider-source',
|
|
},
|
|
],
|
|
[
|
|
'resource-provider-source',
|
|
{
|
|
imageSrc: '/read/provider-source.png',
|
|
model: 'gpt-image-2',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-generated',
|
|
src: '/read/generated.png',
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
objectKey: 'generated/object.png',
|
|
model: 'gpt-image-2',
|
|
locked: true,
|
|
});
|
|
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
|
});
|
|
|
|
it('recovers the normal source model after the owner payload removes an internal model', () => {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-derived',
|
|
resourceId: 'resource-derived',
|
|
title: '去背景结果',
|
|
x: 10,
|
|
y: 20,
|
|
width: 1024,
|
|
height: 768,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 9,
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-source',
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-derived',
|
|
{
|
|
imageSrc: '/read/derived.png',
|
|
sourceResourceId: 'resource-source',
|
|
},
|
|
],
|
|
[
|
|
'resource-source',
|
|
{
|
|
imageSrc: '/read/source.png',
|
|
model: 'gpt-image-2',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated?.model).toBe('gpt-image-2');
|
|
});
|
|
|
|
it('hydrates object metadata from project resources when the saved layer is lean', () => {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-uploaded',
|
|
resourceId: 'resource-uploaded',
|
|
title: '上传图',
|
|
x: 10,
|
|
y: 20,
|
|
width: 640,
|
|
height: 360,
|
|
originalWidth: 640,
|
|
originalHeight: 360,
|
|
zIndex: 2,
|
|
sourceType: 'uploaded',
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-uploaded',
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
|
|
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
|
|
assetObjectId: 'asset-object-uploaded',
|
|
sourceResourceId: 'resource-source',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-uploaded',
|
|
src: '/generated-character-drafts/editor/asset-library/image.png',
|
|
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
|
|
assetObjectId: 'asset-object-uploaded',
|
|
sourceResourceId: 'resource-source',
|
|
});
|
|
});
|
|
|
|
it('hydrates manually selected publication material tags from saved layout', () => {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-publication',
|
|
resourceId: 'resource-publication',
|
|
title: '宣发图',
|
|
x: 10,
|
|
y: 20,
|
|
width: 1024,
|
|
height: 1024,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
zIndex: 5,
|
|
sourceType: 'generated',
|
|
assetKind: 'publication-material',
|
|
},
|
|
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-publication',
|
|
assetKind: 'publication-material',
|
|
});
|
|
});
|
|
|
|
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
|
|
const hydrated = hydrateLayer(
|
|
{
|
|
layerId: 'layer-audio',
|
|
resourceId: 'resource-audio',
|
|
title: '游戏音效',
|
|
x: 10,
|
|
y: 20,
|
|
width: 420,
|
|
height: 120,
|
|
originalWidth: 420,
|
|
originalHeight: 120,
|
|
zIndex: 5,
|
|
sourceType: 'generated',
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
durationSeconds: 2.4,
|
|
},
|
|
new Map([
|
|
[
|
|
'resource-audio',
|
|
{
|
|
imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
durationSeconds: 2.4,
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
id: 'layer-audio',
|
|
src: '/generated-character-drafts/editor-audios/sfx.mp3',
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
durationSeconds: 2.4,
|
|
});
|
|
});
|
|
|
|
it('serializes and hydrates image sequence frames for character animation layers', () => {
|
|
const layer: CanvasLayer = {
|
|
id: 'layer-action',
|
|
resourceId: 'resource-action',
|
|
title: '角色动作',
|
|
src: '/generated-character-drafts/editor/frame01.png',
|
|
mediaType: 'image-sequence',
|
|
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
|
imageSequenceFrames: [
|
|
{
|
|
frameIndex: 1,
|
|
imageSrc: '/generated-character-drafts/editor/frame01.png',
|
|
objectKey: 'generated-character-drafts/editor/frame01.png',
|
|
assetObjectId: 'assetobj-frame-01',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
{
|
|
frameIndex: 2,
|
|
imageSrc: '/generated-character-drafts/editor/frame02.png',
|
|
objectKey: 'generated-character-drafts/editor/frame02.png',
|
|
assetObjectId: 'assetobj-frame-02',
|
|
width: 1024,
|
|
height: 1024,
|
|
},
|
|
],
|
|
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
|
x: 10,
|
|
y: 20,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
zIndex: 6,
|
|
sourceType: 'generated',
|
|
assetKind: 'character-animation',
|
|
durationSeconds: 4,
|
|
};
|
|
|
|
const snapshot = serializeLayer(layer);
|
|
expect(snapshot.thumbnailSrc).toBe(
|
|
'/generated-character-drafts/editor/frame01.png',
|
|
);
|
|
expect(snapshot.mediaType).toBe('image-sequence');
|
|
expect(snapshot.imageSequenceFrames).toHaveLength(2);
|
|
|
|
const hydrated = hydrateLayer(
|
|
snapshot,
|
|
new Map([
|
|
[
|
|
'resource-action',
|
|
{
|
|
imageSrc: layer.src,
|
|
assetKind: 'character-animation',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(hydrated).toMatchObject({
|
|
mediaType: 'image-sequence',
|
|
thumbnailSrc: '/generated-character-drafts/editor/frame01.png',
|
|
imageSequenceFrames: layer.imageSequenceFrames,
|
|
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
|
assetKind: 'character-animation',
|
|
durationSeconds: 4,
|
|
});
|
|
});
|
|
|
|
it('restores audio generation dialogs from saved layout', () => {
|
|
const dialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-audio',
|
|
mode: 'audio-sound-effect',
|
|
prompt: '金币掉落叮当声',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
soundDurationSeconds: 8,
|
|
generatedLayerId: 'layer-audio',
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 420,
|
|
height: 120,
|
|
originalWidth: 420,
|
|
originalHeight: 120,
|
|
},
|
|
};
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [dialog],
|
|
}),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: 'generation-dialog-audio',
|
|
mode: 'audio-sound-effect',
|
|
prompt: '金币掉落叮当声',
|
|
soundDurationSeconds: 8,
|
|
generatedLayerId: 'layer-audio',
|
|
});
|
|
});
|
|
|
|
it('restores publication material generator inputs from saved layout', () => {
|
|
const dialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-publication',
|
|
mode: 'publication',
|
|
prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
publicationWorkflowId: 'publication-promo-poster',
|
|
publicationGameInfo: {
|
|
gameName: '马戏团午夜惊魂',
|
|
gameCategories: '非对称对抗',
|
|
gameDescription: '找到钥匙,开门逃离马戏团',
|
|
},
|
|
publicationReferences: [
|
|
{
|
|
id: 'publication-reference-1',
|
|
label: '首图参考',
|
|
src: 'data:image/png;base64,publication-ref',
|
|
resourceId: 'resource-publication-reference',
|
|
},
|
|
],
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
generatedLayerId: 'layer-publication',
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 1280,
|
|
height: 720,
|
|
originalWidth: 1280,
|
|
originalHeight: 720,
|
|
},
|
|
};
|
|
|
|
const layout = serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [dialog],
|
|
});
|
|
expect(JSON.stringify(layout)).not.toContain('data:image');
|
|
expect(JSON.stringify(layout)).toContain(
|
|
'ref:project-resource:resource-publication-reference',
|
|
);
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
layout,
|
|
new Map([
|
|
[
|
|
'resource-publication-reference',
|
|
{
|
|
imageSrc: '/read/publication-reference.png',
|
|
objectKey: 'generated-character-drafts/editor/reference.png',
|
|
assetObjectId: 'asset-object-publication-reference',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: 'generation-dialog-publication',
|
|
mode: 'publication',
|
|
publicationWorkflowId: 'publication-promo-poster',
|
|
publicationGameInfo: {
|
|
gameName: '马戏团午夜惊魂',
|
|
gameCategories: '非对称对抗',
|
|
gameDescription: '找到钥匙,开门逃离马戏团',
|
|
},
|
|
imageModel: 'gpt-image-2',
|
|
generatedLayerId: 'layer-publication',
|
|
});
|
|
expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({
|
|
id: 'publication-reference-1',
|
|
label: '首图参考',
|
|
src: '/read/publication-reference.png',
|
|
objectKey: 'generated-character-drafts/editor/reference.png',
|
|
assetObjectId: 'asset-object-publication-reference',
|
|
resourceId: 'resource-publication-reference',
|
|
});
|
|
});
|
|
|
|
it('serializes generation dialogs beside layers and splits them on load', () => {
|
|
const layer: CanvasLayer = {
|
|
id: 'layer-generated',
|
|
resourceId: 'resource-generated',
|
|
title: '生成图',
|
|
src: 'data:image/png;base64,heavy',
|
|
x: 10,
|
|
y: 20,
|
|
width: 1024,
|
|
height: 768,
|
|
originalWidth: 1024,
|
|
originalHeight: 768,
|
|
zIndex: 9,
|
|
sourceType: 'generated',
|
|
};
|
|
const dialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-9',
|
|
mode: 'generate',
|
|
prompt: '刷新后要继续保留',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
generatedLayerId: 'layer-generated',
|
|
imageModel: 'gpt-image-2',
|
|
style: 'pixelArt',
|
|
generationStartedAt: 1_771_400_000_000,
|
|
generationFinishedAt: 1_771_400_004_000,
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
generationReferences: [
|
|
{
|
|
id: 'reference-1',
|
|
label: '参考图',
|
|
src: 'data:image/png;base64,ref',
|
|
resourceId: 'resource-reference-1',
|
|
},
|
|
],
|
|
};
|
|
|
|
const layout = serializeCanvasLayout({
|
|
layers: [layer],
|
|
canvasGenerationDialogs: [dialog],
|
|
});
|
|
expect(
|
|
JSON.stringify(
|
|
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
|
|
),
|
|
).not.toContain('data:image');
|
|
expect(
|
|
JSON.stringify(
|
|
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
|
|
),
|
|
).toContain('ref:project-resource:resource-reference-1');
|
|
|
|
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
|
layout,
|
|
new Map([
|
|
[
|
|
'resource-reference-1',
|
|
{
|
|
imageSrc: '/read/reference-1.png',
|
|
objectKey: 'generated/reference-1.png',
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
expect(layerItems).toHaveLength(1);
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]).toMatchObject({
|
|
id: 'generation-dialog-9',
|
|
mode: 'generate',
|
|
prompt: '刷新后要继续保留',
|
|
status: 'generating',
|
|
generatedLayerId: 'layer-generated',
|
|
imageModel: 'gpt-image-2',
|
|
style: 'pixelArt',
|
|
generationStartedAt: 1_771_400_000_000,
|
|
generationFinishedAt: 1_771_400_004_000,
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 420,
|
|
},
|
|
});
|
|
expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({
|
|
label: '参考图',
|
|
src: '/read/reference-1.png',
|
|
resourceId: 'resource-reference-1',
|
|
objectKey: 'generated/reference-1.png',
|
|
});
|
|
});
|
|
|
|
it('defaults restored supported image styles to none and drops them from other modes', () => {
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-legacy',
|
|
mode: 'generate',
|
|
prompt: '旧任务',
|
|
status: 'idle',
|
|
})?.style,
|
|
).toBe('none');
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-unknown-style',
|
|
mode: 'character',
|
|
prompt: '未知风格',
|
|
status: 'idle',
|
|
style: 'futureStyle',
|
|
})?.style,
|
|
).toBe('none');
|
|
expect(
|
|
hydrateCanvasGenerationDialog({
|
|
id: 'generation-dialog-spec',
|
|
mode: 'spec',
|
|
prompt: '规范任务',
|
|
status: 'idle',
|
|
style: 'pixelArt',
|
|
})?.style,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('drops restored generator references owned by another user', () => {
|
|
const dialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-owner',
|
|
mode: 'generate',
|
|
prompt: '不应复用别人素材',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [
|
|
{
|
|
id: 'foreign-reference',
|
|
label: '别人账号的素材',
|
|
src: 'data:image/png;base64,foreign',
|
|
resourceId: 'resource-foreign',
|
|
},
|
|
],
|
|
};
|
|
|
|
const { generationDialogs } = splitCanvasLayoutItems(
|
|
serializeCanvasLayout({
|
|
layers: [],
|
|
canvasGenerationDialogs: [dialog],
|
|
}),
|
|
new Map([
|
|
[
|
|
'resource-foreign',
|
|
{
|
|
imageSrc: '/read/foreign.png',
|
|
objectKey: 'generated/foreign.png',
|
|
ownerUserId: 'user-b',
|
|
},
|
|
],
|
|
]),
|
|
'user-a',
|
|
);
|
|
|
|
expect(generationDialogs).toHaveLength(1);
|
|
expect(generationDialogs[0]?.generationReferences).toEqual([]);
|
|
});
|
|
|
|
it('snaps moving layers to nearby canvas and layer guides', () => {
|
|
const movingLayer: CanvasLayer = {
|
|
id: 'moving',
|
|
resourceId: 'resource-moving',
|
|
title: '移动图',
|
|
src: 'data:image/png;base64,moving',
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
originalWidth: 100,
|
|
originalHeight: 100,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
};
|
|
const anchorLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'anchor',
|
|
resourceId: 'resource-anchor',
|
|
x: 300,
|
|
y: 240,
|
|
zIndex: 2,
|
|
};
|
|
|
|
const snapped = resolveSnappedLayerPosition(
|
|
movingLayer,
|
|
CANVAS_WORLD_ORIGIN - 47,
|
|
238,
|
|
[movingLayer, anchorLayer],
|
|
1,
|
|
);
|
|
|
|
expect(snapped.x).toBe(CANVAS_WORLD_ORIGIN - movingLayer.width / 2);
|
|
expect(snapped.y).toBe(anchorLayer.y);
|
|
expect(snapped.guide).toEqual({
|
|
vertical: CANVAS_WORLD_ORIGIN,
|
|
horizontal: anchorLayer.y,
|
|
});
|
|
});
|
|
|
|
it('snaps moving layers to equal horizontal and vertical spacing', () => {
|
|
const movingLayer: CanvasLayer = {
|
|
id: 'moving',
|
|
resourceId: 'resource-moving',
|
|
title: '移动图',
|
|
src: 'data:image/png;base64,moving',
|
|
x: 0,
|
|
y: 0,
|
|
width: 80,
|
|
height: 80,
|
|
originalWidth: 80,
|
|
originalHeight: 80,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
};
|
|
const firstLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'first',
|
|
resourceId: 'resource-first',
|
|
x: 100,
|
|
y: 160,
|
|
zIndex: 2,
|
|
};
|
|
const secondLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'second',
|
|
resourceId: 'resource-second',
|
|
x: 260,
|
|
y: 160,
|
|
zIndex: 3,
|
|
};
|
|
const topLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'top',
|
|
resourceId: 'resource-top',
|
|
x: 520,
|
|
y: 120,
|
|
zIndex: 4,
|
|
};
|
|
const bottomLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'bottom',
|
|
resourceId: 'resource-bottom',
|
|
x: 520,
|
|
y: 300,
|
|
zIndex: 5,
|
|
};
|
|
|
|
const horizontalSnap = resolveSnappedLayerPosition(
|
|
movingLayer,
|
|
417,
|
|
160,
|
|
[movingLayer, firstLayer, secondLayer],
|
|
1,
|
|
);
|
|
expect(horizontalSnap.x).toBe(420);
|
|
expect(horizontalSnap.guide).toEqual({
|
|
vertical: 460,
|
|
horizontal: 160,
|
|
});
|
|
|
|
const verticalSnap = resolveSnappedLayerPosition(
|
|
movingLayer,
|
|
520,
|
|
207,
|
|
[movingLayer, topLayer, bottomLayer],
|
|
1,
|
|
);
|
|
expect(verticalSnap.y).toBe(210);
|
|
expect(verticalSnap.guide).toEqual({
|
|
vertical: 520,
|
|
horizontal: 250,
|
|
});
|
|
});
|
|
|
|
it('keeps equal-spacing snap focused on nearby overlapping layers', () => {
|
|
const movingLayer: CanvasLayer = {
|
|
id: 'moving',
|
|
resourceId: 'resource-moving',
|
|
title: '移动图',
|
|
src: 'data:image/png;base64,moving',
|
|
x: 0,
|
|
y: 0,
|
|
width: 80,
|
|
height: 80,
|
|
originalWidth: 80,
|
|
originalHeight: 80,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
};
|
|
const firstLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'first',
|
|
resourceId: 'resource-first',
|
|
x: 100,
|
|
y: 160,
|
|
zIndex: 2,
|
|
};
|
|
const secondLayer: CanvasLayer = {
|
|
...movingLayer,
|
|
id: 'second',
|
|
resourceId: 'resource-second',
|
|
x: 260,
|
|
y: 160,
|
|
zIndex: 3,
|
|
};
|
|
const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({
|
|
...movingLayer,
|
|
id: `offscreen-${index}`,
|
|
resourceId: `resource-offscreen-${index}`,
|
|
x: 2400 + index * 96,
|
|
y: 1600,
|
|
zIndex: 10 + index,
|
|
}));
|
|
|
|
const snapped = resolveSnappedLayerPosition(
|
|
movingLayer,
|
|
417,
|
|
160,
|
|
[movingLayer, ...offscreenLayers, firstLayer, secondLayer],
|
|
1,
|
|
);
|
|
|
|
expect(snapped.x).toBe(420);
|
|
expect(snapped.guide).toEqual({
|
|
vertical: 460,
|
|
horizontal: 160,
|
|
});
|
|
});
|
|
});
|