From f2a36e86b2a2b3d39593aa199a6e25c21446f855 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Wed, 8 Jul 2026 22:23:18 +0800 Subject: [PATCH] fix(video): enhance video generation error handling and URL retrieval --- CHANGELOG.md | 7 ++- docs/content/docs/progress/pending-test.mdx | 2 + web/src/services/api/video.ts | 67 +++++++++++++++------ 3 files changed, 53 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd8f5b..2ce5643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,14 @@ + [新增] 新增Codex App插件支持。 + [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab。 -+ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。 -+ [修复] 修复 Gemini 调用格式生图未传递尺寸比例配置的问题。 + [新增] 新增GitHub Pages 前端静态站点发布 workflow。 + [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。 ++ [调整] Docker 运行镜像改为 nginx 静态托管。 ++ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。 ++ [修复] 修复 Gemini 调用格式生图未传递尺寸比例配置的问题。 + [修复] 修复前端 TypeScript 构建报错。 + [修复] 修复画布生成配置切换文本/视频/音频模式时模型仍显示为生图模型的问题。 -+ [调整] Docker 运行镜像改为 nginx 静态托管。 ++ [修复] 兼容中转站视频任务直接返回视频 URL 且没有 `/content` 接口的情况,并优化失败原因展示。 ## v0.5.0 - 2026-07-05 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a36f16a..1bf0c23 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -28,4 +28,6 @@ description: 当前版本已实现但仍需人工验证的变更项 - Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。 - 环境变量:移除过期的后端 `.env.example` 示例文件。 - 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。 +- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。 +- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。 - 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。 diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index f5c43f5..d99bb78 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -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 | { code?: number; data?: T | null; msg?: string }; +type ApiEnvelope = 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 { 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 { 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: 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 { try { const state = unwrapSeedanceTask((await axios.get>(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) { function unwrapEnvelope(payload: ApiEnvelope, 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) {