完善 AI 设计资产预览与视频首帧选择
需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。 实现:新增首帧选择弹窗、JPEG/PNG/WebP 有界上传、附件 Asset ID 透传、Main 到服务端 multipart 转发,并保留上传失败重试与私有图片下载链路。 验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssetUploadInput,
|
||||
DesignCapabilities,
|
||||
DesignConfirmGenerationInput,
|
||||
DesignCreateWorkspaceInput,
|
||||
@@ -41,6 +43,7 @@ export interface DesignWorkspaceModule {
|
||||
submitMessage(input: DesignSubmitMessageInput): Promise<DesignWorkspace>;
|
||||
confirmGeneration(input: DesignConfirmGenerationInput): Promise<DesignWorkspace>;
|
||||
listTasks(workspaceId: string): Promise<DesignGenerationTask[]>;
|
||||
uploadAsset?(input: DesignAssetUploadInput): Promise<DesignAsset>;
|
||||
openWorkspaceEvents?(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignAssetUploadInput,
|
||||
DesignAssistantDeltaEvent,
|
||||
DesignBrief,
|
||||
DesignCapabilities,
|
||||
@@ -266,22 +267,26 @@ function mapTask(task: ServerTask): DesignGenerationTask {
|
||||
quoteId: task.quote_id,
|
||||
quotedDesignPoints: task.quoted_design_points,
|
||||
failureCode: task.failure_code,
|
||||
resultAssets: task.result_assets.map((asset) => ({
|
||||
assetId: asset.asset_id,
|
||||
workspaceId: task.workspace_id,
|
||||
mediaType: asset.media_type,
|
||||
mimeType: asset.mime_type,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
durationMilliseconds: asset.duration_milliseconds,
|
||||
createdAt: asset.created_at,
|
||||
contentPath: designAssetContentPath(task.workspace_id, asset.asset_id),
|
||||
})),
|
||||
resultAssets: task.result_assets.map((asset) => mapAsset(task.workspace_id, asset)),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAsset(workspaceId: string, asset: ServerAsset): DesignAsset {
|
||||
return {
|
||||
assetId: asset.asset_id,
|
||||
workspaceId,
|
||||
mediaType: asset.media_type,
|
||||
mimeType: asset.mime_type,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
durationMilliseconds: asset.duration_milliseconds,
|
||||
createdAt: asset.created_at,
|
||||
contentPath: designAssetContentPath(workspaceId, asset.asset_id),
|
||||
};
|
||||
}
|
||||
|
||||
function createClientId(prefix: string): string {
|
||||
const id = globalThis.crypto?.randomUUID?.()
|
||||
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
@@ -913,6 +918,20 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
return tasks.map(mapTask);
|
||||
}
|
||||
|
||||
async uploadAsset(input: DesignAssetUploadInput): Promise<DesignAsset> {
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
'file',
|
||||
new Blob([Uint8Array.from(input.bytes)], { type: input.mimeType }),
|
||||
input.fileName,
|
||||
);
|
||||
const asset = await this.requestJson<ServerAsset>(
|
||||
`/api/design/workspaces/${encodeURIComponent(input.workspaceId)}/assets`,
|
||||
{ method: 'POST', body: form },
|
||||
);
|
||||
return mapAsset(input.workspaceId, asset);
|
||||
}
|
||||
|
||||
async openWorkspaceEvents(
|
||||
input: DesignWorkspaceEventSubscriptionInput,
|
||||
): Promise<DesignWorkspaceEventSubscription> {
|
||||
@@ -1141,7 +1160,9 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(init.body && !(init.body instanceof FormData)
|
||||
? { 'Content-Type': 'application/json' }
|
||||
: {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user