import { getMediaBlob } from "@/services/file-storage"; import { getImageBlob } from "@/services/image-storage"; import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources"; import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center"; import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; export type BackendGenerationMode = "text" | "image" | "video" | "audio"; export type BackendGenerationResult = { mode?: BackendGenerationMode; images?: Array<{ dataUrl: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string }>; video?: { dataUrl: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string }; audio?: { dataUrl: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; format?: string }; text?: string; }; type BackendGenerationTaskOptions = { projectId?: string; mode: BackendGenerationMode; prompt: string; config: AiConfig; referenceImages?: ReferenceImage[]; referenceVideos?: ReferenceVideo[]; referenceAudios?: ReferenceAudio[]; mask?: ReferenceImage; signal?: AbortSignal; metadata?: Record; onTaskUpdate?: (task: GenerationTask) => void; }; type PreparedGenerationReferences = { referenceImages: Awaited>[]; referenceVideos: Awaited>[]; referenceAudios: Awaited>[]; mask?: Awaited>; }; // 生成、计费、取消和任务记录必须共用后端任务生命周期,页面层不能再直连供应商。 export async function runBackendGenerationTask({ projectId, mode, prompt, config, referenceImages = [], referenceVideos = [], referenceAudios = [], mask, signal, metadata, onTaskUpdate, }: BackendGenerationTaskOptions) { throwIfAborted(signal); const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask }); throwIfAborted(signal); return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, signal, metadata, onTaskUpdate }, prepared); } export async function runBackendGenerationTaskBatch(options: BackendGenerationTaskOptions & { count: number }) { const count = Math.max(1, Math.min(15, Math.floor(Number(options.count)) || 1)); throwIfAborted(options.signal); const prepared = await prepareGenerationReferences(options); throwIfAborted(options.signal); return Promise.allSettled(Array.from({ length: count }, (_, batchIndex) => createAndWaitGenerationTask({ ...options, metadata: { ...options.metadata, batchIndex, batchCount: count }, }, prepared))); } function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); } async function prepareGenerationReferences({ referenceImages = [], referenceVideos = [], referenceAudios = [], mask }: Pick): Promise { const preparedImages = await Promise.all(referenceImages.map(prepareBackendImageReference)); const preparedVideos = await Promise.all(referenceVideos.map(prepareBackendMediaReference)); const preparedAudios = await Promise.all(referenceAudios.map(prepareBackendMediaReference)); const preparedMask = mask ? await prepareBackendImageReference(mask) : undefined; return { referenceImages: preparedImages, referenceVideos: preparedVideos, referenceAudios: preparedAudios, mask: preparedMask }; } async function createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages = [], signal, metadata, onTaskUpdate }: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences) { const videoOperation = String(metadata?.videoEditOperation || (referenceImages.length ? "image_to_video" : "text_to_video")); const task = await createGenerationTask({ ...(projectId ? { projectId } : {}), type: `canvas_${mode}`, operation: mode === "video" ? videoOperation : mode, prompt, model: config.model, input: { mode, prompt, config: backendProviderConfig(config), referenceImages: prepared.referenceImages, referenceVideos: prepared.referenceVideos, referenceAudios: prepared.referenceAudios, mask: prepared.mask, metadata, }, }); onTaskUpdate?.(task); const completed = await waitForGenerationTask(task.id, { signal, initialTask: task, onTaskUpdate }); return parseBackendGenerationResult(completed); } async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAudio) { if (resourceIdFromStorageKey(media.storageKey)) return { ...media, dataUrl: "" }; const url = media.url || ""; if (/^https?:\/\//i.test(url)) return media; let blob: Blob | null = null; if (media.storageKey) blob = await getMediaBlob(media.storageKey); if (!blob && (url.startsWith("blob:") || url.startsWith("data:"))) blob = await (await fetch(url)).blob(); if (!blob) throw new Error("参考媒体尚未保存,请重新上传后再生成"); try { const kind: "video" | "audio" | "file" = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file"; const resource = await uploadResourceFile(blob, kind, { fileName: media.name, width: "width" in media ? media.width : undefined, height: "height" in media ? media.height : undefined, durationMs: media.durationMs }); return { ...media, url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), dataUrl: "", type: resource.mimeType || media.type || blob.type }; } catch (error) { throw new Error(error instanceof Error ? `参考媒体上传失败:${error.message}` : "参考媒体上传失败"); } } async function prepareBackendImageReference(image: ReferenceImage) { if (resourceIdFromStorageKey(image.storageKey)) return { ...image, dataUrl: "" }; if (/^https?:\/\//i.test(image.dataUrl)) return { ...image, url: image.url || image.dataUrl, dataUrl: "" }; const blob = image.storageKey ? await getImageBlob(image.storageKey) : image.dataUrl ? await (await fetch(image.dataUrl)).blob() : null; if (!blob) throw new Error("参考图片尚未保存,请重新上传后再生成"); try { const resource = await uploadResourceFile(blob, "image", { fileName: image.name }); return { ...image, dataUrl: "", url: resource.publicUrl || `/api/resources/${resource.id}/file`, storageKey: resourceStorageKey(resource.id), type: resource.mimeType || image.type || blob.type }; } catch (error) { throw new Error(error instanceof Error ? `参考图片上传失败:${error.message}` : "参考图片上传失败"); } } export function backendProviderConfig(config: AiConfig) { const requestConfig = resolveModelRequestConfig(config, config.model); return { channelId: requestConfig.channelId, apiFormat: requestConfig.apiFormat, interfaceType: requestConfig.interfaceType, baseUrl: requestConfig.baseUrl, apiKey: requestConfig.apiKey, secretKey: requestConfig.secretKey, model: requestConfig.model, size: config.size, quality: config.quality, transparentBackground: config.transparentBackground, count: config.count, videoSeconds: config.videoSeconds, vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio, videoWatermark: config.videoWatermark, audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: config.audioSpeed, audioInstructions: config.audioInstructions, systemPrompt: config.systemPrompt, }; } export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult { if (!task.resultJson) throw new Error("后端任务没有返回结果"); const result = JSON.parse(task.resultJson) as BackendGenerationResult; if (!result || typeof result !== "object") throw new Error("后端任务结果格式错误"); return result; }