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 | { code?: number | string; data?: T | null; msg?: string; message?: string; error?: { message?: string } }; type RequestOptions = { signal?: AbortSignal }; const apiText = (key: string, options?: Record) => 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(); 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 { 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 { 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 { 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 { 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; 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 { 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 { 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(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 { try { const video = unwrapVideoResponse((await axios.get(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(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 { try { const response = await axios.get(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(payload: ApiEnvelope, 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((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 }, ); }); }