feat(media): 视频渠道 - 新增 Novita 异步视频生成协议
Co-authored-by: jax-novita <jax-novita@users.noreply.github.com> Original-PR: https://github.com/ddcat-ai/open-ai-canvas/pull/206
This commit is contained in:
@@ -28,7 +28,7 @@ type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
|
||||
type RequestOptions = { signal?: AbortSignal };
|
||||
|
||||
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo"; model: string };
|
||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita"; model: string };
|
||||
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
||||
|
||||
function aiApiUrl(config: AiConfig, path: string) {
|
||||
@@ -68,6 +68,9 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
|
||||
if (requestConfig.interfaceType === "gemini-veo") {
|
||||
return createGeminiVeoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
if (requestConfig.interfaceType === "novita-video") {
|
||||
return createNovitaVideoTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
if (requestConfig.interfaceType === "volcengine-ark-video") {
|
||||
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
@@ -100,6 +103,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener
|
||||
if (task.provider === "seedance") return pollSeedanceTask(requestConfig, task, options);
|
||||
if (task.provider === "video-generations") return pollVideoGenerationsTask(requestConfig, task, options);
|
||||
if (task.provider === "gemini-veo") return pollGeminiVeoTask(requestConfig, task, options);
|
||||
if (task.provider === "novita") return pollNovitaVideoTask(requestConfig, task, options);
|
||||
return pollOpenAIVideoTask(requestConfig, task, options);
|
||||
}
|
||||
|
||||
@@ -222,6 +226,57 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) {
|
||||
return { "x-goog-api-key": config.apiKey, ...(contentType ? { "Content-Type": contentType } : {}) };
|
||||
}
|
||||
|
||||
type NovitaVideoResult = { task?: { status?: string; reason?: string }; videos?: Array<{ video_url?: string }> };
|
||||
|
||||
async function createNovitaVideoTask(config: ResolvedAiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||
if (references.length > 1 || videoReferences.length || audioReferences.length) throw new Error("Novita 视频当前只支持 1 张起始图,不支持参考视频或音频");
|
||||
const payload: Record<string, unknown> = {
|
||||
model: modelOptionName(model),
|
||||
prompt: prompt.trim(),
|
||||
duration: normalizeNovitaVideoDuration(config.videoSeconds),
|
||||
};
|
||||
if (references[0]) {
|
||||
payload.image = isPublicMediaUrl(references[0].url || "") ? references[0].url : await imageToDataUrl(references[0]);
|
||||
} else {
|
||||
payload.aspect_ratio = normalizeNovitaVideoRatio(config.size);
|
||||
}
|
||||
try {
|
||||
const created = await channelPost<{ task_id?: string }>(config, novitaVideoUrl(config, "/video/create"), payload, options);
|
||||
if (!created.task_id) throw new Error("Novita 视频接口没有返回任务 ID");
|
||||
return { id: created.task_id, provider: "novita", model };
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "Novita 视频任务创建失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function pollNovitaVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const result = await channelGet<NovitaVideoResult>(config, novitaVideoUrl(config, `/async/task-result?task_id=${encodeURIComponent(task.id)}`), options);
|
||||
const status = result.task?.status || "";
|
||||
if (status === "TASK_STATUS_SUCCEED") {
|
||||
const url = result.videos?.[0]?.video_url || "";
|
||||
if (!url) return { status: "failed", error: "Novita 视频任务已完成但没有返回视频地址" };
|
||||
return { status: "completed", result: await videoResultFromUrl(url, options) };
|
||||
}
|
||||
if (status === "TASK_STATUS_FAILED") return { status: "failed", error: result.task?.reason || "视频生成失败" };
|
||||
return { status: "pending" };
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "Novita 视频任务查询失败"));
|
||||
}
|
||||
}
|
||||
|
||||
function novitaVideoUrl(config: ResolvedAiConfig, path: string) {
|
||||
return `${config.baseUrl.replace(/\/+$/, "")}${path}`;
|
||||
}
|
||||
|
||||
function normalizeNovitaVideoDuration(value: string) {
|
||||
return normalizeSeedanceDuration(value) >= 8 ? "10" : "5";
|
||||
}
|
||||
|
||||
function normalizeNovitaVideoRatio(value: string) {
|
||||
return value === "16:9" || value === "9:16" || value === "1:1" ? value : "16:9";
|
||||
}
|
||||
|
||||
async function channelPost<T>(config: ResolvedAiConfig, upstreamUrl: string, body: unknown, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
|
||||
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
|
||||
Reference in New Issue
Block a user