Files
makelore/src/lib/image-workspace.ts
brother7 15b17775cb 完善 AI 设计资产预览与视频首帧选择
需求:生成图片需要支持大图查看和本地下载;制作视频时需要从当前项目作品选择首帧,或上传本地图片。

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

验证:相关 57 项单测、TypeScript 类型检查、目标 ESLint 和 Vite 生产构建全部通过。
2026-08-06 11:31:47 +08:00

264 lines
7.3 KiB
TypeScript

import { AppError } from '@/lib/error-model';
import {
createHostEventSource,
ensureHostApiToken,
getHostApiBase,
hostApiFetch,
} from '@/lib/host-api';
import {
IMAGE_WORKSPACE_API_PATH,
IMAGE_WORKSPACE_UNAVAILABLE_CODE,
IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
designAssetDownloadPath,
type DesignAsset,
type DesignAssetSaveResult,
type DesignGenerationTask,
type DesignWorkspace,
type DesignWorkspaceBootstrap,
} from '../../shared/image-workspace';
type ImageWorkspaceEnvelope<T> = {
success?: boolean;
status?: number;
code?: string;
error?: string;
data?: T;
};
export const IMAGE_WORKSPACE_CREATE_PROJECT_EVENT = 'niancode:image-workspace:create-project';
export class ImageWorkspaceApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = 'ImageWorkspaceApiError';
this.status = status;
this.code = code;
}
}
function createClientId(prefix: string): string {
const id = globalThis.crypto?.randomUUID?.()
?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${id}`;
}
export function createImageWorkspaceTurnId(): string {
return createClientId('turn');
}
function getErrorStatus(error: unknown): number {
if (error instanceof AppError && typeof error.details?.status === 'number') {
return error.details.status;
}
return 502;
}
function getErrorCode(error: unknown, status: number): string {
if (error instanceof AppError && typeof error.details?.backendCode === 'string') {
return error.details.backendCode;
}
if (status === 501) return IMAGE_WORKSPACE_UNAVAILABLE_CODE;
return 'IMAGE_WORKSPACE_REQUEST_FAILED';
}
async function requestData<T>(path: string, init: RequestInit = {}): Promise<T> {
let response: ImageWorkspaceEnvelope<T>;
try {
response = await hostApiFetch<ImageWorkspaceEnvelope<T>>(path, init);
} catch (error) {
const status = getErrorStatus(error);
throw new ImageWorkspaceApiError(
status,
getErrorCode(error, status),
error instanceof Error ? error.message : IMAGE_WORKSPACE_UNAVAILABLE_MESSAGE,
);
}
if (!response.success || response.data === undefined) {
throw new ImageWorkspaceApiError(
response.status ?? 502,
response.code ?? 'IMAGE_WORKSPACE_REQUEST_FAILED',
response.error ?? 'AI 设计请求失败',
);
}
return response.data;
}
export function fetchImageWorkspace(): Promise<DesignWorkspaceBootstrap> {
return requestData(IMAGE_WORKSPACE_API_PATH);
}
export function createImageWorkspaceProject(
title: string,
clientWorkspaceId = createClientId('workspace'),
): Promise<DesignWorkspace> {
return requestData(`${IMAGE_WORKSPACE_API_PATH}/workspaces`, {
method: 'POST',
body: JSON.stringify({
clientWorkspaceId,
title: title.trim(),
}),
});
}
export function renameImageWorkspaceProject(
workspaceId: string,
title: string,
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
{
method: 'PATCH',
body: JSON.stringify({ title: title.trim() }),
},
);
}
export function fetchImageWorkspaceProject(workspaceId: string): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}`,
);
}
export function sendImageWorkspaceMessage(
workspaceId: string,
expectedTurnRevision: number,
message: string,
clientTurnId = createImageWorkspaceTurnId(),
attachmentAssetIds: string[] = [],
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/messages`,
{
method: 'POST',
body: JSON.stringify({
clientTurnId,
expectedTurnRevision,
message: message.trim(),
attachmentAssetIds,
}),
},
);
}
export function confirmImageWorkspaceGeneration(
workspaceId: string,
expectedTurnRevision: number,
quoteId: string,
clientTurnId = createImageWorkspaceTurnId(),
): Promise<DesignWorkspace> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/quotes/${encodeURIComponent(quoteId)}/confirm`,
{
method: 'POST',
body: JSON.stringify({
clientTurnId,
expectedTurnRevision,
}),
},
);
}
export function fetchImageWorkspaceTasks(
workspaceId: string,
): Promise<DesignGenerationTask[]> {
return requestData(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/tasks`,
);
}
export async function openImageWorkspaceTaskEvents(workspaceId: string): Promise<EventSource> {
await ensureHostApiToken();
return createHostEventSource(
`${IMAGE_WORKSPACE_API_PATH}/workspaces/${encodeURIComponent(workspaceId)}/events`,
);
}
export async function resolveImageWorkspaceAssetUrl(contentPath: string): Promise<string> {
const token = await ensureHostApiToken();
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),
}),
},
);
}