Files
ttstd-infinite-canvas/web/src/services/api/video-provider-minimax.ts

102 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getMediaBlob } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
import { boolConfig } from "@/lib/seedance-video";
import { modelOptionName } from "@/stores/use-config-store";
import { isPublicMediaUrl } from "./video-validation";
import { blobToDataUrl } from "./video-response";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import type { MiniMaxVideoCreateResponse, MiniMaxVideoTask, RequestOptions, ResolvedAiConfig, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts";
import type { VideoProviderDeps } from "./video-provider-deps";
import { resolveVideoImageReferences } from "./video-reference-roles";
export async function createMiniMaxVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
const imageUrls = await Promise.all(references.slice(0, 9).map((image) => resolveMiniMaxImageUrl(image)));
const videoUrls = await Promise.all(videoReferences.slice(0, 3).map((video) => resolveMiniMaxMediaUrl(video, "参考视频")));
const audioUrls = await Promise.all(audioReferences.slice(0, 3).map((audio) => resolveMiniMaxMediaUrl(audio, "参考音频")));
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt.trim() }];
const imagePlan = resolveVideoImageReferences(references.slice(0, 9), options, { videoCount: videoUrls.length, audioCount: audioUrls.length });
const frameMode = imagePlan.some(({ role }) => role === "first_frame" || role === "last_frame");
imageUrls.forEach((url, index) => {
const role = imagePlan[index]?.role || "reference_image";
if (frameMode && role === "reference_image") throw new Error("MiniMax 首尾帧模式不能混合未标记的参考图");
content.push({ type: "image_url", image_url: { url }, role });
});
videoUrls.forEach((url) => content.push({ type: "video_url", video_url: { url }, role: "reference_video" }));
audioUrls.forEach((url) => content.push({ type: "audio_url", audio_url: { url }, role: "reference_audio" }));
const payload = {
model: modelOptionName(model),
content,
resolution: normalizeMiniMaxResolution(config.vquality),
duration: normalizeMiniMaxDuration(config.videoSeconds),
ratio: normalizeMiniMaxRatio(config.size, frameMode),
aigc_watermark: boolConfig(config.videoWatermark, false),
};
try {
const created = await deps.transport.post<MiniMaxVideoCreateResponse>(miniMaxVideoUrl(config, "/video_generation"), payload, options);
const id = created.task_id || created.request_id || created.data?.task_id || created.data?.id || "";
if (!id) throw new Error("MiniMax 视频接口没有返回任务 ID");
return { id, provider: "minimax", model };
} catch (error) {
throw new Error(deps.response.readAxiosError(error, "MiniMax 视频任务创建失败"));
}
}
export async function pollMiniMaxVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const response = await deps.transport.get<{ task?: MiniMaxVideoTask }>(miniMaxVideoUrl(config, `/query/video_generation/${encodeURIComponent(task.id)}`), options);
const state = response.task || {};
const status = String(state.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const url = state.content?.url || "";
if (!url) return { status: "failed", error: "MiniMax 视频任务已完成但没有返回视频地址" };
return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) };
}
if (status === "failed" || status === "cancelled") {
const code = state.error?.code ? `${state.error.code}:` : "";
return { status: "failed", error: `${code}${state.error?.message || "MiniMax 视频生成失败"}` };
}
return { status: "pending" };
} catch (error) {
throw new Error(deps.response.readAxiosError(error, "MiniMax 视频任务查询失败"));
}
}
function miniMaxVideoUrl(config: ResolvedAiConfig, path: string) {
const base = config.baseUrl.replace(/\/+$/, "");
return /\/v2$/i.test(base) ? `${base}${path}` : `${base}/v2${path}`;
}
function normalizeMiniMaxResolution(value: string) {
const normalized = value.trim().toLowerCase();
return ["2k", "4k", "high", "1080", "1080p", "1440p", "2160p"].includes(normalized) ? "2K" : "768P";
}
function normalizeMiniMaxDuration(value: string) {
const seconds = Math.floor(Number(value) || 5);
return Math.max(4, Math.min(15, seconds));
}
function normalizeMiniMaxRatio(value: string, frameMode: boolean) {
if (frameMode) return "adaptive";
return ["adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(value) ? value : "16:9";
}
async function resolveMiniMaxImageUrl(image: ReferenceImage) {
const directUrl = image.url || image.dataUrl;
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("data:")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("MiniMax 参考图读取失败,请换一张图片或重新上传");
return dataUrl;
}
async function resolveMiniMaxMediaUrl(media: ReferenceVideo | ReferenceAudio, label: string) {
if (isPublicMediaUrl(media.url) || media.url?.startsWith("data:")) return media.url;
let blob: Blob | null = null;
if (media.storageKey) blob = await getMediaBlob(media.storageKey);
if (!blob && media.url?.startsWith("blob:")) blob = await (await fetch(media.url)).blob();
if (!blob) throw new Error(`MiniMax ${label}必须是公网 URL 或本地已保存素材`);
return blobToDataUrl(blob);
}