498b905bd8
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video> - 实现shift ctrl的范围选扩选 - 实现多选下载(为一个zip) - 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示. result: ```shell root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip Archive: 未命名画布-选中素材-20260806-195631.zip creating: 未命名画布-选中素材/ creating: 未命名画布-选中素材/images/ creating: 未命名画布-选中素材/media/ extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4 creating: 未命名画布-选中素材/sequences/ creating: 未命名画布-选中素材/sequences/002-角色动作/ creating: 未命名画布-选中素材/sequences/002-角色动作/frames/ extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png ... extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt extracting: 未命名画布-选中素材/metadata.json extracting: 未命名画布-选中素材/manifest.txt ``` ~~动作的导出存在很大的问题, 在另一个pr #117 解决~~ --------- Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
createLocalAssetFolder,
|
|
deleteAssetFolderLocally,
|
|
filterGroupedAssetsBySearch,
|
|
groupAssetsByFolder,
|
|
layerMatchesSearch,
|
|
layerStartsWithSearch,
|
|
moveAssetToFolderLocally,
|
|
removeAssetById,
|
|
removeSelectedAssets,
|
|
renameAssetById,
|
|
renameAssetFolderById,
|
|
replaceLocalAssetFolder,
|
|
resolveAssetFolderIdFromPoint,
|
|
resolveAssetShowcaseSubmissionState,
|
|
resolveDefaultAssetFolder,
|
|
resolvePinnedAssetMoveFolderId,
|
|
resolvePreferredAssetUploadFolder,
|
|
resolveUploadAssetFolder,
|
|
toggleAssetFolderCollapsed,
|
|
} from './ImageCanvasAssetLibraryModel';
|
|
import type {
|
|
CanvasLayer,
|
|
EditorAsset,
|
|
EditorAssetFolder,
|
|
} from './ImageCanvasEditorTypes';
|
|
|
|
function createFolder(
|
|
overrides: Partial<EditorAssetFolder> = {},
|
|
): EditorAssetFolder {
|
|
return {
|
|
id: 'project',
|
|
label: '项目素材',
|
|
collapsed: false,
|
|
systemDefault: true,
|
|
persisted: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createAsset(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 createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
|
|
return {
|
|
id: 'layer-a',
|
|
resourceId: 'resource-a',
|
|
title: '图层A',
|
|
src: 'data:image/png;base64,YQ==',
|
|
x: 0,
|
|
y: 0,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('ImageCanvasAssetLibraryModel', () => {
|
|
it('groups assets by folder and resolves selectable uploaded assets', () => {
|
|
const folders = [
|
|
createFolder(),
|
|
createFolder({ id: 'folder-role', label: '角色素材' }),
|
|
];
|
|
const assets = [
|
|
createAsset({ id: 'asset-a', folderId: 'project' }),
|
|
createAsset({
|
|
id: 'asset-b',
|
|
label: '素材B',
|
|
folderId: 'folder-role',
|
|
}),
|
|
createAsset({
|
|
id: 'built-in',
|
|
label: '内置素材',
|
|
sourceKind: 'built-in',
|
|
}),
|
|
];
|
|
|
|
expect(groupAssetsByFolder(folders, assets)).toMatchObject([
|
|
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
|
|
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
|
|
]);
|
|
});
|
|
|
|
it('explains why assets cannot be submitted to showcase review', () => {
|
|
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
|
|
'只有生成素材可以提交精选审核',
|
|
);
|
|
expect(
|
|
resolveAssetShowcaseSubmissionState(
|
|
createAsset({ sourceType: 'generated', persisted: false }),
|
|
).reason,
|
|
).toBe('素材保存完成后才能提交精选审核');
|
|
expect(
|
|
resolveAssetShowcaseSubmissionState(
|
|
createAsset({ sourceType: 'generated', src: '' }),
|
|
).reason,
|
|
).toBe('没有媒体内容的素材不能提交精选审核');
|
|
expect(
|
|
resolveAssetShowcaseSubmissionState(
|
|
createAsset({
|
|
sourceType: 'generated',
|
|
showcaseReviewStatus: 'rejected',
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
canSubmit: false,
|
|
label: '精选未通过',
|
|
reason: '该素材审核未通过,不能重复提交',
|
|
});
|
|
});
|
|
|
|
it('allows persisted generated assets to be submitted and keeps prior backend errors visible', () => {
|
|
expect(
|
|
resolveAssetShowcaseSubmissionState(
|
|
createAsset({ sourceType: 'generated' }),
|
|
),
|
|
).toMatchObject({
|
|
canSubmit: true,
|
|
label: '提交精选审核',
|
|
reason: null,
|
|
});
|
|
expect(
|
|
resolveAssetShowcaseSubmissionState(
|
|
createAsset({
|
|
sourceType: 'generated',
|
|
showcaseSubmitError: '只有生成素材可以提交精选审核',
|
|
}),
|
|
),
|
|
).toMatchObject({
|
|
canSubmit: true,
|
|
label: '提交精选审核',
|
|
reason: '只有生成素材可以提交精选审核',
|
|
});
|
|
});
|
|
|
|
it('filters grouped assets by folder and asset search text', () => {
|
|
const groupedAssets = groupAssetsByFolder(
|
|
[
|
|
createFolder(),
|
|
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
|
|
],
|
|
[
|
|
createAsset({ id: 'asset-a', label: '草地角色', folderId: 'project' }),
|
|
createAsset({
|
|
id: 'asset-b',
|
|
label: '音效',
|
|
folderId: 'folder-uploaded',
|
|
model: 'audio1.0',
|
|
}),
|
|
createAsset({
|
|
id: 'asset-c',
|
|
label: '角色音效',
|
|
folderId: 'folder-uploaded',
|
|
}),
|
|
],
|
|
);
|
|
|
|
expect(
|
|
filterGroupedAssetsBySearch(groupedAssets, 'audio').map((folder) => ({
|
|
id: folder.id,
|
|
assets: folder.assets.map((asset) => asset.id),
|
|
})),
|
|
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b'] }]);
|
|
expect(
|
|
filterGroupedAssetsBySearch(groupedAssets, '音').map((folder) => ({
|
|
id: folder.id,
|
|
assets: folder.assets.map((asset) => asset.id),
|
|
})),
|
|
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
|
|
expect(
|
|
filterGroupedAssetsBySearch(groupedAssets, '上传').map((folder) => ({
|
|
id: folder.id,
|
|
assets: folder.assets.map((asset) => asset.id),
|
|
})),
|
|
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
|
|
});
|
|
|
|
it('excludes internal processing models and providers from asset and layer search without deleting metadata', () => {
|
|
const internalAsset = createAsset({
|
|
id: 'asset-internal',
|
|
label: '透明角色',
|
|
sourceType: 'generated',
|
|
model: 'BgFilter complex',
|
|
provider: 'BgFilter',
|
|
});
|
|
const visibleAsset = createAsset({
|
|
id: 'asset-visible',
|
|
label: '生成角色',
|
|
sourceType: 'generated',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
});
|
|
const groupedAssets = groupAssetsByFolder(
|
|
[createFolder()],
|
|
[internalAsset, visibleAsset],
|
|
);
|
|
const internalLayer = createLayer({
|
|
model: 'segment-common-image',
|
|
provider: 'Aliyun Matting',
|
|
});
|
|
const visibleLayer = createLayer({
|
|
id: 'layer-visible',
|
|
model: 'audio1.0',
|
|
provider: 'VectorEngine',
|
|
});
|
|
|
|
expect(filterGroupedAssetsBySearch(groupedAssets, 'bgfilter')).toEqual([]);
|
|
expect(filterGroupedAssetsBySearch(groupedAssets, 'vectorengine')).toEqual(
|
|
[],
|
|
);
|
|
expect(
|
|
filterGroupedAssetsBySearch(groupedAssets, 'gpt-image-2')[0]?.assets.map(
|
|
(asset) => asset.id,
|
|
),
|
|
).toEqual(['asset-visible']);
|
|
expect(layerMatchesSearch(internalLayer, 'segment-common-image')).toBe(
|
|
false,
|
|
);
|
|
expect(layerStartsWithSearch(internalLayer, 'aliyun')).toBe(false);
|
|
expect(layerMatchesSearch(visibleLayer, 'audio1.0')).toBe(true);
|
|
expect(layerMatchesSearch(visibleLayer, 'vectorengine')).toBe(false);
|
|
expect(internalAsset).toMatchObject({
|
|
model: 'BgFilter complex',
|
|
provider: 'BgFilter',
|
|
});
|
|
expect(internalLayer).toMatchObject({
|
|
model: 'segment-common-image',
|
|
provider: 'Aliyun Matting',
|
|
});
|
|
});
|
|
|
|
it('renames assets, toggles folders and creates local folders', () => {
|
|
expect(
|
|
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
|
|
).toMatchObject({ label: '新名字' });
|
|
expect(
|
|
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
|
|
).toMatchObject({ label: '默认素材' });
|
|
expect(
|
|
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
|
|
).toMatchObject({ collapsed: true });
|
|
expect(
|
|
createLocalAssetFolder({
|
|
folderId: 'folder-local',
|
|
label: '角色素材',
|
|
}),
|
|
).toEqual({
|
|
id: 'folder-local',
|
|
label: '角色素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
persisted: false,
|
|
});
|
|
});
|
|
|
|
it('replaces local folder ids in folders and contained assets', () => {
|
|
const result = replaceLocalAssetFolder({
|
|
folders: [createFolder(), createFolder({ id: 'folder-local' })],
|
|
assets: [createAsset({ folderId: 'folder-local' })],
|
|
localFolderId: 'folder-local',
|
|
persistedFolder: {
|
|
folderId: 'folder-role',
|
|
label: '角色素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
},
|
|
});
|
|
|
|
expect(result.folders[1]).toEqual({
|
|
id: 'folder-role',
|
|
label: '角色素材',
|
|
collapsed: false,
|
|
systemDefault: false,
|
|
persisted: true,
|
|
});
|
|
expect(result.assets[0]).toMatchObject({ folderId: 'folder-role' });
|
|
});
|
|
|
|
it('deletes assets and moves deleted folder contents to the default folder', () => {
|
|
const folders = [
|
|
createFolder(),
|
|
createFolder({ id: 'folder-role', systemDefault: false }),
|
|
];
|
|
const assets = [
|
|
createAsset({ id: 'asset-a', folderId: 'folder-role' }),
|
|
createAsset({ id: 'asset-b', folderId: 'project' }),
|
|
];
|
|
|
|
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
|
|
['asset-b'],
|
|
);
|
|
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
|
|
expect(
|
|
resolveUploadAssetFolder([
|
|
createFolder(),
|
|
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
|
|
])?.id,
|
|
).toBe('folder-uploaded');
|
|
expect(resolvePreferredAssetUploadFolder(folders)?.id).toBe('project');
|
|
expect(
|
|
deleteAssetFolderLocally({
|
|
folders,
|
|
assets,
|
|
folderId: 'folder-role',
|
|
defaultFolderId: 'project',
|
|
}),
|
|
).toMatchObject({
|
|
folders: [{ id: 'project' }],
|
|
assets: [
|
|
{ id: 'asset-a', folderId: 'project' },
|
|
{ id: 'asset-b', folderId: 'project' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('removes selected assets', () => {
|
|
const assets = [
|
|
createAsset({ id: 'asset-a' }),
|
|
createAsset({ id: 'asset-b' }),
|
|
];
|
|
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
|
|
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
|
|
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
|
|
});
|
|
|
|
it('moves assets between folders locally', () => {
|
|
expect(
|
|
moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0],
|
|
).toMatchObject({ folderId: 'folder-role' });
|
|
});
|
|
|
|
it('resolves folder hit targets from pointer coordinates', () => {
|
|
const folders = [
|
|
{
|
|
folderId: 'project',
|
|
rect: { left: 10, right: 110, top: 10, bottom: 80 },
|
|
},
|
|
{
|
|
folderId: 'folder-role',
|
|
rect: { left: 10, right: 110, top: 81, bottom: 150 },
|
|
},
|
|
];
|
|
|
|
expect(
|
|
resolveAssetFolderIdFromPoint({
|
|
point: { clientX: 40, clientY: 80 },
|
|
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
|
|
folders,
|
|
}),
|
|
).toBe('project');
|
|
expect(
|
|
resolveAssetFolderIdFromPoint({
|
|
point: { clientX: 5, clientY: 40 },
|
|
listRect: { left: 10, right: 140, top: 0, bottom: 180 },
|
|
folders,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
resolveAssetFolderIdFromPoint({
|
|
point: { clientX: 120, clientY: 40 },
|
|
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
|
|
folders,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('pins asset move folder labels only when their header is outside the list viewport', () => {
|
|
const listRect = { left: 0, right: 120, top: 100, bottom: 240 };
|
|
|
|
expect(
|
|
resolvePinnedAssetMoveFolderId({
|
|
folderId: 'folder-role',
|
|
listRect,
|
|
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
|
|
}),
|
|
).toBe('folder-role');
|
|
expect(
|
|
resolvePinnedAssetMoveFolderId({
|
|
folderId: 'folder-role',
|
|
listRect,
|
|
headerRect: { left: 0, right: 120, top: 260, bottom: 300 },
|
|
}),
|
|
).toBe('folder-role');
|
|
expect(
|
|
resolvePinnedAssetMoveFolderId({
|
|
folderId: 'folder-role',
|
|
listRect,
|
|
headerRect: { left: 0, right: 120, top: 120, bottom: 150 },
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
resolvePinnedAssetMoveFolderId({
|
|
folderId: null,
|
|
listRect,
|
|
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|