mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
fix(video): enhance video generation error handling and URL retrieval
This commit is contained in:
+4
-3
@@ -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
|
||||
|
||||
|
||||
@@ -28,4 +28,6 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
|
||||
- 环境变量:移除过期的后端 `.env.example` 示例文件。
|
||||
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
|
||||
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
|
||||
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
|
||||
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user