162 lines
6.3 KiB
TypeScript
162 lines
6.3 KiB
TypeScript
import type { PromptMaterial } from "@/lib/prompt/assembler";
|
||
import { toAbsoluteUrl } from "@/lib/server/runtime";
|
||
import type { GenerationStatus } from "@/lib/types";
|
||
|
||
export const MINIMAX_H3_MODEL = "MiniMax-H3";
|
||
export const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.cn";
|
||
|
||
export type MinimaxH3Settings = {
|
||
ratio?: string;
|
||
duration?: number;
|
||
resolution?: string;
|
||
};
|
||
|
||
export type MinimaxH3Usage = {
|
||
totalSeconds: number;
|
||
inputSeconds: number;
|
||
outputSeconds: number;
|
||
inputImageCount: number;
|
||
};
|
||
|
||
export function getMinimaxConfig() {
|
||
return {
|
||
apiKey: process.env.MINIMAX_API_KEY?.trim(),
|
||
baseUrl: (process.env.MINIMAX_BASE_URL || MINIMAX_DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
||
model: MINIMAX_H3_MODEL
|
||
};
|
||
}
|
||
|
||
export function buildMinimaxH3Payload(input: {
|
||
prompt: string;
|
||
materials: PromptMaterial[];
|
||
settings: MinimaxH3Settings;
|
||
origin: string;
|
||
}) {
|
||
const prompt = input.prompt.trim();
|
||
if (!prompt) throw new Error("MiniMax H3 视频提示词不能为空。");
|
||
if (input.materials.length > 1 || input.materials.some((material) => material.type !== "image")) {
|
||
throw new Error("MiniMax H3 第一版支持纯文字生成,或只上传 1 张图片作为首帧。");
|
||
}
|
||
const duration = Number(input.settings.duration ?? 5);
|
||
if (!Number.isInteger(duration) || duration < 4 || duration > 15) {
|
||
throw new Error("MiniMax H3 视频时长须为 4–15 秒整数。");
|
||
}
|
||
const resolution = String(input.settings.resolution || "768P").toUpperCase();
|
||
if (resolution !== "768P" && resolution !== "2K") {
|
||
throw new Error("MiniMax H3 分辨率仅支持 768P 或 2K。");
|
||
}
|
||
const ratio = input.materials.length === 1 ? "adaptive" : String(input.settings.ratio || "16:9");
|
||
if (input.materials.length === 0 && !["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(ratio)) {
|
||
throw new Error("MiniMax H3 文生视频须选择固定画面比例。");
|
||
}
|
||
return {
|
||
model: MINIMAX_H3_MODEL,
|
||
content: [
|
||
{ type: "text", text: prompt },
|
||
...input.materials.map((material) => ({
|
||
type: "image_url",
|
||
image_url: { url: toAbsoluteUrl(material.url, input.origin) },
|
||
role: "first_frame"
|
||
}))
|
||
],
|
||
resolution,
|
||
duration,
|
||
ratio,
|
||
aigc_watermark: false
|
||
};
|
||
}
|
||
|
||
export async function createMinimaxH3Task(payload: ReturnType<typeof buildMinimaxH3Payload>) {
|
||
const config = requiredConfig();
|
||
const response = await minimaxFetch(`${config.baseUrl}/v2/video_generation`, config.apiKey, {
|
||
method: "POST",
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const providerTaskId = text(response.task_id);
|
||
if (!providerTaskId) throw new Error("MiniMax H3 响应中缺少任务 ID。");
|
||
return { providerTaskId, raw: response };
|
||
}
|
||
|
||
export async function queryMinimaxH3Task(providerTaskId: string) {
|
||
const config = requiredConfig();
|
||
const response = await minimaxFetch(`${config.baseUrl}/v2/query/video_generation/${encodeURIComponent(providerTaskId)}`, config.apiKey);
|
||
const task = record(response.task);
|
||
const content = record(task.content);
|
||
const error = record(task.error);
|
||
return {
|
||
status: normalizeStatus(task.status),
|
||
resultUrl: text(content.url),
|
||
errorMessage: minimaxTaskError(text(error.code), text(error.message)),
|
||
usage: extractMinimaxH3Usage(task.usage),
|
||
raw: response
|
||
};
|
||
}
|
||
|
||
export function extractMinimaxH3Usage(value: unknown): MinimaxH3Usage | undefined {
|
||
const usage = record(value);
|
||
const outputSeconds = nonNegativeInteger(usage.output_seconds);
|
||
if (!outputSeconds) return undefined;
|
||
return {
|
||
totalSeconds: nonNegativeInteger(usage.total_seconds),
|
||
inputSeconds: nonNegativeInteger(usage.input_seconds),
|
||
outputSeconds,
|
||
inputImageCount: nonNegativeInteger(usage.input_image_count)
|
||
};
|
||
}
|
||
|
||
async function minimaxFetch(url: string, apiKey: string, init: RequestInit = {}) {
|
||
const response = await fetch(url, {
|
||
...init,
|
||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers || {}) }
|
||
});
|
||
const json = await response.json().catch(() => ({})) as Record<string, unknown>;
|
||
if (!response.ok) throw new Error(minimaxHTTPError(response.status));
|
||
return json;
|
||
}
|
||
|
||
function requiredConfig() {
|
||
const config = getMinimaxConfig();
|
||
if (!config.apiKey) throw new Error("缺少 MINIMAX_API_KEY,请先配置 MiniMax 凭据。");
|
||
return { ...config, apiKey: config.apiKey };
|
||
}
|
||
|
||
function minimaxHTTPError(status: number) {
|
||
if (status === 401) return "MiniMax 鉴权失败,请检查 API Key 是否正确且已开通 H3。";
|
||
if (status === 403) return "MiniMax API Key 无 H3 调用权限,请确认已购买并开通该模型。";
|
||
if (status === 402) return "MiniMax API 账户余额不足,请充值后重试。";
|
||
if (status === 404) return "MiniMax H3 接口或模型不可用,请检查 Base URL 和账号模型权限。";
|
||
if (status === 422) return "视频描述或输入素材触发内容安全审核,请调整后重试。";
|
||
if (status === 429) return "MiniMax 请求频率过高,请稍后重试。";
|
||
if (status >= 500) return "MiniMax 服务暂时不可用,请稍后重试。";
|
||
return "MiniMax 请求参数不符合要求,请检查提示词、素材和生成参数。";
|
||
}
|
||
|
||
function minimaxTaskError(code?: string, message?: string) {
|
||
if (code === "1008") return "MiniMax API 账户余额不足,请充值后重试。";
|
||
if (code === "1026") return "视频描述或输入素材触发内容安全审核,请调整后重试。";
|
||
if (code === "1002") return "MiniMax 请求频率过高,请稍后重试。";
|
||
return message ? "MiniMax 视频生成失败,请调整提示词或素材后重试。" : undefined;
|
||
}
|
||
|
||
function normalizeStatus(value: unknown): GenerationStatus {
|
||
const status = String(value || "").toLowerCase();
|
||
if (status === "succeeded") return "succeeded";
|
||
if (status === "failed") return "failed";
|
||
if (status === "cancelled") return "cancelled";
|
||
if (status === "running") return "running";
|
||
return "queued";
|
||
}
|
||
|
||
function record(value: unknown): Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||
}
|
||
|
||
function text(value: unknown) {
|
||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||
}
|
||
|
||
function nonNegativeInteger(value: unknown) {
|
||
const number = Number(value);
|
||
return Number.isInteger(number) && number >= 0 ? number : 0;
|
||
}
|