288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
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 },
|
||
);
|
||
});
|
||
}
|