完善 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,10 +1,16 @@
import { createWriteStream } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { basename, dirname, extname, join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { app, dialog, type SaveDialogOptions } from 'electron';
import {
IMAGE_WORKSPACE_API_PATH,
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
type DesignAssetUploadInput,
type DesignConfirmGenerationInput,
type DesignCreateWorkspaceInput,
type DesignRenameWorkspaceInput,
@@ -43,10 +49,172 @@ function asStringArray(value: unknown): string[] {
: [];
}
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
const MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES = (
Math.ceil(MAX_DESIGN_IMAGE_UPLOAD_BYTES / 3) * 4 + 16 * 1024
);
async function parseAssetUploadBody(req: IncomingMessage): Promise<Record<string, unknown>> {
const declaredLength = Number(req.headers['content-length']);
if (Number.isFinite(declaredLength) && declaredLength > MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES) {
throw new DesignWorkspaceModuleError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
const chunks: Buffer[] = [];
let receivedBytes = 0;
for await (const chunk of req) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
receivedBytes += bytes.length;
if (receivedBytes > MAX_DESIGN_IMAGE_UPLOAD_BODY_BYTES) {
throw new DesignWorkspaceModuleError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
chunks.push(bytes);
}
try {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error();
return body as Record<string, unknown>;
} catch {
throw new DesignWorkspaceModuleError(
400,
'design_asset_upload_invalid',
'上传图片请求无效',
);
}
}
function decodeAssetUpload(
workspaceId: string,
body: Record<string, unknown>,
): DesignAssetUploadInput {
const fileName = basename(asString(body.fileName).trim().replace(/\\/g, '/')).slice(0, 160);
const mimeType = asString(body.mimeType);
const encoded = asString(body.dataBase64);
if (!fileName || !DESIGN_IMAGE_UPLOAD_MIME_TYPES.has(mimeType)) {
throw new DesignWorkspaceModuleError(
400,
'design_asset_upload_invalid',
'请选择 JPEG、PNG 或 WebP 图片',
);
}
if (!encoded
|| encoded.length % 4 !== 0
|| encoded.length > Math.ceil(MAX_DESIGN_IMAGE_UPLOAD_BYTES / 3) * 4
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
throw new DesignWorkspaceModuleError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
const bytes = Buffer.from(encoded, 'base64');
if (bytes.length <= 0 || bytes.length > MAX_DESIGN_IMAGE_UPLOAD_BYTES) {
throw new DesignWorkspaceModuleError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
return {
workspaceId,
fileName,
mimeType: mimeType as DesignAssetUploadInput['mimeType'],
bytes,
};
}
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']);
function safeImageFileName(value: string, assetId: string): string {
const fallbackId = assetId
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32) || 'image';
const fallback = `Makelore-AI-Design-${fallbackId}.png`;
const sanitized = [...value.trim().replace(/[<>:"/\\|?*]/g, '-')]
.map((character) => character.charCodeAt(0) < 32 ? '-' : character)
.join('')
.replace(/[. ]+$/g, '')
.slice(0, 120);
if (!sanitized || !IMAGE_FILE_EXTENSIONS.has(extname(sanitized).toLowerCase())) {
return fallback;
}
return sanitized;
}
async function saveAssetContent(
res: ServerResponse,
ctx: HostApiContext,
workspaceId: string,
assetId: string,
defaultFileName: string,
): Promise<void> {
const fileName = safeImageFileName(defaultFileName, assetId);
const extension = extname(fileName).slice(1);
const options: SaveDialogOptions = {
defaultPath: join(app.getPath('downloads'), fileName),
filters: [
{ name: '图片', extensions: [extension] },
{ name: '所有文件', extensions: ['*'] },
],
};
const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed()
? ctx.mainWindow
: null;
const selection = mainWindow
? await dialog.showSaveDialog(mainWindow, options)
: await dialog.showSaveDialog(options);
if (selection.canceled || !selection.filePath) {
sendData(res, { status: 'cancelled' });
return;
}
const content = await ctx.imageWorkspace!.openAssetContent(workspaceId, assetId);
if (!content.ok || !content.body || !content.headers.get('content-type')?.startsWith('image/')) {
throw new DesignWorkspaceModuleError(
content.status >= 400 ? content.status : 502,
'DESIGN_ASSET_DOWNLOAD_FAILED',
'生成图片暂时无法下载,请稍后重试',
);
}
const temporaryPath = join(
dirname(selection.filePath),
`.${basename(selection.filePath)}.${randomUUID()}.download`,
);
try {
await pipeline(
Readable.fromWeb(content.body),
createWriteStream(temporaryPath, { flags: 'wx' }),
);
await rename(temporaryPath, selection.filePath);
} catch {
await rm(temporaryPath, { force: true }).catch(() => undefined);
throw new DesignWorkspaceModuleError(
500,
'DESIGN_ASSET_SAVE_FAILED',
'图片保存失败,请重新选择位置后重试',
);
}
sendData(res, { status: 'saved' });
}
function sendRouteError(res: ServerResponse, error: unknown): void {
if (res.headersSent) {
res.destroy(error instanceof Error ? error : undefined);
@@ -253,6 +421,25 @@ export async function handleImageWorkspaceRoutes(
return true;
}
if (segments.length === 3
&& segments[0] === 'workspaces'
&& segments[2] === 'assets'
&& req.method === 'POST') {
if (!ctx.imageWorkspace.uploadAsset) {
throw new DesignWorkspaceModuleError(
501,
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
'当前环境暂不支持上传设计图片',
);
}
const body = await parseAssetUploadBody(req);
sendData(
res,
await ctx.imageWorkspace.uploadAsset(decodeAssetUpload(segments[1], body)),
);
return true;
}
if (segments.length === 3
&& segments[0] === 'workspaces'
&& segments[2] === 'events'
@@ -286,6 +473,22 @@ export async function handleImageWorkspaceRoutes(
return true;
}
if (segments.length === 5
&& segments[0] === 'workspaces'
&& segments[2] === 'assets'
&& segments[4] === 'download'
&& req.method === 'POST') {
const body = await parseJsonBody<Record<string, unknown>>(req);
await saveAssetContent(
res,
ctx,
segments[1],
segments[3],
asString(body.defaultFileName),
);
return true;
}
sendJson(res, 404, {
success: false,
status: 404,