修复私有序列帧批量导出去重
将帧级 assetObjectId、objectKey 与 imageSrc 纳入有边界的稳定导出身份 补充两个 objectKey-only 私有序列共同导出及 metadata 映射回归 同步角色动作集合导出去重契约文档
This commit is contained in:
@@ -110,7 +110,26 @@ describe('ImageCanvasExportModel', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe('image-sequence:sequence-task');
|
||||
).toBe('image-sequence:task:sequence-task');
|
||||
expect(
|
||||
getLayerExportKey({
|
||||
...buildLayer(),
|
||||
mediaType: 'image-sequence',
|
||||
taskId: null,
|
||||
sourceResourceId: null,
|
||||
src: '',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '',
|
||||
objectKey: 'private/animation-a/frame-01.png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(
|
||||
'image-sequence:frames:["object-key:private/animation-a/frame-01.png"]',
|
||||
);
|
||||
});
|
||||
|
||||
it('detects image extensions from content type before falling back to src', () => {
|
||||
|
||||
@@ -34,12 +34,22 @@ export function formatExportDate(date: Date) {
|
||||
|
||||
export function getLayerExportKey(layer: CanvasLayer) {
|
||||
if (layer.mediaType === 'image-sequence') {
|
||||
return `image-sequence:${
|
||||
layer.taskId ||
|
||||
layer.sourceResourceId ||
|
||||
layer.imageSequenceFrames?.map((frame) => frame.imageSrc).join('|') ||
|
||||
layer.src
|
||||
}`;
|
||||
if (layer.taskId) {
|
||||
return `image-sequence:task:${layer.taskId}`;
|
||||
}
|
||||
if (layer.sourceResourceId) {
|
||||
return `image-sequence:resource:${layer.sourceResourceId}`;
|
||||
}
|
||||
const frameIdentities = layer.imageSequenceFrames?.map((frame) =>
|
||||
frame.assetObjectId
|
||||
? `asset-object:${frame.assetObjectId}`
|
||||
: frame.objectKey
|
||||
? `object-key:${frame.objectKey}`
|
||||
: `source:${frame.imageSrc}`,
|
||||
);
|
||||
return frameIdentities?.length
|
||||
? `image-sequence:frames:${JSON.stringify(frameIdentities)}`
|
||||
: `image-sequence:source:${layer.src}`;
|
||||
}
|
||||
return (
|
||||
layer.assetObjectId ||
|
||||
|
||||
@@ -419,6 +419,127 @@ describe('useImageCanvasAssetExportWorkflow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('exports distinct object-key-only private image sequences with separate metadata', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const frameContents = new Map([
|
||||
['private/animation-a/frame-01.png', 'animation-a-frame'],
|
||||
['private/animation-b/frame-01.png', 'animation-b-frame'],
|
||||
]);
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const requestUrl = new URL(url, 'http://localhost');
|
||||
if (requestUrl.pathname === '/api/assets/read-url') {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
if (requestUrl.pathname === '/api/assets/read-bytes') {
|
||||
const objectKey = requestUrl.searchParams.get('objectKey') ?? '';
|
||||
const content = frameContents.get(objectKey);
|
||||
return content
|
||||
? new Response(content, {
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
})
|
||||
: new Response(null, { status: 404 });
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
let exportedBlob: Blob | null = null;
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => {
|
||||
exportedBlob = blob;
|
||||
return 'blob:private-sequences';
|
||||
}),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
render(
|
||||
<ExportWorkflowHarness
|
||||
layers={[
|
||||
createLayer('private-sequence-a', {
|
||||
title: '私有动作 A',
|
||||
src: '',
|
||||
objectKey: null,
|
||||
taskId: null,
|
||||
sourceResourceId: null,
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '',
|
||||
objectKey: 'private/animation-a/frame-01.png',
|
||||
width: 100,
|
||||
height: 80,
|
||||
},
|
||||
],
|
||||
zIndex: 1,
|
||||
}),
|
||||
createLayer('private-sequence-b', {
|
||||
title: '私有动作 B',
|
||||
src: '',
|
||||
objectKey: null,
|
||||
taskId: null,
|
||||
sourceResourceId: null,
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
imageSrc: '',
|
||||
objectKey: 'private/animation-b/frame-01.png',
|
||||
width: 100,
|
||||
height: 80,
|
||||
},
|
||||
],
|
||||
zIndex: 2,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出画布素材' }));
|
||||
|
||||
await waitFor(() => expect(exportedBlob).toBeTruthy());
|
||||
const zip = await JSZip.loadAsync(exportedBlob!);
|
||||
const firstFramePath =
|
||||
'导出项目-画布素材/sequences/001-私有动作 A/frames/frame-01.png';
|
||||
const secondFramePath =
|
||||
'导出项目-画布素材/sequences/002-私有动作 B/frames/frame-01.png';
|
||||
expect(await zip.file(firstFramePath)!.async('text')).toBe(
|
||||
'animation-a-frame',
|
||||
);
|
||||
expect(await zip.file(secondFramePath)!.async('text')).toBe(
|
||||
'animation-b-frame',
|
||||
);
|
||||
const metadata = JSON.parse(
|
||||
await readZipText(zip, '导出项目-画布素材/metadata.json'),
|
||||
);
|
||||
expect(metadata.layers).toHaveLength(2);
|
||||
expect(
|
||||
metadata.layers.map((layer: { file: string }) => layer.file),
|
||||
).toEqual([
|
||||
'sequences/001-私有动作 A/manifest.txt',
|
||||
'sequences/002-私有动作 B/manifest.txt',
|
||||
]);
|
||||
expect(
|
||||
await readZipText(zip, '导出项目-画布素材/manifest.txt'),
|
||||
).toContain('素材数量:2');
|
||||
for (const objectKey of frameContents.keys()) {
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`/api/assets/read-bytes?objectKey=${encodeURIComponent(objectKey)}`,
|
||||
),
|
||||
expect.any(Object),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
|
||||
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
it('reads layers and sequence frames with bounded concurrency and stable zip order', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const pendingResponses = new Map<string, () => void>();
|
||||
|
||||
Reference in New Issue
Block a user