修复生成确认与素材导出
补齐外部生成确认请求的 JSON Content-Type 本地屏蔽已确认终态任务避免轮询重复弹窗 单素材导出改为读取 Blob 后触发浏览器下载 序列帧导出按 frame objectKey 换签读取
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user