From f73f1026cfef4bc2af5362c09e43186edd67b02b Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 24 Jun 2026 20:35:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=B8=8E=E7=B4=A0=E6=9D=90=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐外部生成确认请求的 JSON Content-Type 本地屏蔽已确认终态任务避免轮询重复弹窗 单素材导出改为读取 Blob 后触发浏览器下载 序列帧导出按 frame objectKey 换签读取 --- .../ImageCanvasExportModel.test.ts | 65 ++++++++ .../image-editor/ImageCanvasExportModel.ts | 1 + ...useImageCanvasAssetExportWorkflow.test.tsx | 140 +++++++++++++----- .../useImageCanvasAssetExportWorkflow.ts | 38 +++-- .../PlatformEntryFlowShellImpl.tsx | 20 ++- .../externalGenerationClient.test.ts | 35 +++++ .../externalGenerationClient.ts | 1 + 7 files changed, 250 insertions(+), 50 deletions(-) create mode 100644 src/services/external-generation/externalGenerationClient.test.ts diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 86351dc8a..9d523b5c5 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -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 { diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 19105cf80..7da3b3a1e 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -207,6 +207,7 @@ export async function readLayerImageSequenceFrameBlob( ) { return readAssetSourceBlob({ source: frame.imageSrc, + objectKey: frame.objectKey, refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, }); } diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index d69956dd6..a32b73de4 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -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( - , - ); - fireEvent.click(screen.getByRole('button', { name: '导出单图' })); - - expect(downloadName).toBe('单图 导出.png'); - - render( - , - ); - fireEvent.click( - screen.getAllByRole('button', { name: '导出单图' }).at(-1)!, - ); - - expect(downloadName).toBe('视频导出.webm'); - - render(); - fireEvent.click( - screen.getAllByRole('button', { name: '导出画布素材' }).at(-1)!, - ); - - await waitFor(() => { - expect(screen.getAllByTestId('status').at(-1)?.textContent).toBe( - 'info:当前画布没有可导出的素材', + try { + render( + , ); - }); + + 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( + , + ); + fireEvent.click( + screen.getAllByRole('button', { name: '导出单图' }).at(-1)!, + ); + + await waitFor(() => { + expect(downloadName).toBe('视频导出.webm'); + }); + + render(); + 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 () => { diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index 0d2699452..533089caa 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -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 () => { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 34d2e3815..75a224df3 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -2146,6 +2146,9 @@ export function PlatformEntryFlowShellImpl({ const acknowledgingExternalGenerationDialogKeysRef = useRef>( new Set(), ); + const locallyAcknowledgedExternalGenerationJobIdsRef = useRef>( + 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, + ), + ); }); }, [], diff --git a/src/services/external-generation/externalGenerationClient.test.ts b/src/services/external-generation/externalGenerationClient.test.ts new file mode 100644 index 000000000..7f959da8e --- /dev/null +++ b/src/services/external-generation/externalGenerationClient.test.ts @@ -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), + ); + }); +}); diff --git a/src/services/external-generation/externalGenerationClient.ts b/src/services/external-generation/externalGenerationClient.ts index 9ca396409..8a52cff6a 100644 --- a/src/services/external-generation/externalGenerationClient.ts +++ b/src/services/external-generation/externalGenerationClient.ts @@ -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, },