修复生成确认与素材导出

补齐外部生成确认请求的 JSON Content-Type

本地屏蔽已确认终态任务避免轮询重复弹窗

单素材导出改为读取 Blob 后触发浏览器下载

序列帧导出按 frame objectKey 换签读取
This commit is contained in:
2026-06-24 20:35:03 +08:00
parent 694574d8fa
commit f73f1026cf
7 changed files with 250 additions and 50 deletions
@@ -8,6 +8,7 @@ import {
getLayerAssetExtensionFromTypeOrSrc,
getLayerExportKey,
readLayerAssetBlob,
readLayerImageSequenceFrameBlob,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
@@ -215,6 +216,70 @@ describe('ImageCanvasExportModel', () => {
globalThis.fetch = originalFetch;
}
});
it('reads private image-sequence frame object keys through signed URLs', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-url?')) {
return new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated/frame-1.png',
signedUrl:
'https://oss.example.com/generated/frame-1.png?x-oss-signature=1',
expiresAt: '2026-06-20T00:00:00.000Z',
},
},
error: null,
meta: { apiVersion: '2026-06-16' },
}),
);
}
if (
url === 'https://oss.example.com/generated/frame-1.png?x-oss-signature=1'
) {
return new Response(new Blob(['frame'], { type: 'image/png' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
try {
const blob = await readLayerImageSequenceFrameBlob(
buildLayer({
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
height: 512,
},
],
}),
{
frameIndex: 1,
imageSrc: '/generated-editor-frames/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 512,
height: 512,
},
);
expect(blob).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-url?objectKey=generated%2Fframe-1.png',
),
expect.any(Object),
);
} finally {
globalThis.fetch = originalFetch;
}
});
});
function buildLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
@@ -207,6 +207,7 @@ export async function readLayerImageSequenceFrameBlob(
) {
return readAssetSourceBlob({
source: frame.imageSrc,
objectKey: frame.objectKey,
refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`,
});
}
@@ -300,49 +300,119 @@ describe('useImageCanvasAssetExportWorkflow', () => {
});
it('reports empty exports and supports direct layer image downloads', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url: string) => {
if (url.startsWith('/api/assets/read-url?')) {
return new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey: 'generated/private.png',
signedUrl:
'https://oss.example.com/generated/private.png?x-oss-signature=1',
expiresAt: '2026-06-20T00:00:00.000Z',
},
},
error: null,
meta: { apiVersion: '2026-06-16' },
}),
);
}
if (
url === 'https://oss.example.com/generated/private.png?x-oss-signature=1'
) {
return new Response(new Blob(['private'], { type: 'image/png' }));
}
if (url.startsWith('/generated/video.webm?token=1')) {
return new Response(new Blob(['video'], { type: 'video/webm' }));
}
return new Response(null, { status: 404 });
});
globalThis.fetch = fetchMock as typeof fetch;
let downloadedBlob: Blob | null = null;
let downloadName = '';
let downloadHref = '';
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: vi.fn((blob: Blob) => {
downloadedBlob = blob;
return 'blob:direct-layer-export';
}),
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: vi.fn(),
});
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
function click(this: HTMLAnchorElement) {
downloadName = this.download;
downloadHref = this.href;
},
);
render(
<ExportWorkflowHarness
layers={[createLayer('single-export', { title: '单图/导出:*?' })]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
expect(downloadName).toBe('单图 导出.png');
render(
<ExportWorkflowHarness
layers={[
createLayer('single-video-export', {
title: '视频导出',
src: '/generated/video.webm?token=1',
mediaType: 'video',
}),
]}
/>,
);
fireEvent.click(
screen.getAllByRole('button', { name: '导出单图' }).at(-1)!,
);
expect(downloadName).toBe('视频导出.webm');
render(<ExportWorkflowHarness layers={[]} />);
fireEvent.click(
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
);
await waitFor(() => {
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
'info:当前画布没有可导出的素材',
try {
render(
<ExportWorkflowHarness
layers={[
createLayer('single-export', {
title: '单图/导出:*?',
src: '/generated/private.png',
objectKey: 'generated/private.png',
}),
]}
/>,
);
});
fireEvent.click(screen.getByRole('button', { name: '导出单图' }));
await waitFor(() => {
expect(downloadName).toBe('单图 导出.png');
});
expect(downloadHref).toBe('blob:direct-layer-export');
expect(downloadedBlob).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining(
'/api/assets/read-url?objectKey=generated%2Fprivate.png',
),
expect.any(Object),
);
render(
<ExportWorkflowHarness
layers={[
createLayer('single-video-export', {
title: '视频导出',
src: '/generated/video.webm?token=1',
mediaType: 'video',
objectKey: null,
}),
]}
/>,
);
fireEvent.click(
screen.getAllByRole('button', { name: '导出单图' }).at(-1)!,
);
await waitFor(() => {
expect(downloadName).toBe('视频导出.webm');
});
render(<ExportWorkflowHarness layers={[]} />);
fireEvent.click(
screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!,
);
await waitFor(() => {
expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe(
'info:当前画布没有可导出的素材',
);
});
} finally {
globalThis.fetch = originalFetch;
delete (URL as unknown as { createObjectURL?: unknown }).createObjectURL;
delete (URL as unknown as { revokeObjectURL?: unknown }).revokeObjectURL;
}
});
it('downloads character animation layers as a zipped image sequence', async () => {
@@ -146,20 +146,30 @@ export function useImageCanvasAssetExportWorkflow({
});
return;
}
const link = document.createElement('a');
link.href = layer.src;
const extension = getLayerAssetExtensionFromTypeOrSrc(
layer.mediaType ?? 'image',
'',
layer.objectKey ?? layer.src,
);
link.download = `${sanitizeExportFilePart(
layer.title,
'canvas-layer',
)}.${extension}`;
document.body.appendChild(link);
link.click();
link.remove();
void readLayerAssetBlob(layer)
.then((blob) => {
const extension = getLayerAssetExtensionFromTypeOrSrc(
layer.mediaType ?? 'image',
blob.type,
layer.objectKey ?? layer.src,
);
const downloaded = triggerBrowserDownload(
blob,
`${sanitizeExportFilePart(layer.title, 'canvas-layer')}.${extension}`,
);
if (!downloaded) {
setAssetExportStatus({
tone: 'error',
message: '当前浏览器不支持素材下载',
});
}
})
.catch(() => {
setAssetExportStatus({
tone: 'error',
message: '素材导出失败',
});
});
}, []);
const exportCanvasAssets = useCallback(async () => {
@@ -2146,6 +2146,9 @@ export function PlatformEntryFlowShellImpl({
const acknowledgingExternalGenerationDialogKeysRef = useRef<Set<string>>(
new Set(),
);
const locallyAcknowledgedExternalGenerationJobIdsRef = useRef<Set<string>>(
new Set(),
);
const [
pendingPlatformTaskCompletionDialog,
setPendingPlatformTaskCompletionDialog,
@@ -4973,6 +4976,7 @@ export function PlatformEntryFlowShellImpl({
useEffect(() => {
acknowledgingExternalGenerationDialogKeysRef.current.clear();
locallyAcknowledgedExternalGenerationJobIdsRef.current.clear();
setExternalGenerationTasks([]);
setExternalGenerationTaskOverview(null);
}, [externalGenerationQueueOwnerKey]);
@@ -4998,7 +5002,13 @@ export function PlatformEntryFlowShellImpl({
})
.then((response) => {
if (!disposed) {
setExternalGenerationTasks(response.tasks);
const locallyAcknowledgedJobIds =
locallyAcknowledgedExternalGenerationJobIdsRef.current;
setExternalGenerationTasks(
response.tasks.filter(
(task) => !locallyAcknowledgedJobIds.has(task.jobId),
),
);
setExternalGenerationTaskOverview(response.overview);
}
})
@@ -5343,6 +5353,9 @@ export function PlatformEntryFlowShellImpl({
}
const acknowledgedJobIds = new Set(taskJobIds);
const locallyAcknowledgedJobIds =
locallyAcknowledgedExternalGenerationJobIdsRef.current;
taskJobIds.forEach((jobId) => locallyAcknowledgedJobIds.add(jobId));
setExternalGenerationTasks((current) =>
current.filter((task) => !acknowledgedJobIds.has(task.jobId)),
);
@@ -5372,6 +5385,11 @@ export function PlatformEntryFlowShellImpl({
if (dismissKey) {
acknowledgingKeys.delete(dismissKey);
}
taskJobIds.forEach((jobId) =>
locallyAcknowledgedExternalGenerationJobIdsRef.current.delete(
jobId,
),
);
});
},
[],
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { requestJson } from '../apiClient';
import { acknowledgeExternalGenerationTasks } from './externalGenerationClient';
vi.mock('../apiClient', () => ({
BACKGROUND_AUTH_REQUEST_OPTIONS: {
skipAuth: false,
},
requestJson: vi.fn(),
}));
const requestJsonMock = vi.mocked(requestJson);
describe('externalGenerationClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ acknowledgedTasks: [] });
});
it('posts acknowledge requests as JSON', async () => {
await acknowledgeExternalGenerationTasks({ jobIds: ['job-1'] });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/external-generation/jobs/acknowledge',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobIds: ['job-1'] }),
}),
'确认生成任务通知失败',
expect.any(Object),
);
});
});
@@ -83,6 +83,7 @@ export async function acknowledgeExternalGenerationTasks(
`${EXTERNAL_GENERATION_API_BASE}/jobs/acknowledge`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
},