6ee33dc561
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。 - 320×240 封面改为居中 cover 裁切,不再从左上角取景。 - 正确绘制画布背景色。 in canva:   in home:   因为原本的布局持久化可能有一些bug,改动不相关,所以我回退到最开始的一版: - 不额外监听画布尺寸变化,给最小尺寸兜底,(大了比小了好) - 封面不要求强一致性,生成/上传时机仍然按照原来随布局持久化 优化了封面生成: - 改用webp格式 - 尽量利用图片缓存 最终生成封面从700ms加速到150ms以内, 加上必须的上传和确认150ms 和 布局持久化的时间相差不多,不是特别离谱的手速都能保证两者一致 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/111 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import { fireEvent } from '@testing-library/react';
|
|
import JSZip from 'jszip';
|
|
import type { ContextType } from 'react';
|
|
import { afterEach, beforeEach, expect, type Mock, vi } from 'vitest';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import type {
|
|
EditorProjectLayerSnapshot,
|
|
EditorProjectResourceSnapshot,
|
|
EditorProjectSnapshot,
|
|
} from '../../services/image-editor/editorProjectClient';
|
|
import { AuthUiContext } from '../auth/AuthUiContext';
|
|
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
|
|
|
export { ApiClientError, AuthUiContext, ImageCanvasEditorView };
|
|
|
|
export type AuthValue = NonNullable<
|
|
ContextType<typeof AuthUiContext>
|
|
>;
|
|
|
|
export type ImageCanvasEditorViewServiceMocks = {
|
|
generateEditorImageMock: Mock;
|
|
generateEditorIconSpritesheetMock: Mock;
|
|
extractEditorUiDesignAssetsMock?: Mock;
|
|
generateEditorCharacterAnimationMock: Mock;
|
|
editEditorImageMock: Mock;
|
|
createEditorAssetMock: Mock;
|
|
createEditorProjectResourceMock: Mock;
|
|
createEditorAssetFolderMock: Mock;
|
|
updateEditorAssetMock: Mock;
|
|
updateEditorAssetFolderMock: Mock;
|
|
deleteEditorAssetFolderMock: Mock;
|
|
deleteEditorAssetMock: Mock;
|
|
loadEditorAssetLibraryMock: Mock;
|
|
loadEditorProjectMock: Mock;
|
|
loadOrCreateRecentEditorProjectMock: Mock;
|
|
renameEditorProjectMock: Mock;
|
|
saveEditorProjectLayoutMock: Mock;
|
|
};
|
|
|
|
const runRequiredAuthAction = (action: () => void) => action();
|
|
|
|
export function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
|
|
return {
|
|
user: null,
|
|
canAccessProtectedData: false,
|
|
openLoginModal: vi.fn(),
|
|
requireAuth: vi.fn(runRequiredAuthAction),
|
|
openSettingsModal: vi.fn(),
|
|
openAccountModal: vi.fn(),
|
|
setCurrentUser: vi.fn(),
|
|
logout: vi.fn(),
|
|
musicVolume: 0.5,
|
|
setMusicVolume: vi.fn(),
|
|
platformTheme: 'light',
|
|
setPlatformTheme: vi.fn(),
|
|
isHydratingSettings: false,
|
|
isPersistingSettings: false,
|
|
settingsError: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export const defaultEditorProjectResources = [
|
|
{
|
|
resourceId: 'resource-puzzle',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/creation-type-references/puzzle.webp',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
resourceId: 'resource-big-fish',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/creation-type-references/big-fish.webp',
|
|
width: 720,
|
|
height: 405,
|
|
sourceType: 'uploaded',
|
|
},
|
|
] satisfies EditorProjectResourceSnapshot[];
|
|
|
|
export const defaultEditorProjectLayers = [
|
|
{
|
|
layerId: 'layer-puzzle',
|
|
resourceId: 'resource-puzzle',
|
|
title: '拼图素材',
|
|
x: 470,
|
|
y: 300,
|
|
width: 640,
|
|
height: 640,
|
|
originalWidth: 640,
|
|
originalHeight: 640,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
layerId: 'layer-big-fish',
|
|
resourceId: 'resource-big-fish',
|
|
title: '大鱼素材',
|
|
x: 930,
|
|
y: 360,
|
|
width: 720,
|
|
height: 405,
|
|
originalWidth: 720,
|
|
originalHeight: 405,
|
|
zIndex: 2,
|
|
sourceType: 'uploaded',
|
|
},
|
|
] satisfies EditorProjectLayerSnapshot[];
|
|
|
|
export function withEditorProjectCanvasRevision<
|
|
T extends EditorProjectSnapshot,
|
|
>(project: T, revision = 0): T {
|
|
if (typeof project.canvas?.revision === 'number') {
|
|
return project;
|
|
}
|
|
return {
|
|
...project,
|
|
canvas: {
|
|
canvasId:
|
|
project.canvas?.canvasId ?? `${project.projectId}:canvas:default`,
|
|
projectId: project.projectId,
|
|
title: project.canvas?.title ?? '默认画布',
|
|
viewport: project.canvas?.viewport ?? project.viewport,
|
|
layers: project.canvas?.layers ?? project.layers,
|
|
revision,
|
|
layoutStorageVersion: project.canvas?.layoutStorageVersion ?? 0,
|
|
backgroundColor: project.canvas?.backgroundColor,
|
|
createdAt: project.canvas?.createdAt,
|
|
updatedAt: project.canvas?.updatedAt ?? project.updatedAt,
|
|
},
|
|
};
|
|
}
|
|
|
|
export const defaultEditorAssetLibraryAssets = [
|
|
{
|
|
assetId: 'asset-puzzle',
|
|
folderId: 'project',
|
|
label: '拼图素材',
|
|
imageSrc: '/creation-type-references/puzzle.webp',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
assetId: 'asset-match3d',
|
|
folderId: 'project',
|
|
label: '抓大鹅素材',
|
|
imageSrc: '/creation-type-references/match3d.webp',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
assetId: 'asset-big-fish',
|
|
folderId: 'project',
|
|
label: '大鱼素材',
|
|
imageSrc: '/creation-type-references/big-fish.webp',
|
|
width: 720,
|
|
height: 405,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
assetId: 'asset-bark-battle',
|
|
folderId: 'project',
|
|
label: '声浪素材',
|
|
imageSrc: '/creation-type-references/bark-battle.webp',
|
|
width: 640,
|
|
height: 900,
|
|
sourceType: 'uploaded',
|
|
},
|
|
{
|
|
assetId: 'asset-visual-novel',
|
|
folderId: 'project',
|
|
label: '视觉小说素材',
|
|
imageSrc: '/creation-type-references/visual-novel.webp',
|
|
width: 720,
|
|
height: 405,
|
|
sourceType: 'uploaded',
|
|
},
|
|
];
|
|
|
|
export function dispatchPointerEvent(
|
|
target: Element,
|
|
type: string,
|
|
init: MouseEventInit & { pointerId: number },
|
|
) {
|
|
const event = new MouseEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...init,
|
|
});
|
|
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
|
|
fireEvent(target, event);
|
|
}
|
|
|
|
export function immediateAsync<T>(value: T) {
|
|
return {
|
|
then(onFulfilled: (value: T) => unknown) {
|
|
onFulfilled(value);
|
|
return {
|
|
catch() {},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createDataTransferStub() {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
files: [],
|
|
types: [] as string[],
|
|
dropEffect: 'none',
|
|
effectAllowed: 'all',
|
|
setData(type: string, value: string) {
|
|
store.set(type, value);
|
|
if (!this.types.includes(type)) {
|
|
this.types.push(type);
|
|
}
|
|
},
|
|
getData(type: string) {
|
|
return store.get(type) ?? '';
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
export async function readZipText(zip: JSZip, path: string) {
|
|
const file = zip.file(path);
|
|
expect(file).toBeTruthy();
|
|
return file!.async('string');
|
|
}
|
|
|
|
export function setupImageCanvasEditorViewTestLifecycle({
|
|
generateEditorImageMock,
|
|
generateEditorIconSpritesheetMock,
|
|
extractEditorUiDesignAssetsMock,
|
|
generateEditorCharacterAnimationMock,
|
|
editEditorImageMock,
|
|
createEditorAssetMock,
|
|
createEditorProjectResourceMock,
|
|
createEditorAssetFolderMock,
|
|
updateEditorAssetMock,
|
|
updateEditorAssetFolderMock,
|
|
deleteEditorAssetFolderMock,
|
|
deleteEditorAssetMock,
|
|
loadEditorAssetLibraryMock,
|
|
loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProjectMock,
|
|
renameEditorProjectMock,
|
|
saveEditorProjectLayoutMock,
|
|
}: ImageCanvasEditorViewServiceMocks) {
|
|
beforeEach(() => {
|
|
window.sessionStorage.clear();
|
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
|
loadOrCreateRecentEditorProjectMock.mockImplementation(() =>
|
|
immediateAsync(withEditorProjectCanvasRevision({
|
|
projectId: 'editor-project-default',
|
|
title: '默认项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: defaultEditorProjectLayers,
|
|
resources: defaultEditorProjectResources,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
})),
|
|
);
|
|
loadEditorAssetLibraryMock.mockImplementation(() =>
|
|
immediateAsync({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: defaultEditorAssetLibraryAssets,
|
|
}),
|
|
);
|
|
createEditorAssetMock.mockImplementation(async (input) => ({
|
|
assetId: `persisted-${input.label}`,
|
|
folderId: input.folderId,
|
|
label: input.label,
|
|
imageSrc: input.imageSrc,
|
|
objectKey: input.objectKey,
|
|
assetObjectId: input.assetObjectId,
|
|
width: input.width,
|
|
height: input.height,
|
|
sourceType: input.sourceType,
|
|
assetKind: input.assetKind,
|
|
generationInputs: input.generationInputs,
|
|
durationSeconds: input.durationSeconds,
|
|
}));
|
|
createEditorAssetFolderMock.mockResolvedValue({
|
|
folderId: 'folder-role-persisted',
|
|
label: '角色上传',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
});
|
|
updateEditorAssetMock.mockImplementation(async (assetId, input) => ({
|
|
assetId,
|
|
folderId: input.folderId ?? 'project',
|
|
label: input.label ?? '拼图素材',
|
|
imageSrc: '/creation-type-references/puzzle.webp',
|
|
width: 640,
|
|
height: 640,
|
|
sourceType: 'uploaded',
|
|
}));
|
|
renameEditorProjectMock.mockImplementation(async (projectId, title) => ({
|
|
projectId,
|
|
title,
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: defaultEditorProjectLayers,
|
|
resources: defaultEditorProjectResources,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
}));
|
|
updateEditorAssetFolderMock.mockImplementation(async (folderId, input) => ({
|
|
folderId,
|
|
label: input.label ?? '角色上传',
|
|
collapsed: input.collapsed ?? false,
|
|
systemDefault: false,
|
|
}));
|
|
deleteEditorAssetFolderMock.mockResolvedValue({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
deleteEditorAssetMock.mockResolvedValue({});
|
|
createEditorProjectResourceMock.mockImplementation(
|
|
async (projectId, input) => ({
|
|
resourceId: `resource-${projectId}-${input.width}`,
|
|
projectId,
|
|
imageSrc: input.imageSrc,
|
|
objectKey: input.objectKey,
|
|
assetObjectId: input.assetObjectId,
|
|
width: input.width,
|
|
height: input.height,
|
|
sourceType: input.sourceType,
|
|
durationSeconds: input.durationSeconds,
|
|
assetKind: input.assetKind,
|
|
generationInputs: input.generationInputs,
|
|
}),
|
|
);
|
|
saveEditorProjectLayoutMock.mockResolvedValue({});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
generateEditorImageMock.mockReset();
|
|
generateEditorIconSpritesheetMock.mockReset();
|
|
extractEditorUiDesignAssetsMock?.mockReset();
|
|
generateEditorCharacterAnimationMock.mockReset();
|
|
editEditorImageMock.mockReset();
|
|
createEditorAssetMock.mockReset();
|
|
createEditorProjectResourceMock.mockReset();
|
|
createEditorAssetFolderMock.mockReset();
|
|
updateEditorAssetMock.mockReset();
|
|
updateEditorAssetFolderMock.mockReset();
|
|
deleteEditorAssetFolderMock.mockReset();
|
|
deleteEditorAssetMock.mockReset();
|
|
loadEditorAssetLibraryMock.mockReset();
|
|
loadEditorProjectMock.mockReset();
|
|
loadOrCreateRecentEditorProjectMock.mockReset();
|
|
renameEditorProjectMock.mockReset();
|
|
saveEditorProjectLayoutMock.mockReset();
|
|
window.history.replaceState(null, '', '/editor/canvas');
|
|
});
|
|
}
|