完善 AI 设计资产预览与视频首帧选择

需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。

实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。

验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
2026-08-06 11:31:47 +08:00
parent 2a3c9850d1
commit 15b17775cb
11 changed files with 1149 additions and 31 deletions

View File

@@ -1,9 +1,22 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleImageWorkspaceRoutes } from '@electron/api/routes/image-workspace';
import type { HostApiContext } from '@electron/api/context';
const electronMocks = vi.hoisted(() => ({
getPath: vi.fn(),
showSaveDialog: vi.fn(),
}));
vi.mock('electron', () => ({
app: { getPath: electronMocks.getPath },
dialog: { showSaveDialog: electronMocks.showSaveDialog },
}));
function createResponse() {
const chunks: string[] = [];
const res = {
@@ -68,6 +81,21 @@ const bootstrap = {
};
describe('AI design Main route boundary', () => {
let temporaryDirectory: string | null = null;
beforeEach(() => {
electronMocks.getPath.mockReset();
electronMocks.getPath.mockReturnValue(tmpdir());
electronMocks.showSaveDialog.mockReset();
});
afterEach(async () => {
if (temporaryDirectory) {
await rm(temporaryDirectory, { recursive: true, force: true });
temporaryDirectory = null;
}
});
it('does not claim unrelated routes', async () => {
const response = createResponse();
const handled = await handleImageWorkspaceRoutes(
@@ -107,6 +135,7 @@ describe('AI design Main route boundary', () => {
submitMessage: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
confirmGeneration: vi.fn().mockResolvedValue({ workspaceId: 'workspace/one' }),
listTasks: vi.fn().mockResolvedValue([]),
uploadAsset: vi.fn().mockResolvedValue({ assetId: 'asset-uploaded' }),
openAssetContent: vi.fn(),
getCapabilities: vi.fn(),
reset: vi.fn().mockResolvedValue(bootstrap),
@@ -160,6 +189,30 @@ describe('AI design Main route boundary', () => {
attachmentAssetIds: [],
});
const uploadResponse = createResponse();
await handleImageWorkspaceRoutes(
createRequest('POST', {
fileName: 'poster.webp',
mimeType: 'image/webp',
dataBase64: 'AQID',
}),
uploadResponse.res,
new URL(
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets',
),
ctx,
);
expect(workspace.uploadAsset).toHaveBeenCalledWith({
workspaceId: 'workspace/one',
fileName: 'poster.webp',
mimeType: 'image/webp',
bytes: Buffer.from([1, 2, 3]),
});
expect(uploadResponse.json()).toMatchObject({
success: true,
data: { assetId: 'asset-uploaded' },
});
const confirmResponse = createResponse();
await handleImageWorkspaceRoutes(
createRequest('POST', {
@@ -216,6 +269,116 @@ describe('AI design Main route boundary', () => {
expect(Buffer.concat(response.chunks).toString()).toBe('partial');
});
it('streams a private image asset into the native save location', async () => {
temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-design-download-'));
const savedPath = join(temporaryDirectory, 'design.png');
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
const openAssetContent = vi.fn().mockResolvedValue(new Response('image-bytes', {
status: 200,
headers: { 'Content-Type': 'image/png' },
}));
const response = createResponse();
await handleImageWorkspaceRoutes(
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
response.res,
new URL(
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace%2Fone/assets/asset%2Fone/download',
),
{ imageWorkspace: { openAssetContent } } as unknown as HostApiContext,
);
expect(electronMocks.showSaveDialog).toHaveBeenCalledWith(expect.objectContaining({
defaultPath: expect.stringContaining('Ocean poster.png'),
}));
expect(openAssetContent).toHaveBeenCalledWith('workspace/one', 'asset/one');
expect(response.json()).toMatchObject({
success: true,
data: { status: 'saved' },
});
await expect(readFile(savedPath, 'utf8')).resolves.toBe('image-bytes');
});
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');
await writeFile(savedPath, 'original');
electronMocks.showSaveDialog.mockResolvedValue({ canceled: false, filePath: savedPath });
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('partial'));
controller.error(new Error('connection lost'));
},
});
const response = createResponse();
await handleImageWorkspaceRoutes(
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
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(body, {
status: 200,
headers: { 'Content-Type': 'image/png' },
})),
},
} as unknown as HostApiContext,
);
expect(response.json()).toMatchObject({
success: false,
code: 'DESIGN_ASSET_SAVE_FAILED',
});
await expect(readFile(savedPath, 'utf8')).resolves.toBe('original');
});
it('does not fetch private bytes when the native save dialog is cancelled', async () => {
electronMocks.showSaveDialog.mockResolvedValue({ canceled: true });
const openAssetContent = vi.fn();
const response = createResponse();
await handleImageWorkspaceRoutes(
createRequest('POST', { defaultFileName: 'Ocean poster.png' }),
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(openAssetContent).not.toHaveBeenCalled();
expect(response.json()).toMatchObject({
success: true,
data: { status: 'cancelled' },
});
});
it('rejects an oversized image upload before reading it into the Main process', async () => {
const uploadAsset = vi.fn();
const request = createRequest('POST', {});
request.headers['content-length'] = String(14 * 1024 * 1024);
const response = createResponse();
await handleImageWorkspaceRoutes(
request,
response.res,
new URL(
'http://127.0.0.1/api/works/image-workspace/workspaces/workspace-one/assets',
),
{ imageWorkspace: { uploadAsset } } as unknown as HostApiContext,
);
expect(response.statusCode).toBe(413);
expect(response.json()).toMatchObject({
success: false,
code: 'design_asset_upload_too_large',
});
expect(uploadAsset).not.toHaveBeenCalled();
});
it('relays normalized task events over local SSE and forwards the opaque resume cursor', async () => {
const close = vi.fn();
const openWorkspaceEvents = vi.fn().mockResolvedValue({