From 43e8a6051ab6e49a92f3b5233767851375bae887 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 18 Aug 2026 02:31:17 +0800 Subject: [PATCH] fix: preserve video extensions on AI design downloads --- electron/api/routes/image-workspace.ts | 29 +++++++--- tests/unit/image-workspace-route.test.ts | 73 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index eda9fe4..7370b25 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -160,9 +160,19 @@ function sendData(res: ServerResponse, data: unknown): void { sendJson(res, 200, { success: true, status: 200, data }); } -const IMAGE_FILE_EXTENSIONS = new Set(['.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']); +const ASSET_FILE_EXTENSIONS = new Set([ + '.gif', + '.jpeg', + '.jpg', + '.mov', + '.mp4', + '.png', + '.svg', + '.webm', + '.webp', +]); -function safeImageFileName(value: string, assetId: string): string { +function safeAssetFileName(value: string, assetId: string): string { const fallbackId = assetId .replace(/[^a-zA-Z0-9_-]+/g, '-') .replace(/^-+|-+$/g, '') @@ -173,7 +183,7 @@ function safeImageFileName(value: string, assetId: string): string { .join('') .replace(/[. ]+$/g, '') .slice(0, 120); - if (!sanitized || !IMAGE_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) { + if (!sanitized || !ASSET_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) { return fallback; } return sanitized; @@ -186,12 +196,12 @@ async function saveAssetContent( assetId: string, defaultFileName: string, ): Promise { - const fileName = safeImageFileName(defaultFileName, assetId); + const fileName = safeAssetFileName(defaultFileName, assetId); const extension = extname(fileName).slice(1); const options: SaveDialogOptions = { defaultPath: join(app.getPath('downloads'), fileName), filters: [ - { name: '图片', extensions: [extension] }, + { name: '设计资产', extensions: [extension] }, { name: '所有文件', extensions: ['*'] }, ], }; @@ -207,11 +217,14 @@ async function saveAssetContent( } const content = await ctx.imageWorkspace!.openAssetContent(workspaceId, assetId); - if (!content.ok || !content.body || !content.headers.get('content-type')?.startsWith('image/')) { + const contentType = content.headers.get('content-type'); + if (!content.ok + || !content.body + || (!contentType?.startsWith('image/') && !contentType?.startsWith('video/'))) { throw new DesignWorkspaceModuleError( content.status >= 400 ? content.status : 502, 'DESIGN_ASSET_DOWNLOAD_FAILED', - '生成图片暂时无法下载,请稍后重试', + '生成资产暂时无法下载,请稍后重试', ); } @@ -230,7 +243,7 @@ async function saveAssetContent( throw new DesignWorkspaceModuleError( 500, 'DESIGN_ASSET_SAVE_FAILED', - '图片保存失败,请重新选择位置后重试', + '资产保存失败,请重新选择位置后重试', ); } sendData(res, { status: 'saved' }); diff --git a/tests/unit/image-workspace-route.test.ts b/tests/unit/image-workspace-route.test.ts index ba037a7..714be93 100644 --- a/tests/unit/image-workspace-route.test.ts +++ b/tests/unit/image-workspace-route.test.ts @@ -427,6 +427,79 @@ describe('AI design Main route boundary', () => { await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes'); }); + it.each([ + ['mp4', 'video/mp4'], + ['mov', 'video/quicktime'], + ['webm', 'video/webm'], + ])('streams a private %s asset into a video save location', async (extension, mimeType) => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-')); + const savedPath = join(temporaryDirectory, `animation.${extension}`); + electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath }); + const expectedBytes = `video-${extension}-bytes`; + const content = new Response(expectedBytes, { + status: 200, + headers: { 'Content-Type': mimeType }, + }); + const getContentType = vi.spyOn(content.headers, 'get'); + const openAssetContent = vi.fn().mockResolvedValue(content); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { defaultFileName: `Ocean animation.${extension}` }), + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download', + ), + { imageWorkspace: { openAssetContent } } as unknown as HostApiContext, + ); + + expect(electronMocks.showSaveDialog).toHaveBeenCalledWith(expect.objectContaining({ + defaultPath: expect.stringContaining(`Ocean animation.${extension}`), + filters: [ + { name: '设计资产', extensions: [extension] }, + { name: '所有文件', extensions: ['*'] }, + ], + })); + expect(getContentType).toHaveBeenCalledWith('content-type'); + expect(content.headers.get('content-type')).toBe(mimeType); + expect(openAssetContent).toHaveBeenCalledWith('workspace-one', 'asset-one'); + expect(response.json()).toMatchObject({ + success: true, + data: { status: 'saved' }, + }); + await expect(readFile(savedPath, 'utf8')).resolves.toBe(expectedBytes); + }); + + it('rejects a private download response that is not an image or video', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-')); + const savedPath = join(temporaryDirectory, 'payload.mp4'); + electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath }); + const response = createResponse(); + + await handleImageWorkspaceRoutes( + createRequest('POST', { defaultFileName: 'payload.mp4' }), + response.res, + new URL( + 'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets/asset-one/download', + ), + { + imageWorkspace: { + openAssetContent: vi.fn().mockResolvedValue(new Response('untrusted', { + status: 200, + headers: { 'Content-Type': 'application/octet-stream' }, + })), + }, + } as unknown as HostApiContext, + ); + + expect(response.json()).toMatchObject({ + success: false, + code: 'DESIGN_ASSET_DOWNLOAD_FAILED', + error: '生成资产暂时无法下载,请稍后重试', + }); + await expect(readFile(savedPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + it('preserves an existing destination when the private image stream fails', async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-')); const savedPath = join(temporaryDirectory, 'existing.png');