Files
MiragenFlow/web/src/services/api/video.ts
T

288 lines
14 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 axios from "axios";
import { nanoid } from "nanoid";
import i18n from "@/i18n";
import { dataUrlToFile } from "@/lib/image-utils";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { imageToDataUrl } from "@/services/image-storage";
import { boolConfig, buildApiUrl, modelOptionName, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
import { runModelPlugin } from "./model-plugin";
import type { ReferenceImage } from "@/types/image";
type VideoResponse = { id: string; status?: string; error?: { message?: string }; url?: string; result_url?: string; video_url?: string; content?: { video_url?: string; url?: string } | null };
type ApiVideoResponse = VideoResponse | { code?: number | string; data?: VideoResponse | null; msg?: string; message?: string; error?: { message?: string } };
type ApiEnvelope<T> = T | { code?: number | string; data?: T | null; msg?: string; message?: string; error?: { message?: string } };
type RequestOptions = { signal?: AbortSignal };
const apiText = (key: string, options?: Record<string, unknown>) => i18n.t(`apiErrors.${key}`, options);
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "plugin"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
/** Results for scripted (plugin) video models, which run their own create+poll in one shot at task creation. */
const pluginVideoResults = new Map<string, VideoGenerationResult>();
function aiApiUrl(config: AiConfig, path: string) {
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], options?: RequestOptions): Promise<VideoGenerationResult> {
const task = await createVideoGenerationTask(config, prompt, references, options);
for (let attempt = 0; attempt < 120; attempt += 1) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
const state = await pollVideoGenerationTask(config, task, options);
if (state.status === "completed") return state.result;
if (state.status === "failed") throw new Error(state.error);
if (attempt === 119) throw new Error(apiText("videoTimeout", { provider: "" }));
await delay(2500, options?.signal);
}
throw new Error(apiText("videoTimeout", { provider: "" }));
}
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], options?: RequestOptions): Promise<VideoGenerationTask> {
const selectedModel = (config.model || config.videoModel).trim();
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const script = resolveModelScript(config, selectedModel);
if (script) return createPluginVideoTask(requestConfig, selectedModel, script, prompt, references, options);
assertVideoConfig(requestConfig, requestConfig.model);
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references, options);
}
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
if (task.provider === "plugin") {
const result = pluginVideoResults.get(task.id);
return result ? { status: "completed", result } : { status: "failed", error: apiText("pluginVideoExpired") };
}
const requestConfig = resolveModelRequestConfig(config, task.model);
assertVideoConfig(requestConfig, requestConfig.model);
return pollOpenAIVideoTask(requestConfig, task, options);
}
async function createPluginVideoTask(config: AiConfig, model: string, script: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
if (!config.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!config.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
const result = videoPluginResult(
await runModelPlugin({
capability: "video",
script,
config,
prompt,
images: refs,
params: {
seconds: normalizeVideoSeconds(config.videoSeconds),
size: normalizeVideoSize(config.size),
resolution: normalizeVideoResolution(config.vquality),
ratio: config.size,
generateAudio: boolConfig(config.videoGenerateAudio, true),
watermark: boolConfig(config.videoWatermark, false),
},
signal: options?.signal,
}),
);
const id = nanoid();
pluginVideoResults.set(id, result);
return { id, provider: "plugin", model };
}
function videoPluginResult(result: unknown): VideoGenerationResult {
if (result instanceof Blob) return { blob: result };
if (typeof result === "string") return { url: result, mimeType: "video/mp4" };
if (result && typeof result === "object") {
const record = result as Record<string, unknown>;
if (record.blob instanceof Blob) return { blob: record.blob };
const url = [record.url, record.video_url, record.result_url].find((value) => typeof value === "string" && value) as string | undefined;
if (url) return { url, mimeType: "video/mp4" };
}
throw new Error(apiText("scriptNoVideo"));
}
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
if (result.blob) return uploadMediaFile(result.blob, "video");
if (result.url) {
try {
return await uploadMediaFile(result.url, "video");
} catch {
return { url: result.url, storageKey: "", bytes: 0, mimeType: result.mimeType || "video/mp4" };
}
}
throw new Error(apiText("noPlayableVideo"));
}
async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
const body = new FormData();
body.append("model", modelOptionName(model));
body.append("prompt", prompt);
body.append("seconds", normalizeVideoSeconds(config.videoSeconds));
if (normalizeVideoSize(config.size)) body.append("size", normalizeVideoSize(config.size)!);
body.append("resolution_name", normalizeVideoResolution(config.vquality));
body.append("preset", "normal");
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => body.append("input_reference[]", file));
try {
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data);
if (!created.id) throw new Error(apiText("noVideoTaskId"));
return { id: created.id, provider: "openai", model };
} catch (error) {
throw new Error(readAxiosError(error, apiText("videoTaskCreateFailed")));
}
}
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), signal: options?.signal })).data);
const url = videoResultUrl(video);
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
if (video.status === "completed") {
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
await assertVideoBlob(content.data);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: readApiErrorMessage(video.error?.message) || apiText("videoGenerationFailed") };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, apiText("videoTaskQueryFailed")));
}
}
async function videoResultFromUrl(url: string, options?: RequestOptions): Promise<VideoGenerationResult> {
try {
const response = await axios.get<Blob>(url, { responseType: "blob", signal: options?.signal });
await assertVideoBlob(response.data);
return { blob: response.data };
} catch (error) {
if (axios.isCancel(error) || options?.signal?.aborted) throw error;
return { url, mimeType: "video/mp4" };
}
}
function assertVideoConfig(config: AiConfig, model: string) {
if (!model) throw new Error(apiText("videoModelRequired"));
if (!config.baseUrl.trim()) throw new Error(apiText("baseUrlRequired"));
if (!config.apiKey.trim()) throw new Error(apiText("apiKeyRequired"));
if (config.apiFormat === "gemini") throw new Error(apiText("geminiVideoUnsupported"));
}
function normalizeVideoSeconds(value: string) {
const seconds = Math.floor(Number(value) || 6);
return String(Math.max(1, Math.min(20, seconds)));
}
function normalizeVideoSize(value: string) {
if (value === "auto") return null;
const size = value || "1280x720";
if (/^\d+x\d+$/.test(size)) return size;
return ["9:16", "2:3", "3:4"].includes(size) ? "720x1280" : "1280x720";
}
function normalizeVideoResolution(value: string) {
if (value === "low") return "480p";
if (value === "auto" || value === "high" || value === "medium") return "720p";
const resolution = value.replace(/p$/i, "") || "720";
return `${resolution}p`;
}
function unwrapVideoResponse(payload: ApiVideoResponse) {
return unwrapEnvelope(payload, apiText("noVideoTask"));
}
function unwrapEnvelope<T>(payload: ApiEnvelope<T>, emptyMessage: string): T {
if (!payload) throw new Error(emptyMessage);
if (typeof payload === "object" && "code" in payload && payload.code !== undefined) {
if (payload.code !== 0 && payload.code !== "0") throw new Error(readApiErrorMessage(payload) || apiText("requestFailed"));
if (!payload.data) throw new Error(emptyMessage);
return payload.data;
}
return payload as T;
}
function videoResultUrl(payload: VideoResponse) {
return [payload.video_url, payload.result_url, payload.url, payload.content?.video_url, payload.content?.url].find((url) => typeof url === "string" && (isPublicMediaUrl(url) || /\.mp4(\?|#|$)/i.test(url)));
}
function readApiErrorMessage(value: unknown): string {
if (!value) return "";
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
const inner = readApiErrorMessage(parsed) || value;
if (inner === value && typeof parsed === "object" && Object.keys(parsed).length === 0) return "";
return inner;
} catch {
if (/<[a-z][\s\S]*>/i.test(value)) return apiText("htmlError", { preview: `${value.slice(0, 80)}...` });
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: unknown; detail?: unknown };
// error may be a string or an object containing a message.
const errorMsg =
typeof payload.error === "string"
? payload.error
: (payload.error as { message?: unknown })?.message;
return (
readApiErrorMessage(payload.msg) ||
readApiErrorMessage(payload.message) ||
readApiErrorMessage(errorMsg) ||
readApiErrorMessage(payload.detail) ||
""
);
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return apiText("requestCanceled");
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; message?: string; code?: number | string }>(error)) {
const responseData = error.response?.data;
return readApiErrorMessage(responseData) || statusMessage(error.response?.status, fallback);
}
if (error instanceof DOMException && error.name === "AbortError") return apiText("requestCanceled");
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function statusMessage(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return apiText("authenticationFailed");
if (status === 429) return apiText("rateLimited");
return status ? `${fallback}(${status})` : fallback;
}
async function assertVideoBlob(blob: Blob) {
if (!blob.type.includes("json")) return;
let payload: { code?: number; msg?: string; error?: { message?: string } };
try {
payload = JSON.parse(await blob.text()) as { code?: number; msg?: string; error?: { message?: string } };
} catch {
return;
}
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(readApiErrorMessage(payload) || apiText("videoDownloadFailed"));
if (payload.error?.message) throw new Error(readApiErrorMessage(payload.error.message) || payload.error.message);
}
function isPublicMediaUrl(value: string) {
return /^https?:\/\//i.test(value || "");
}
function delay(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("Aborted", "AbortError"));
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}