fix(video): enhance video generation error handling and URL retrieval

This commit is contained in:
HouYunFei
2026-07-08 22:23:18 +08:00
parent d747527e2a
commit f2a36e86b2
3 changed files with 53 additions and 23 deletions
+47 -20
View File
@@ -8,15 +8,18 @@ import { buildApiUrl, modelOptionName, resolveModelRequestConfig, type AiConfig
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
type VideoResponse = { id: string; status?: string; error?: { message?: string } };
type ApiVideoResponse = VideoResponse | { code?: number; data?: VideoResponse | null; msg?: string };
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 SeedanceTask = {
id: string;
status?: "queued" | "running" | "succeeded" | "failed" | "cancelled" | "expired";
status?: "queued" | "running" | "succeeded" | "completed" | "failed" | "cancelled" | "expired";
error?: { code?: string; message?: string } | null;
content?: { video_url?: string; last_frame_url?: string } | null;
content?: { video_url?: string; url?: string; last_frame_url?: string } | null;
url?: string;
result_url?: string;
video_url?: string;
};
type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
type ApiEnvelope<T> = T | { code?: number | string; data?: T | null; msg?: string; message?: string; error?: { message?: string } };
type RequestOptions = { signal?: AbortSignal };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
@@ -69,7 +72,13 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
if (result.blob) return uploadMediaFile(result.blob, "video");
if (result.url) return { url: result.url, storageKey: "", bytes: 0, mimeType: result.mimeType || "video/mp4" };
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("视频接口没有返回可播放的视频");
}
@@ -95,12 +104,14 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
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: video.error?.message || "视频生成失败" };
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: readApiErrorMessage(video.error?.message) || "视频生成失败" };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "视频任务查询失败"));
@@ -137,12 +148,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data);
if (state.status === "succeeded") {
const url = state.content?.video_url;
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
return { status: "completed", result: await videoResultFromUrl(url, options) };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
const url = videoResultUrl(state);
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
if (state.status === "succeeded" || state.status === "completed") return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: readApiErrorMessage(state.error?.message) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
return { status: "pending" };
} catch (error) {
throw new Error(readAxiosError(error, "Seedance 任务查询失败"));
@@ -264,22 +273,40 @@ function unwrapSeedanceTask(payload: ApiEnvelope<SeedanceTask>) {
function unwrapEnvelope<T>(payload: ApiEnvelope<T>, emptyMessage: string): T {
if (!payload) throw new Error(emptyMessage);
if (typeof payload === "object" && "code" in payload && typeof payload.code === "number") {
if (payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (typeof payload === "object" && "code" in payload && payload.code !== undefined) {
if (payload.code !== 0 && payload.code !== "0") throw new Error(readApiErrorMessage(payload) || "请求失败");
if (!payload.data) throw new Error(emptyMessage);
return payload.data;
}
return payload as T;
}
function videoResultUrl(payload: VideoResponse | SeedanceTask) {
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 {
return readApiErrorMessage(JSON.parse(value)) || value;
} catch {
return value;
}
}
if (typeof value !== "object") return "";
const payload = value as { msg?: unknown; message?: unknown; error?: { message?: unknown } };
return readApiErrorMessage(payload.msg) || readApiErrorMessage(payload.message) || readApiErrorMessage(payload.error?.message);
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; message?: string; code?: number | string }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || statusMessage(error.response?.status, fallback);
return readApiErrorMessage(responseData) || statusMessage(error.response?.status, fallback);
}
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? error.message : fallback;
return error instanceof Error ? readApiErrorMessage(error.message) || error.message : fallback;
}
function statusMessage(status: number | undefined, fallback: string) {
@@ -296,8 +323,8 @@ async function assertVideoBlob(blob: Blob) {
} catch {
return;
}
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "视频下载失败");
if (payload.error?.message) throw new Error(payload.error.message);
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(readApiErrorMessage(payload) || "视频下载失败");
if (payload.error?.message) throw new Error(readApiErrorMessage(payload.error.message) || payload.error.message);
}
function isPublicMediaUrl(value: string) {