a03f13d0a5
原问题
```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```
- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs` 手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试
- 移除preview_video_path字段, reason:
>preview video
→ separate resource/asset with assetKind="video"
final transparent action
→ assetKind="character-animation"
→ source_resource_id points to the preview-video resource
→ image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复
- 清除副产品preview video的generation_input_json, 因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造
仍存在的共性问题: #134
- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变
画布放置:
before:

after:

精选模块: 主页展示, 审核部分UI:


这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
406 lines
12 KiB
TypeScript
406 lines
12 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: '默认项目',
|
|
canvas: {
|
|
canvasId: 'editor-project-default:canvas:default',
|
|
projectId: 'editor-project-default',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: defaultEditorProjectLayers,
|
|
revision: 0,
|
|
layoutStorageVersion: 0,
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
},
|
|
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,
|
|
imageSequenceFrames: input.imageSequenceFrames,
|
|
imageSequenceDurationMs: input.imageSequenceDurationMs,
|
|
}));
|
|
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,
|
|
assetKind: input.assetKind,
|
|
generationInputs: input.generationInputs,
|
|
imageSequenceFrames: input.imageSequenceFrames,
|
|
imageSequenceDurationMs: input.imageSequenceDurationMs,
|
|
}),
|
|
);
|
|
saveEditorProjectLayoutMock.mockImplementation(
|
|
async (projectId, input) => ({
|
|
projectId,
|
|
canvasId: `${projectId}:canvas:default`,
|
|
revision: input.expectedRevision + 1,
|
|
updatedAt: '2026-06-12T00:00:01.000Z',
|
|
}),
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|
|
}
|