1206 lines
35 KiB
TypeScript
1206 lines
35 KiB
TypeScript
// 画布服务层
|
||
import type {
|
||
CanvasProject,
|
||
CanvasProjectListItem,
|
||
CanvasState,
|
||
CanvasNode,
|
||
CanvasWorkspaceType,
|
||
} from '@/types/canvas';
|
||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from '@/types/ai';
|
||
import { fetchUploadWithRetry } from '@/lib/client/upload-with-retry';
|
||
import { requestLongTaskResult } from '@/lib/client/long-task';
|
||
import {
|
||
buildCanvasSaveRequestBody,
|
||
type CanvasSaveRevisionOptions,
|
||
} from '@/lib/canvas/canvas-save-request';
|
||
import type {
|
||
EnhanceProKreaModel,
|
||
EnhanceProOutputFormat,
|
||
EnhanceProSubjectDetection,
|
||
} from '@/lib/enhance-pro/config';
|
||
import { createLogger } from '@/lib/observability/logger';
|
||
import { useCanvasStore } from '@/store/canvas';
|
||
|
||
const log = createLogger("Canvas");
|
||
|
||
function logCatch(
|
||
err: unknown,
|
||
context: string,
|
||
extra?: Record<string, unknown>
|
||
): void {
|
||
const error = err instanceof Error ? err : new Error(String(err));
|
||
log.error(`${context} failed`, error, extra);
|
||
}
|
||
|
||
function getCanvasSaveRevisionFallback(): CanvasSaveRevisionOptions {
|
||
if (typeof window === "undefined") return {};
|
||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||
return {
|
||
baseCanvasRevision: canvasRevision,
|
||
baseCanvasUpdatedAt: canvasUpdatedAt,
|
||
};
|
||
}
|
||
|
||
// API 响应类型
|
||
interface ApiResponse<T = any> {
|
||
success: boolean;
|
||
data?: T;
|
||
error?: string;
|
||
status?: number;
|
||
pagination?: {
|
||
page: number;
|
||
limit: number;
|
||
hasMore: boolean;
|
||
};
|
||
}
|
||
|
||
export interface NormalizedCropRect {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export interface CanvasTextDetectItem {
|
||
id: string;
|
||
text: string;
|
||
confidence?: number;
|
||
}
|
||
|
||
export interface CanvasTextEditPayloadItem {
|
||
id: string;
|
||
originalText: string;
|
||
editedText?: string;
|
||
textColor?: string;
|
||
action?: 'replace' | 'delete';
|
||
}
|
||
|
||
export interface CanvasTextSuggestionItem {
|
||
text: string;
|
||
reason?: string;
|
||
}
|
||
|
||
export type WorkflowAnalysisMode =
|
||
| "video-analysis"
|
||
| "character-scene-extraction"
|
||
| "agent-plan";
|
||
|
||
export interface EnhanceProImageResult {
|
||
originalUrl: string;
|
||
thumbnailUrl: string;
|
||
width?: number;
|
||
height?: number;
|
||
}
|
||
|
||
export interface EnhanceProRequestParams {
|
||
imageUrl?: string;
|
||
image_base64?: string;
|
||
mimeType?: string;
|
||
prompt?: string;
|
||
chatId?: string | null;
|
||
messageId?: string | null;
|
||
clientRequestId?: string | null;
|
||
width: number;
|
||
height: number;
|
||
batchSize?: number;
|
||
seed?: number;
|
||
output_format?: EnhanceProOutputFormat;
|
||
subject_detection?: EnhanceProSubjectDetection;
|
||
face_enhancement?: boolean;
|
||
face_enhancement_creativity?: number;
|
||
face_enhancement_strength?: number;
|
||
crop_to_fill?: boolean;
|
||
model?: EnhanceProKreaModel;
|
||
upscaling_activated?: boolean;
|
||
image_scaling_factor?: number;
|
||
creativity?: number;
|
||
texture?: number;
|
||
sharpen?: number;
|
||
denoise?: number;
|
||
detail?: number;
|
||
}
|
||
|
||
// 画布服务
|
||
export const canvasService = {
|
||
// 获取画布列表
|
||
async getProjects(
|
||
workspaceType: CanvasWorkspaceType = 'canvas',
|
||
options: { page?: number; limit?: number } = {}
|
||
): Promise<ApiResponse<CanvasProjectListItem[]>> {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (workspaceType && workspaceType !== 'canvas') {
|
||
params.set('workspaceType', workspaceType);
|
||
}
|
||
params.set('page', String(Math.max(1, Math.round(options.page || 1))));
|
||
params.set('limit', String(Math.max(1, Math.round(options.limit || 24))));
|
||
const response = await fetch(`/api/canvas?${params.toString()}`);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '获取画布列表失败' };
|
||
}
|
||
},
|
||
|
||
// 获取单个画布
|
||
async getProject(id: string): Promise<ApiResponse<CanvasProject>> {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}`);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '获取画布失败' };
|
||
}
|
||
},
|
||
|
||
// 创建画布
|
||
async createProject(
|
||
project: Partial<CanvasProject>,
|
||
workspaceType: CanvasWorkspaceType = 'canvas'
|
||
): Promise<ApiResponse<CanvasProject>> {
|
||
try {
|
||
const response = await fetch('/api/canvas', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...project, workspaceType }),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '创建画布失败' };
|
||
}
|
||
},
|
||
|
||
// 更新画布
|
||
async updateProject(
|
||
id: string,
|
||
updates: Partial<CanvasProject>,
|
||
workspaceType?: CanvasWorkspaceType
|
||
): Promise<ApiResponse<CanvasProject>> {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(
|
||
workspaceType ? { ...updates, workspaceType } : updates
|
||
),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '更新画布失败' };
|
||
}
|
||
},
|
||
|
||
// 删除画布
|
||
async deleteProject(id: string): Promise<ApiResponse> {
|
||
try {
|
||
const response = await fetch(`/api/canvas/${id}`, {
|
||
method: 'DELETE',
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '删除画布失败' };
|
||
}
|
||
},
|
||
|
||
// 保存画布状态
|
||
async saveCanvasState(
|
||
id: string,
|
||
canvasData: CanvasState,
|
||
workspaceType?: CanvasWorkspaceType,
|
||
options?: CanvasSaveRevisionOptions
|
||
): Promise<ApiResponse> {
|
||
try {
|
||
const requestBody = JSON.stringify(
|
||
buildCanvasSaveRequestBody({
|
||
canvasData,
|
||
workspaceType,
|
||
options,
|
||
fallbackOptions: getCanvasSaveRevisionFallback(),
|
||
})
|
||
);
|
||
const doSave = async () =>
|
||
fetch(`/api/canvas/${id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: requestBody,
|
||
});
|
||
|
||
let response = await doSave();
|
||
if (response.status === 429) {
|
||
const retryPayload = await response.json().catch(() => ({}));
|
||
const retryAfter = Number(retryPayload?.retryAfter || 0);
|
||
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 800;
|
||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||
response = await doSave();
|
||
}
|
||
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
return {
|
||
success: false,
|
||
error: String(data?.error || data?.message || '保存画布状态失败'),
|
||
status: response.status,
|
||
};
|
||
}
|
||
return data as ApiResponse;
|
||
} catch (error) {
|
||
return { success: false, error: '保存画布状态失败' };
|
||
}
|
||
},
|
||
|
||
// 自动保存(防抖)
|
||
createAutoSave(projectId: string, delay = 2000) {
|
||
let timeoutId: NodeJS.Timeout | null = null;
|
||
|
||
return (canvasData: CanvasState) => {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
|
||
timeoutId = setTimeout(async () => {
|
||
await this.saveCanvasState(projectId, canvasData);
|
||
}, delay);
|
||
};
|
||
},
|
||
};
|
||
|
||
// 图片生成服务
|
||
export const imageGenerationService = {
|
||
// 上传图片到存储桶
|
||
async uploadImage(file: File): Promise<ApiResponse<{ url: string; pathname: string; assetId?: string }>> {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('purpose', 'canvas-upload');
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
return { success: false, error: errorData.error || '上传失败' };
|
||
}
|
||
|
||
const data = await response.json();
|
||
return {
|
||
success: true,
|
||
data: {
|
||
url: data.url,
|
||
pathname: data.pathname,
|
||
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
|
||
}
|
||
};
|
||
} catch (error) {
|
||
logCatch(error, "image_upload");
|
||
return { success: false, error: '图片上传失败' };
|
||
}
|
||
},
|
||
|
||
// AI 生成图片
|
||
async generateImage(params: {
|
||
prompt: string;
|
||
negativePrompt?: string;
|
||
model?: string;
|
||
aspectRatio?: string;
|
||
imageSize?: string;
|
||
imageCount?: number;
|
||
promptExtend?: boolean;
|
||
referenceImage?: string;
|
||
referenceImages?: string[];
|
||
billingFeature?: 'multi-view' | 'repaint';
|
||
async?: boolean;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
images: Array<{ thumbnailUrl: string; originalUrl: string }>;
|
||
model?: string;
|
||
prompt?: string;
|
||
queueStatus?: unknown;
|
||
status?: string;
|
||
taskId?: string;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/generate/image', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
if (data?.success && data?.taskId) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
images: [],
|
||
queueStatus: data.queueStatus,
|
||
status: data.status,
|
||
taskId: data.taskId,
|
||
},
|
||
};
|
||
}
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '生成图片失败' };
|
||
}
|
||
},
|
||
|
||
async localEdit(params: {
|
||
sourceImageUrl: string;
|
||
sourceNodeId?: string;
|
||
regions: Array<{
|
||
id: string;
|
||
index?: number;
|
||
color: string;
|
||
prompt: string;
|
||
rect: NormalizedCropRect;
|
||
targetRect?: NormalizedCropRect | null;
|
||
referenceImages?: Array<{
|
||
id?: string;
|
||
url: string;
|
||
name?: string;
|
||
source?: "upload" | "canvas";
|
||
}>;
|
||
}>;
|
||
model?: string;
|
||
imageSize?: string;
|
||
aspectRatio?: string;
|
||
operation?: "scale-adjust";
|
||
scalePercent?: number;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
annotatedReferenceUrl: string;
|
||
aspectRatio?: string;
|
||
displayPrompt?: string;
|
||
prompt: string;
|
||
queueStatus?: unknown;
|
||
sourceHeight?: number;
|
||
sourceWidth?: number;
|
||
status?: string;
|
||
taskId: string;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/local-edit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.taskId) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
annotatedReferenceUrl: data.annotatedReferenceUrl,
|
||
aspectRatio: data.aspectRatio,
|
||
displayPrompt: data.displayPrompt,
|
||
prompt: data.prompt,
|
||
queueStatus: data.queueStatus,
|
||
sourceHeight: data.sourceHeight,
|
||
sourceWidth: data.sourceWidth,
|
||
status: data.status,
|
||
taskId: data.taskId,
|
||
},
|
||
};
|
||
}
|
||
return { success: false, error: data.error || '局部编辑提交失败' };
|
||
} catch (error) {
|
||
logCatch(error, "local_edit_submit");
|
||
return { success: false, error: '局部编辑提交失败' };
|
||
}
|
||
},
|
||
|
||
async getImageTask(taskId: string): Promise<
|
||
ApiResponse<{
|
||
task: {
|
||
id: string;
|
||
status: string;
|
||
error?: string | null;
|
||
result?: {
|
||
images?: Array<{ thumbnailUrl: string; originalUrl: string }>;
|
||
model?: string;
|
||
modelRef?: string;
|
||
prompt?: string;
|
||
} | null;
|
||
queueStatus?: unknown;
|
||
};
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch(`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`);
|
||
const data = await response.json();
|
||
if (data.success && data.task) {
|
||
return { success: true, data: { task: data.task } };
|
||
}
|
||
return { success: false, error: data.error || '查询生图任务失败' };
|
||
} catch (error) {
|
||
logCatch(error, "image_task_fetch", { taskId });
|
||
return { success: false, error: '查询生图任务失败' };
|
||
}
|
||
},
|
||
|
||
// 图片抠图 - 使用图片URL
|
||
async removeBackground(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
|
||
try {
|
||
const response = await fetch('/api/rmbg', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl }), // 使用 imageUrl 参数
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.original_url) {
|
||
return { success: true, data: { url: data.original_url } };
|
||
}
|
||
return { success: false, error: data.error || '抠图失败' };
|
||
} catch (error) {
|
||
logCatch(error, "remove_background");
|
||
return { success: false, error: '抠图失败' };
|
||
}
|
||
},
|
||
|
||
async boxCutout(params: {
|
||
imageUrl: string;
|
||
cropRect: NormalizedCropRect;
|
||
paddingRatio?: number;
|
||
sourceNodeId?: string;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
url: string;
|
||
cropRectApplied?: NormalizedCropRect;
|
||
paddedCropRectApplied?: NormalizedCropRect;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/box-cutout', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.original_url) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
url: data.original_url,
|
||
cropRectApplied: data.cropRectApplied,
|
||
paddedCropRectApplied: data.paddedCropRectApplied,
|
||
},
|
||
};
|
||
}
|
||
return { success: false, error: data.error || '框选抠图失败' };
|
||
} catch (error) {
|
||
logCatch(error, "box_cutout");
|
||
return { success: false, error: '框选抠图失败' };
|
||
}
|
||
},
|
||
|
||
// 去水印 - 使用图片URL
|
||
async removeWatermark(imageUrl: string): Promise<ApiResponse<{ url: string }>> {
|
||
try {
|
||
const response = await fetch('/api/watermark-remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image_url: imageUrl }),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.original_url) {
|
||
return { success: true, data: { url: data.original_url } };
|
||
}
|
||
return { success: false, error: data.error || '去水印失败' };
|
||
} catch (error) {
|
||
logCatch(error, "remove_watermark");
|
||
return { success: false, error: '去水印失败' };
|
||
}
|
||
},
|
||
|
||
// 图片增强 - 使用图片URL
|
||
async enhanceImage(
|
||
imageUrl: string,
|
||
options?: {
|
||
scaleFactor?: number;
|
||
}
|
||
): Promise<ApiResponse<{ url: string }>> {
|
||
try {
|
||
const response = await fetch('/api/enhance', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
imageUrl,
|
||
scaleFactor: options?.scaleFactor,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.original_url) {
|
||
return { success: true, data: { url: data.original_url } };
|
||
}
|
||
return { success: false, error: data.error || '图片增强失败' };
|
||
} catch (error) {
|
||
logCatch(error, "enhance_image");
|
||
return { success: false, error: '图片增强失败' };
|
||
}
|
||
},
|
||
|
||
async enhanceImagePro(
|
||
params: EnhanceProRequestParams,
|
||
): Promise<ApiResponse<{ images: EnhanceProImageResult[] }>> {
|
||
try {
|
||
const payload = await requestLongTaskResult({
|
||
endpoint: '/api/enhance/pro',
|
||
payload: params,
|
||
});
|
||
|
||
if (!payload?.success) {
|
||
return { success: false, error: payload?.error || payload?.detail || '图像增强 Pro 失败' };
|
||
}
|
||
|
||
const images: EnhanceProImageResult[] = [];
|
||
if (Array.isArray(payload.images)) {
|
||
for (const item of payload.images) {
|
||
const originalUrl = String(item?.original_url || '').trim();
|
||
const thumbnailUrl = String(item?.thumbnail_url || item?.original_url || '').trim();
|
||
if (!originalUrl) {
|
||
continue;
|
||
}
|
||
images.push({
|
||
originalUrl,
|
||
thumbnailUrl,
|
||
width: typeof item?.width === 'number' ? item.width : undefined,
|
||
height: typeof item?.height === 'number' ? item.height : undefined,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (images.length === 0 && payload.original_url) {
|
||
images.push({
|
||
originalUrl: payload.original_url,
|
||
thumbnailUrl: payload.thumbnail_url || payload.original_url,
|
||
width: typeof payload.width === 'number' ? payload.width : undefined,
|
||
height: typeof payload.height === 'number' ? payload.height : undefined,
|
||
});
|
||
}
|
||
|
||
if (images.length === 0) {
|
||
return { success: false, error: '图像增强 Pro 未返回有效结果' };
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
data: {
|
||
images,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
logCatch(error, "enhance_image_pro");
|
||
if (error instanceof Error && /failed to fetch|network/i.test(error.message)) {
|
||
return {
|
||
success: false,
|
||
error: '任务可能已提交,网络连接暂时中断,请稍后重试查看结果,避免重复提交。',
|
||
};
|
||
}
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '图像增强 Pro 失败',
|
||
};
|
||
}
|
||
},
|
||
|
||
// 图片扩展 - 使用图片URL
|
||
async extendImage(
|
||
imageUrl: string,
|
||
options?:
|
||
| string
|
||
| {
|
||
prompt?: string;
|
||
xScale?: number;
|
||
yScale?: number;
|
||
}
|
||
): Promise<ApiResponse<{ url: string }>> {
|
||
try {
|
||
const normalizedOptions =
|
||
typeof options === 'string'
|
||
? {
|
||
prompt: options.trim() ? `图像拓展处理:${options.trim()}` : undefined,
|
||
}
|
||
: options || {};
|
||
const response = await fetch('/api/extend', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
image_url: imageUrl,
|
||
prompt: normalizedOptions.prompt || '图像拓展处理',
|
||
includeBase64: false,
|
||
...(typeof normalizedOptions.xScale === 'number'
|
||
? { xScale: normalizedOptions.xScale }
|
||
: {}),
|
||
...(typeof normalizedOptions.yScale === 'number'
|
||
? { yScale: normalizedOptions.yScale }
|
||
: {}),
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.original_url) {
|
||
return { success: true, data: { url: data.original_url } };
|
||
}
|
||
return { success: false, error: data.error || data.detail || '图片扩展失败' };
|
||
} catch (error) {
|
||
logCatch(error, "extend_image");
|
||
return { success: false, error: '图片扩展失败' };
|
||
}
|
||
},
|
||
|
||
async splitImageLayers(imageUrl: string): Promise<
|
||
ApiResponse<{
|
||
layers: Array<{
|
||
originalUrl: string;
|
||
thumbnailUrl: string;
|
||
title: string;
|
||
layerIndex: number;
|
||
totalLayers: number;
|
||
bounds?: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
originalWidth: number;
|
||
originalHeight: number;
|
||
coverage: number;
|
||
} | null;
|
||
}>;
|
||
model?: string;
|
||
provider?: string;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/layer-split', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl }),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && Array.isArray(data.layers)) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
layers: data.layers,
|
||
model: data.model,
|
||
provider: data.provider,
|
||
},
|
||
};
|
||
}
|
||
return { success: false, error: data.error || '图层拆分失败' };
|
||
} catch (error) {
|
||
logCatch(error, "split_image_layers");
|
||
return { success: false, error: '图层拆分失败' };
|
||
}
|
||
},
|
||
|
||
// 尺寸调整 - 通过 URL 下载图片,调整大小后上传到 S3
|
||
async resizeImage(imageUrl: string, width: number, height: number): Promise<ApiResponse<{ url: string }>> {
|
||
try {
|
||
// 1. 下载原图
|
||
const response = await fetch(imageUrl);
|
||
if (!response.ok) {
|
||
throw new Error('下载图片失败');
|
||
}
|
||
const blob = await response.blob();
|
||
|
||
// 2. 使用 canvas 调整尺寸
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) {
|
||
throw new Error('无法创建 canvas context');
|
||
}
|
||
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
img.onload = () => resolve();
|
||
img.onerror = () => reject(new Error('图片加载失败'));
|
||
img.src = URL.createObjectURL(blob);
|
||
});
|
||
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
ctx.drawImage(img, 0, 0, width, height);
|
||
|
||
// 3. 转换为 base64
|
||
const base64 = canvas.toDataURL('image/png').split(',')[1];
|
||
|
||
// 4. 上传到 S3
|
||
const uploadResponse = await fetch('/api/resize', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
image_base64: base64,
|
||
canvasWidth: width,
|
||
canvasHeight: height,
|
||
prompt: '尺寸调整',
|
||
}),
|
||
});
|
||
|
||
const data = await uploadResponse.json();
|
||
if (data.success && data.original_url) {
|
||
return { success: true, data: { url: data.original_url } };
|
||
}
|
||
return { success: false, error: data.error || '尺寸调整失败' };
|
||
} catch (error) {
|
||
logCatch(error, "resize_image", { width, height });
|
||
return { success: false, error: '尺寸调整失败' };
|
||
}
|
||
},
|
||
|
||
// 角度编辑
|
||
async editAngle(params: {
|
||
imageUrl: string;
|
||
horizontalAngle: number;
|
||
verticalAngle: number;
|
||
zoom: number;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
url: string;
|
||
thumbnailUrl?: string;
|
||
prompt?: string;
|
||
angles?: {
|
||
horizontal_angle?: number;
|
||
vertical_angle?: number;
|
||
zoom?: number;
|
||
};
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/angle-edit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
image_url: params.imageUrl,
|
||
horizontal_angle: params.horizontalAngle,
|
||
vertical_angle: params.verticalAngle,
|
||
zoom: params.zoom,
|
||
}),
|
||
});
|
||
const data = await response.json();
|
||
if (data.success && data.image_url) {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
url: data.image_url,
|
||
thumbnailUrl: data.thumbnail_url,
|
||
prompt: data.prompt,
|
||
angles: data.angles,
|
||
},
|
||
};
|
||
}
|
||
return { success: false, error: data.error || '角度编辑失败' };
|
||
} catch (error) {
|
||
return { success: false, error: '角度编辑失败' };
|
||
}
|
||
},
|
||
|
||
// 场景裂变
|
||
async sceneFusion(params: {
|
||
productImage: string;
|
||
backgroundImages: string[];
|
||
productName?: string;
|
||
}): Promise<ApiResponse<{ urls: string[] }>> {
|
||
try {
|
||
const response = await fetch('/api/scene-fusion', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '场景裂变失败' };
|
||
}
|
||
},
|
||
|
||
// 识别图片中的文字(OCR)
|
||
async detectCanvasText(imageUrl: string): Promise<ApiResponse<{ items: CanvasTextDetectItem[]; model?: string }>> {
|
||
try {
|
||
const response = await fetch('/api/canvas/text-detect', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ imageUrl }),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
logCatch(error, "detect_canvas_text");
|
||
return { success: false, error: '文字识别失败' };
|
||
}
|
||
},
|
||
|
||
// 生成文字改写建议
|
||
async generateCanvasTextSuggestions(params: {
|
||
imageUrl: string;
|
||
originalText: string;
|
||
currentText?: string;
|
||
title?: string;
|
||
pageTexts?: string[];
|
||
audience?: string;
|
||
usageScenario?: string;
|
||
coreNeed?: string;
|
||
productName?: string;
|
||
workflowSummary?: string;
|
||
workflowPrompt?: string;
|
||
userPrompt?: string;
|
||
presetName?: string;
|
||
}): Promise<ApiResponse<{ suggestions: CanvasTextSuggestionItem[]; model?: string }>> {
|
||
try {
|
||
const response = await fetch('/api/canvas/text-copy-suggestions', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
logCatch(error, "text_suggestions");
|
||
return { success: false, error: '文案建议生成失败' };
|
||
}
|
||
},
|
||
|
||
// 应用文字替换并生成新图
|
||
async editCanvasText(params: {
|
||
imageUrl: string;
|
||
items: CanvasTextEditPayloadItem[];
|
||
}): Promise<ApiResponse<{ thumbnailUrl: string; originalUrl: string; model?: string }>> {
|
||
try {
|
||
const response = await fetch('/api/canvas/text-edit', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
logCatch(error, "edit_canvas_text", { itemCount: params.items.length });
|
||
return { success: false, error: '文字替换失败' };
|
||
}
|
||
},
|
||
};
|
||
|
||
// 视频生成服务
|
||
export const videoGenerationService = {
|
||
// 生成视频
|
||
async generateVideo(params: {
|
||
prompt: string;
|
||
mode?: VideoGenerationMode;
|
||
images?: string[];
|
||
resolution?: VideoResolution;
|
||
ratio?: VideoRatio;
|
||
duration?: number;
|
||
watermark?: boolean;
|
||
cameraFixed?: boolean;
|
||
returnLastFrame?: boolean;
|
||
preferredModelRef?: string;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
url: string;
|
||
metadata?: Record<string, any> | null;
|
||
modelDisplayName?: string;
|
||
modelId?: string;
|
||
modelRef?: string;
|
||
mode?: VideoGenerationMode;
|
||
images?: string[];
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/generate/video', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '生成视频失败' };
|
||
}
|
||
},
|
||
};
|
||
|
||
export const workflowAnalysisService = {
|
||
async analyze(params: {
|
||
mode: WorkflowAnalysisMode;
|
||
prompt?: string;
|
||
imageUrls?: string[];
|
||
videoUrls?: string[];
|
||
nodeTitle?: string;
|
||
preferredModelRef?: string;
|
||
}): Promise<
|
||
ApiResponse<{
|
||
analysisRows?: Array<{
|
||
sceneDescription?: string;
|
||
backgroundMusic?: string;
|
||
voiceAndSfx?: string;
|
||
imagePrompt?: string;
|
||
motionPrompt?: string;
|
||
keyframeUrl?: string;
|
||
}>;
|
||
framePreviews?: string[];
|
||
summary?: string;
|
||
highlights?: string[];
|
||
shotIdeas?: Array<{ title: string; description: string }>;
|
||
visualMotifs?: string[];
|
||
recommendedNextSteps?: string[];
|
||
characters?: Array<Record<string, any>>;
|
||
scenes?: Array<Record<string, any>>;
|
||
relationships?: string[];
|
||
agentPlan?: string[];
|
||
deliverables?: string[];
|
||
recommendedNodes?: string[];
|
||
model?: string | null;
|
||
raw?: string;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/workflow/analyze', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '工作流分析失败' };
|
||
}
|
||
},
|
||
};
|
||
|
||
export type Model3DTaskStatus =
|
||
| 'queued'
|
||
| 'running'
|
||
| 'success'
|
||
| 'failed'
|
||
| 'banned'
|
||
| 'expired'
|
||
| 'cancelled'
|
||
| 'unknown';
|
||
|
||
export const model3DGenerationService = {
|
||
async uploadImage(
|
||
file: File,
|
||
options?: { providerId?: string | null },
|
||
): Promise<ApiResponse<{ imageToken?: string; file?: Record<string, any>; providerId?: string | null; providerName?: string | null }>> {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
if (options?.providerId) {
|
||
formData.append('providerId', options.providerId);
|
||
}
|
||
const response = await fetch('/api/canvas/3d/upload-image', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '上传参考图失败' };
|
||
}
|
||
},
|
||
|
||
async createTask(params: Record<string, any>): Promise<ApiResponse<{ taskId: string; status: Model3DTaskStatus; providerId?: string | null; providerName?: string | null; raw?: any }>> {
|
||
try {
|
||
const response = await fetch('/api/canvas/3d/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '创建 3D 任务失败' };
|
||
}
|
||
},
|
||
|
||
async getTask(taskId: string, options?: { providerId?: string | null }): Promise<ApiResponse<{
|
||
taskId: string;
|
||
type: string;
|
||
status: Model3DTaskStatus;
|
||
progress?: number | null;
|
||
output?: Record<string, any>;
|
||
modelUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
assetPayload?: Record<string, any> | null;
|
||
errorCode?: number | null;
|
||
errorMessage?: string | null;
|
||
refundedPoints?: number | null;
|
||
}>> {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (options?.providerId) {
|
||
params.set('providerId', options.providerId);
|
||
}
|
||
const query = params.toString();
|
||
const response = await fetch(
|
||
`/api/canvas/3d/tasks/${taskId}${query ? `?${query}` : ''}`
|
||
);
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '查询 3D 任务失败' };
|
||
}
|
||
},
|
||
|
||
async getBalance(): Promise<ApiResponse<Record<string, any>>> {
|
||
try {
|
||
const response = await fetch('/api/canvas/3d/balance');
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '查询余额失败' };
|
||
}
|
||
},
|
||
};
|
||
|
||
export type WorldLabsTaskMode = 'text' | 'image' | 'multi-image' | 'video';
|
||
|
||
export type WorldLabsTaskAssetPayload = {
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
worldId?: string | null;
|
||
worldUrl?: string | null;
|
||
displayName?: string | null;
|
||
model?: string | null;
|
||
caption?: string | null;
|
||
thumbnailUrl?: string | null;
|
||
panoUrl?: string | null;
|
||
meshUrl?: string | null;
|
||
spzUrls?: Record<string, string> | null;
|
||
semanticsMetadata?: Record<string, any> | null;
|
||
storageArchivedAt?: string | null;
|
||
sourceModelUrl?: string | null;
|
||
sourcePreviewImageUrl?: string | null;
|
||
sourceWorldUrl?: string | null;
|
||
sourceMeshUrl?: string | null;
|
||
sourceThumbnailUrl?: string | null;
|
||
sourcePanoUrl?: string | null;
|
||
assetsPending?: boolean | null;
|
||
};
|
||
|
||
export const worldLabsGenerationService = {
|
||
async uploadMedia(
|
||
file: File,
|
||
kind: 'image' | 'video',
|
||
options?: { providerId?: string | null }
|
||
): Promise<
|
||
ApiResponse<{
|
||
mediaAssetId?: string;
|
||
mediaAsset?: Record<string, any>;
|
||
uploadInfo?: Record<string, any>;
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
}>
|
||
> {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('kind', kind);
|
||
if (options?.providerId) {
|
||
formData.append('providerId', options.providerId);
|
||
}
|
||
const response = await fetch('/api/canvas/world/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '上传 World Labs 媒体失败' };
|
||
}
|
||
},
|
||
|
||
async uploadMediaFromUrl(
|
||
sourceUrl: string,
|
||
kind: 'image' | 'video',
|
||
options?: { providerId?: string | null }
|
||
): Promise<
|
||
ApiResponse<{
|
||
mediaAssetId?: string;
|
||
mediaAsset?: Record<string, any>;
|
||
uploadInfo?: Record<string, any>;
|
||
providerId?: string | null;
|
||
providerName?: string | null;
|
||
}>
|
||
> {
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('sourceUrl', sourceUrl);
|
||
formData.append('kind', kind);
|
||
if (options?.providerId) {
|
||
formData.append('providerId', options.providerId);
|
||
}
|
||
const response = await fetch('/api/canvas/world/upload', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '上传 World Labs 媒体失败' };
|
||
}
|
||
},
|
||
|
||
async createTask(params: Record<string, any>): Promise<
|
||
ApiResponse<{
|
||
taskId: string;
|
||
status: Model3DTaskStatus;
|
||
raw?: any;
|
||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||
}>
|
||
> {
|
||
try {
|
||
const response = await fetch('/api/canvas/world/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
});
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '创建 World Labs 任务失败' };
|
||
}
|
||
},
|
||
|
||
async getTask(
|
||
taskId: string,
|
||
options?: { providerId?: string | null }
|
||
): Promise<ApiResponse<{
|
||
taskId: string;
|
||
type: string;
|
||
status: Model3DTaskStatus;
|
||
progress?: number | null;
|
||
output?: Record<string, any>;
|
||
modelUrl?: string | null;
|
||
previewImageUrl?: string | null;
|
||
worldId?: string | null;
|
||
worldUrl?: string | null;
|
||
panoUrl?: string | null;
|
||
caption?: string | null;
|
||
assetPayload?: WorldLabsTaskAssetPayload | null;
|
||
errorMessage?: string | null;
|
||
}>> {
|
||
try {
|
||
const url = new URL(`/api/canvas/world/tasks/${taskId}`, window.location.origin);
|
||
if (options?.providerId) {
|
||
url.searchParams.set('providerId', options.providerId);
|
||
}
|
||
const response = await fetch(url.toString());
|
||
const data = await response.json();
|
||
return data;
|
||
} catch (error) {
|
||
return { success: false, error: '查询 World Labs 任务失败' };
|
||
}
|
||
},
|
||
};
|
||
|
||
export default {
|
||
canvas: canvasService,
|
||
image: imageGenerationService,
|
||
video: videoGenerationService,
|
||
model3d: model3DGenerationService,
|
||
world: worldLabsGenerationService,
|
||
};
|