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>
603 lines
18 KiB
TypeScript
603 lines
18 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
import { useRef } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ApiClientError } from '../../services/apiClient';
|
|
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
|
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
|
|
|
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
|
const submitEditorAssetShowcaseMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
|
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
|
|
|
const defaultOpenEditorLoginModal = () => {};
|
|
const defaultOnDeleteAssets = () => {};
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/image-editor/editorProjectClient')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
createEditorAssetFolder: createEditorAssetFolderMock,
|
|
deleteEditorAsset: deleteEditorAssetMock,
|
|
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
|
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
|
submitEditorAssetShowcase: submitEditorAssetShowcaseMock,
|
|
updateEditorAsset: updateEditorAssetMock,
|
|
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
|
};
|
|
});
|
|
|
|
function createUploadedAsset(
|
|
overrides: Partial<EditorAsset> = {},
|
|
): EditorAsset {
|
|
return {
|
|
id: 'asset-a',
|
|
label: '素材A',
|
|
src: 'data:image/png;base64,YQ==',
|
|
width: 320,
|
|
height: 240,
|
|
folderId: 'project',
|
|
sourceKind: 'uploaded',
|
|
sourceType: 'uploaded',
|
|
persisted: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createAssetSnapshot(
|
|
overrides: Partial<EditorAssetSnapshot> = {},
|
|
): EditorAssetSnapshot {
|
|
return {
|
|
assetId: 'asset-a',
|
|
label: '素材A',
|
|
imageSrc: 'data:image/png;base64,YQ==',
|
|
width: 320,
|
|
height: 240,
|
|
folderId: 'project',
|
|
sourceType: 'uploaded',
|
|
assetKind: 'image',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function AssetLibraryHarness({
|
|
canAccessProtectedData = true,
|
|
openEditorLoginModal = defaultOpenEditorLoginModal,
|
|
onDeleteAssets = defaultOnDeleteAssets,
|
|
}: {
|
|
canAccessProtectedData?: boolean;
|
|
openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void;
|
|
onDeleteAssets?: (assets: EditorAsset[]) => boolean | void;
|
|
}) {
|
|
const assetListRef = useRef<HTMLDivElement | null>(null);
|
|
const assetLibrary = useImageCanvasAssetLibrary({
|
|
assetListRef,
|
|
canAccessProtectedData,
|
|
openEditorLoginModal,
|
|
onDeleteAssets,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<div ref={assetListRef}>
|
|
{assetLibrary.groupedAssets.map((folder) => (
|
|
<section key={folder.id} data-asset-folder-id={folder.id}>
|
|
<h2>{folder.label}</h2>
|
|
{folder.assets.map((asset, index) => (
|
|
<button
|
|
key={asset.id || `asset-${index}`}
|
|
type="button"
|
|
data-asset-id={asset.id}
|
|
onClick={() => assetLibrary.toggleAssetSelected(asset.id)}
|
|
>
|
|
{asset.label}
|
|
</button>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
<span data-testid="folders">
|
|
{assetLibrary.assetFolders
|
|
.map((folder) => `${folder.id}:${folder.label}:${folder.persisted}`)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="assets">
|
|
{assetLibrary.assets
|
|
.map(
|
|
(asset) =>
|
|
`${asset.id}:${asset.label}:${asset.folderId}:${
|
|
asset.publicShowcaseEnabled ?? 'unset'
|
|
}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="asset-submit-errors">
|
|
{assetLibrary.assets
|
|
.map((asset) => `${asset.id}:${asset.showcaseSubmitError ?? ''}`)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="active-upload-folder">
|
|
{assetLibrary.activeUploadFolderId}
|
|
</span>
|
|
<span data-testid="all-selected">
|
|
{String(assetLibrary.allSelectableAssetsSelected)}
|
|
</span>
|
|
<span data-testid="selected-assets">
|
|
{[...assetLibrary.selectedAssetIds].join('|')}
|
|
</span>
|
|
<span data-testid="new-folder-name">{assetLibrary.newFolderName}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
assetLibrary.setNewFolderName('角色素材');
|
|
assetLibrary.setCreatingFolder(true);
|
|
}}
|
|
>
|
|
prepare folder
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void assetLibrary.commitNewAssetFolder()}
|
|
>
|
|
commit folder
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => assetLibrary.moveAssetToFolder('asset-a', 'folder-role')}
|
|
>
|
|
move asset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const target = assetLibrary.assets.find(
|
|
(asset) => asset.id === 'asset-a',
|
|
);
|
|
if (target) {
|
|
assetLibrary.deleteUploadedAsset(target);
|
|
}
|
|
}}
|
|
>
|
|
delete asset
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const target = assetLibrary.assets.find(
|
|
(asset) => asset.id === 'asset-a',
|
|
);
|
|
if (target) {
|
|
assetLibrary.submitAssetToShowcase(target);
|
|
}
|
|
}}
|
|
>
|
|
submit asset to showcase
|
|
</button>
|
|
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
|
|
toggle all
|
|
</button>
|
|
<button type="button" onClick={assetLibrary.deleteSelectedAssets}>
|
|
delete selected
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasAssetLibrary', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loadEditorAssetLibraryMock.mockResolvedValue({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [createAssetSnapshot()],
|
|
});
|
|
createEditorAssetFolderMock.mockResolvedValue({
|
|
folderId: 'folder-role',
|
|
label: '角色素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
});
|
|
updateEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
|
submitEditorAssetShowcaseMock.mockResolvedValue({
|
|
resourceId: 'editor-showcase:asset-a',
|
|
showcaseId: 'editor-showcase:asset-a',
|
|
assetId: 'asset-a',
|
|
projectId: 'project-1',
|
|
imageSrc: 'data:image/png;base64,YQ==',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'generated',
|
|
reviewStatus: 'pending',
|
|
displayEnabled: false,
|
|
likeCount: 0,
|
|
generationCostMudPoints: 4,
|
|
});
|
|
deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot());
|
|
deleteEditorAssetFolderMock.mockResolvedValue({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [],
|
|
});
|
|
updateEditorAssetFolderMock.mockResolvedValue({
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
});
|
|
});
|
|
|
|
it('loads and normalizes the account asset library', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'project-duplicate',
|
|
label: '重复默认',
|
|
sortOrder: 1,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [createAssetSnapshot()],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('folders').textContent).toBe(
|
|
'project:项目素材:true',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-a:素材A:project:unset',
|
|
);
|
|
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
|
|
'project',
|
|
);
|
|
});
|
|
|
|
it('opens login when loading the asset library is unauthorized', async () => {
|
|
const openEditorLoginModal = vi.fn();
|
|
loadEditorAssetLibraryMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '未授权访问',
|
|
status: 401,
|
|
code: 'UNAUTHORIZED',
|
|
}),
|
|
);
|
|
|
|
render(<AssetLibraryHarness openEditorLoginModal={openEditorLoginModal} />);
|
|
|
|
await waitFor(() => {
|
|
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it('does not request the protected asset library before login is available', () => {
|
|
render(<AssetLibraryHarness canAccessProtectedData={false} />);
|
|
|
|
expect(loadEditorAssetLibraryMock).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('folders').textContent).toBe(
|
|
'project:项目素材:false',
|
|
);
|
|
});
|
|
|
|
it('creates a local folder and replaces it with the persisted folder id', async () => {
|
|
let resolveCreateFolder: (
|
|
folder: Awaited<ReturnType<typeof createEditorAssetFolderMock>>,
|
|
) => void = () => {};
|
|
createEditorAssetFolderMock.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveCreateFolder = resolve;
|
|
}),
|
|
);
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() => screen.getByRole('button', { name: 'prepare folder' }).click());
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('new-folder-name').textContent).toBe(
|
|
'角色素材',
|
|
);
|
|
});
|
|
act(() => screen.getByRole('button', { name: 'commit folder' }).click());
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('folders').textContent).toContain('folder-');
|
|
});
|
|
act(() => {
|
|
resolveCreateFolder({
|
|
folderId: 'folder-role',
|
|
label: '角色素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('folders').textContent).toContain(
|
|
'folder-role:角色素材:true',
|
|
);
|
|
});
|
|
expect(createEditorAssetFolderMock).toHaveBeenCalledWith('角色素材', 101);
|
|
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
|
|
'folder-role',
|
|
);
|
|
});
|
|
|
|
it('moves a persisted asset to another folder', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
{
|
|
folderId: 'folder-role',
|
|
label: '角色素材',
|
|
sortOrder: 1,
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
},
|
|
],
|
|
assets: [createAssetSnapshot()],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() => screen.getByRole('button', { name: 'move asset' }).click());
|
|
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-a:素材A:folder-role:unset',
|
|
);
|
|
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', {
|
|
folderId: 'folder-role',
|
|
});
|
|
});
|
|
|
|
it('deletes uploaded assets and reports them to the canvas cleanup callback', async () => {
|
|
const onDeleteAssets = vi.fn();
|
|
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
|
|
|
|
expect(screen.getByTestId('assets').textContent).toBe('');
|
|
expect(onDeleteAssets).toHaveBeenCalledWith([
|
|
expect.objectContaining({ id: 'asset-a' }),
|
|
]);
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
|
});
|
|
|
|
it('keeps uploaded assets when canvas cleanup vetoes deletion', async () => {
|
|
const onDeleteAssets = vi.fn().mockReturnValue(false);
|
|
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() => screen.getByRole('button', { name: 'delete asset' }).click());
|
|
|
|
expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A');
|
|
expect(onDeleteAssets).toHaveBeenCalledWith([
|
|
expect.objectContaining({ id: 'asset-a' }),
|
|
]);
|
|
expect(deleteEditorAssetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('selects and deletes selected uploaded assets', async () => {
|
|
const onDeleteAssets = vi.fn();
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createAssetSnapshot({ assetId: 'asset-a', label: '素材A' }),
|
|
createAssetSnapshot({ assetId: 'asset-b', label: '素材B' }),
|
|
],
|
|
});
|
|
|
|
render(<AssetLibraryHarness onDeleteAssets={onDeleteAssets} />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() => screen.getByRole('button', { name: 'toggle all' }).click());
|
|
expect(screen.getByTestId('all-selected').textContent).toBe('true');
|
|
act(() => screen.getByRole('button', { name: '素材B' }).click());
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('selected-assets').textContent).toBe('asset-a');
|
|
});
|
|
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
|
|
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-b:素材B:project:unset',
|
|
);
|
|
expect(onDeleteAssets).toHaveBeenCalledWith([
|
|
expect.objectContaining({ id: 'asset-a' }),
|
|
]);
|
|
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
|
|
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
|
|
});
|
|
|
|
it('submits a generated asset to showcase review', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createAssetSnapshot({
|
|
assetId: 'asset-a',
|
|
sourceType: 'generated',
|
|
publicShowcaseEnabled: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() =>
|
|
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-a:素材A:project:false',
|
|
);
|
|
});
|
|
expect(submitEditorAssetShowcaseMock).toHaveBeenCalledWith('asset-a');
|
|
});
|
|
|
|
it('submits legacy generated assets without a source resource id', async () => {
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createAssetSnapshot({
|
|
assetId: 'asset-a',
|
|
sourceType: 'generated',
|
|
publicShowcaseEnabled: null,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() =>
|
|
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(submitEditorAssetShowcaseMock).toHaveBeenCalledWith('asset-a');
|
|
});
|
|
});
|
|
|
|
it('rolls back showcase state when submit fails', async () => {
|
|
submitEditorAssetShowcaseMock.mockRejectedValueOnce(new Error('network'));
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createAssetSnapshot({
|
|
assetId: 'asset-a',
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-a',
|
|
publicShowcaseEnabled: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() =>
|
|
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-a:素材A:project:true',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps the backend showcase rejection reason on the asset', async () => {
|
|
submitEditorAssetShowcaseMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '只有生成素材可以提交精选审核',
|
|
status: 400,
|
|
code: 'BAD_REQUEST',
|
|
}),
|
|
);
|
|
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
|
folders: [
|
|
{
|
|
folderId: 'project',
|
|
label: '项目素材',
|
|
sortOrder: 0,
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
},
|
|
],
|
|
assets: [
|
|
createAssetSnapshot({
|
|
assetId: 'asset-a',
|
|
sourceType: 'generated',
|
|
publicShowcaseEnabled: true,
|
|
}),
|
|
],
|
|
});
|
|
|
|
render(<AssetLibraryHarness />);
|
|
|
|
await screen.findByText('素材A');
|
|
act(() =>
|
|
screen.getByRole('button', { name: 'submit asset to showcase' }).click(),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('asset-submit-errors').textContent).toBe(
|
|
'asset-a:只有生成素材可以提交精选审核',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('assets').textContent).toBe(
|
|
'asset-a:素材A:project:true',
|
|
);
|
|
});
|
|
});
|