完善 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

@@ -9,6 +9,9 @@ import {
IMAGE_WORKSPACE_API_PATH,
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
designAssetDownloadPath,
type DesignAsset,
type DesignAssetSaveResult,
type DesignGenerationTask,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
@@ -125,6 +128,7 @@ export function sendImageWorkspaceMessage(
expectedTurnRevision: number,
message: string,
clientTurnId = createImageWorkspaceTurnId(),
attachmentAssetIds: string[] = [],
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
@@ -134,7 +138,7 @@ export function sendImageWorkspaceMessage(
clientTurnId,
expectedTurnRevision,
message: message.trim(),
attachmentAssetIds: [],
attachmentAssetIds,
}),
},
);
@@ -178,3 +182,82 @@ export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promis
const separator = contentPath.includes('?') ? '&' : '?';
return `${getHostApiBase()}${contentPath}${separator}token=${encodeURIComponent(token)}`;
}
const IMAGE_FILE_EXTENSIONS: Record<string, string> = {
'image/gif': 'gif',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/svg+xml': 'svg',
'image/webp': 'webp',
};
function assetDownloadFileName(asset: DesignAsset): string {
const safeAssetId = asset.assetId
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32) || 'image';
const extension = IMAGE_FILE_EXTENSIONS[asset.mimeType.toLowerCase()] ?? 'png';
return `Makelore-AI-Design-${safeAssetId}.${extension}`;
}
export function saveImageWorkspaceAsset(asset: DesignAsset): Promise<DesignAssetSaveResult> {
return requestData(
designAssetDownloadPath(asset.workspaceId, asset.assetId),
{
method: 'POST',
body: JSON.stringify({ defaultFileName: assetDownloadFileName(asset) }),
},
);
}
const DESIGN_IMAGE_UPLOAD_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_DESIGN_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
async function fileToBase64(file: File): Promise<string> {
const bytes = new Uint8Array(await file.arrayBuffer());
let encoded = '';
for (let offset = 0; offset < bytes.length; offset += 48 * 1024) {
const chunk = bytes.subarray(offset, offset + 48 * 1024);
let binary = '';
for (let index = 0; index < chunk.length; index += 0x8000) {
binary += String.fromCharCode(...chunk.subarray(index, index + 0x8000));
}
encoded += btoa(binary);
}
return encoded;
}
export async function uploadImageWorkspaceAsset(
workspaceId: string,
file: File,
): Promise<DesignAsset> {
if (!DESIGN_IMAGE_UPLOAD_MIME_TYPES.has(file.type)) {
throw new ImageWorkspaceApiError(
400,
'design_asset_upload_type_invalid',
'请选择 JPEG、PNG 或 WebP 图片',
);
}
if (file.size <= 0 || file.size > MAX_DESIGN_IMAGE_UPLOAD_BYTES) {
throw new ImageWorkspaceApiError(
413,
'design_asset_upload_too_large',
'图片大小需在 10 MB 以内',
);
}
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/assets`,
{
method: 'POST',
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
dataBase64: await fileToBase64(file),
}),
},
);
}