From 8d0b5ecdb8491e9f74ef081acbee5d860a0ab3e6 Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Mon, 8 Jun 2026 15:26:35 +0800 Subject: [PATCH] feat(video): implement video generation task creation and polling mechanism --- docs/content/docs/progress/pending-test.mdx | 1 + web/src/app/(user)/video/page.tsx | 102 +++++++++++++++----- web/src/services/api/video.ts | 90 +++++++++++------ 3 files changed, 139 insertions(+), 54 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 49b2d93..7151092 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。 - 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。 - 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。 - 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。 diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx index 0d8946d..cafbfb6 100644 --- a/web/src/app/(user)/video/page.tsx +++ b/web/src/app/(user)/video/page.tsx @@ -16,7 +16,7 @@ import { formatBytes, formatDuration } from "@/lib/image-utils"; import { boolConfig, isSeedanceVideoConfig, normalizeSeedanceRatio, seedanceReferenceLabel, seedanceVideoReferenceError, seedanceVideoReferenceHint, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video"; import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; -import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video"; +import { createVideoGenerationTask, pollVideoGenerationTask, storeGeneratedVideo, type VideoGenerationTask } from "@/services/api/video"; import { useAssetStore } from "@/stores/use-asset-store"; import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -56,7 +56,8 @@ type GenerationLog = { size: string; resolution: string; seconds: string; - status: "成功" | "失败"; + status: "生成中" | "成功" | "失败"; + task?: VideoGenerationTask; video?: GeneratedVideo; error?: string; }; @@ -71,6 +72,7 @@ const logStore = localforage.createInstance({ name: "infinite-canvas", storeName export default function VideoPage() { const { message } = App.useApp(); const fileInputRef = useRef(null); + const activeLogIdsRef = useRef>(new Set()); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); @@ -174,26 +176,15 @@ export default function VideoPage() { const batchStartedAt = performance.now(); setStartedAt(batchStartedAt); try { - const stored = await storeGeneratedVideo(await requestVideoGeneration(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences)); - const nextVideo: GeneratedVideo = { - id: nanoid(), - url: stored.url, - storageKey: stored.storageKey, - durationMs: performance.now() - batchStartedAt, - width: stored.width || 1280, - height: stored.height || 720, - bytes: stored.bytes, - mimeType: stored.mimeType, - }; - setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); - saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo })); - message.success("视频已生成"); + const task = await createVideoGenerationTask(snapshot.config, snapshot.text, snapshot.references, snapshot.videoReferences, snapshot.audioReferences); + const log = buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: 0, status: "生成中", task }); + await saveLog(log); + void pollGenerationLog(log, snapshot.config); } catch (error) { const errorMessage = error instanceof Error ? error.message : "生成失败"; setResults([{ id: nanoid(), status: "failed", error: errorMessage }]); - saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); + await saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, videoReferences: snapshot.videoReferences, audioReferences: snapshot.audioReferences, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage })); message.error(errorMessage); - } finally { setRunning(false); } }; @@ -276,11 +267,68 @@ export default function VideoPage() { setDeleteConfirmOpen(false); }; - const saveLog = (log: GenerationLog) => { - void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs); + const saveLog = async (log: GenerationLog) => { + await logStore.setItem(log.id, serializeLog(log)); + await refreshLogs(); }; - const refreshLogs = async () => setLogs(await readStoredLogs()); + const refreshLogs = async () => { + const nextLogs = await readStoredLogs(); + setLogs(nextLogs); + resumePendingLogs(nextLogs); + return nextLogs; + }; + + const resumePendingLogs = (items: GenerationLog[]) => { + for (const log of items) { + if (log.status === "生成中" && log.task) void pollGenerationLog(log); + } + }; + + const pollGenerationLog = async (log: GenerationLog, configOverride?: AiConfig) => { + if (!log.task || activeLogIdsRef.current.has(log.id)) return; + activeLogIdsRef.current.add(log.id); + setRunning(true); + setStartedAt((value) => value || performance.now()); + setResults((value) => (value.length ? value : [{ id: log.id, status: "pending" }])); + const taskConfig = buildVideoConfig({ ...effectiveConfig, ...log.config }, log.task.model || log.model); + try { + for (let attempt = 0; attempt < 120; attempt += 1) { + const state = await pollVideoGenerationTask(configOverride || taskConfig, log.task); + if (state.status === "completed") { + const stored = await storeGeneratedVideo(state.result); + const nextVideo: GeneratedVideo = { + id: nanoid(), + url: stored.url, + storageKey: stored.storageKey, + durationMs: Date.now() - log.createdAt, + width: stored.width || 1280, + height: stored.height || 720, + bytes: stored.bytes, + mimeType: stored.mimeType, + }; + setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]); + await saveLog({ ...log, status: "成功", durationMs: nextVideo.durationMs, video: nextVideo, error: undefined }); + message.success("视频已生成"); + return; + } + if (state.status === "failed") throw new Error(state.error); + if (attempt === 119) throw new Error("视频生成超时,请稍后重试"); + await delay(log.task.provider === "seedance" ? 5000 : 2500); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "生成失败"; + setResults([{ id: log.id, status: "failed", error: errorMessage }]); + await saveLog({ ...log, status: "失败", durationMs: Date.now() - log.createdAt, error: errorMessage }); + message.error(errorMessage); + } finally { + activeLogIdsRef.current.delete(log.id); + if (!activeLogIdsRef.current.size) { + setRunning(false); + setStartedAt(0); + } + } + }; const previewGenerationLog = (log: GenerationLog) => { setPreviewLog(log); @@ -295,7 +343,7 @@ export default function VideoPage() { if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds); if (log.config.videoGenerateAudio) updateConfig("videoGenerateAudio", log.config.videoGenerateAudio); if (log.config.videoWatermark) updateConfig("videoWatermark", log.config.videoWatermark); - setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]); + setResults(log.status === "生成中" ? [{ id: log.id, status: "pending" }] : log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]); }; return ( @@ -608,7 +656,7 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
- + {log.status} @@ -670,6 +718,7 @@ async function normalizeLog(log: Partial): Promise resolution: normalizeResolution(log.resolution || config.vquality || ""), seconds: log.seconds || config.videoSeconds || "", status: log.status || "成功", + task: log.task, video, error: log.error, }; @@ -739,7 +788,7 @@ function normalizeLogConfig(log: Partial): GenerationLogConfig { }; } -function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog { +function buildLog({ prompt, model, config, references, videoReferences, audioReferences, durationMs, status, task, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; videoReferences: ReferenceVideo[]; audioReferences: ReferenceAudio[]; durationMs: number; status: GenerationLog["status"]; task?: VideoGenerationTask; video?: GeneratedVideo; error?: string }): GenerationLog { const logConfig = { model: config.model, videoModel: config.videoModel, @@ -765,6 +814,7 @@ function buildLog({ prompt, model, config, references, videoReferences, audioRef resolution: logConfig.vquality, seconds: logConfig.videoSeconds, status, + task, video, error, }; @@ -797,3 +847,7 @@ function normalizeVideoSize(value: string) { function normalizeResolution(value: string) { return normalizeVideoResolutionValue(value); } + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index ef02431..b5f36fb 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -21,6 +21,8 @@ type ApiEnvelope = T | { code?: number; data?: T | null; msg?: string }; type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number }; export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string }; +export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string }; +export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string }; function aiApiUrl(config: AiConfig, path: string) { return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path); @@ -44,15 +46,33 @@ function refreshRemoteUser(config: AiConfig) { } export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise { + const task = await createVideoGenerationTask(config, prompt, references, videoReferences, audioReferences); + const delayMs = task.provider === "seedance" ? 5000 : 2500; + for (let attempt = 0; attempt < 120; attempt += 1) { + const state = await pollVideoGenerationTask(config, task); + if (state.status === "completed") return state.result; + if (state.status === "failed") throw new Error(state.error); + if (attempt === 119) throw new Error(`${task.provider === "seedance" ? "Seedance " : ""}视频生成超时,请稍后重试`); + await delay(delayMs); + } + throw new Error("视频生成超时,请稍后重试"); +} + +export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise { const model = (config.model || config.videoModel).trim(); assertVideoConfig(config, model); if (isSeedanceVideoConfig({ ...config, model })) { - return requestSeedanceGeneration(config, model, prompt, references, videoReferences, audioReferences); + return createSeedanceTask(config, model, prompt, references, videoReferences, audioReferences); } if (videoReferences.length || audioReferences.length) { throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材"); } - return requestOpenAIVideoGeneration(config, model, prompt, references); + return createOpenAIVideoTask(config, model, prompt, references); +} + +export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask): Promise { + assertVideoConfig(config, task.model); + return task.provider === "seedance" ? pollSeedanceTask(config, task) : pollOpenAIVideoTask(config, task); } export async function storeGeneratedVideo(result: VideoGenerationResult): Promise { @@ -61,7 +81,7 @@ export async function storeGeneratedVideo(result: VideoGenerationResult): Promis throw new Error("视频接口没有返回可播放的视频"); } -async function requestOpenAIVideoGeneration(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]) { +async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[]): Promise { const body = new FormData(); body.append("model", model); body.append("prompt", prompt); @@ -74,23 +94,29 @@ async function requestOpenAIVideoGeneration(config: AiConfig, model: string, pro try { const created = unwrapVideoResponse((await axios.post(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config) })).data); if (!created.id) throw new Error("视频接口没有返回任务 ID"); - for (let attempt = 0; attempt < 120; attempt += 1) { - const video = unwrapVideoResponse((await axios.get(aiApiUrl(config, `/videos/${created.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data); - if (video.status === "completed") break; - if (video.status === "failed" || video.status === "cancelled") throw new Error(video.error?.message || "视频生成失败"); - if (attempt === 119) throw new Error("视频生成超时,请稍后重试"); - await delay(2500); - } - const content = await axios.get(aiApiUrl(config, `/videos/${created.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" }); - await assertVideoBlob(content.data); - refreshRemoteUser(config); - return { blob: content.data }; + return { id: created.id, provider: "openai", model }; } catch (error) { - throw new Error(readAxiosError(error, "视频生成失败")); + throw new Error(readAxiosError(error, "视频任务创建失败")); } } -async function requestSeedanceGeneration(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]) { +async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise { + try { + const video = unwrapVideoResponse((await axios.get(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data); + if (video.status === "completed") { + const content = await axios.get(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" }); + await assertVideoBlob(content.data); + refreshRemoteUser(config); + return { status: "completed", result: { blob: content.data } }; + } + if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" }; + return { status: "pending" }; + } catch (error) { + throw new Error(readAxiosError(error, "视频任务查询失败")); + } +} + +async function createSeedanceTask(config: AiConfig, model: string, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[]): Promise { if (audioReferences.length && !references.length && !videoReferences.length) { throw new Error("Seedance 参考音频不能单独使用,请同时添加参考图或参考视频"); } @@ -111,21 +137,25 @@ async function requestSeedanceGeneration(config: AiConfig, model: string, prompt try { const created = unwrapSeedanceTask((await axios.post>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json") })).data); if (!created.id) throw new Error("Seedance 接口没有返回任务 ID"); - for (let attempt = 0; attempt < 120; attempt += 1) { - const task = unwrapSeedanceTask((await axios.get>(seedanceApiUrl(config, created.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined })).data); - if (task.status === "succeeded") { - const url = task.content?.video_url; - if (!url) throw new Error("Seedance 任务成功但没有返回视频 URL"); - refreshRemoteUser(config); - return videoResultFromUrl(url); - } - if (task.status === "failed" || task.status === "cancelled" || task.status === "expired") throw new Error(task.error?.message || `Seedance 视频生成${task.status === "expired" ? "超时" : "失败"}`); - if (attempt === 119) throw new Error("Seedance 视频生成超时,请稍后重试"); - await delay(5000); - } - throw new Error("Seedance 视频生成超时,请稍后重试"); + return { id: created.id, provider: "seedance", model }; } catch (error) { - throw new Error(readAxiosError(error, "Seedance 视频生成失败")); + throw new Error(readAxiosError(error, "Seedance 任务创建失败")); + } +} + +async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise { + try { + const state = unwrapSeedanceTask((await axios.get>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data); + if (state.status === "succeeded") { + const url = state.content?.video_url; + if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" }; + refreshRemoteUser(config); + return { status: "completed", result: await videoResultFromUrl(url) }; + } + if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` }; + return { status: "pending" }; + } catch (error) { + throw new Error(readAxiosError(error, "Seedance 任务查询失败")); } }