169 lines
8.6 KiB
TypeScript
169 lines
8.6 KiB
TypeScript
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<string, unknown>;
|
|
onTaskUpdate?: (task: GenerationTask) => void;
|
|
};
|
|
|
|
type PreparedGenerationReferences = {
|
|
referenceImages: Awaited<ReturnType<typeof prepareBackendImageReference>>[];
|
|
referenceVideos: Awaited<ReturnType<typeof prepareBackendMediaReference>>[];
|
|
referenceAudios: Awaited<ReturnType<typeof prepareBackendMediaReference>>[];
|
|
mask?: Awaited<ReturnType<typeof prepareBackendImageReference>>;
|
|
};
|
|
|
|
// 生成、计费、取消和任务记录必须共用后端任务生命周期,页面层不能再直连供应商。
|
|
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<BackendGenerationTaskOptions, "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
|
|
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;
|
|
}
|