diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 24b0868b1..b3f69f117 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -218,6 +218,10 @@ export function useCanvasPlayer( // ── 内部引用 ── const decoderRef = useRef(null) const frameQueueRef = useRef(new FrameQueue(10)) + /** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */ + const decodedSegmentsRef = useRef(new Set()) + /** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */ + const decodeGenerationRef = useRef(0) const rafRef = useRef(0) const playStartRef = useRef(0) const playStartOffsetRef = useRef(0) @@ -539,7 +543,9 @@ export function useCanvasPlayer( try { await Promise.race([ decoder.flush(), - new Promise((_, reject) => setTimeout(() => reject(new Error("flush timeout 10s")), 10_000)), + new Promise((_, reject) => + setTimeout(() => reject(new Error("flush timeout 10s")), 10_000), + ), ]) console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`) } catch (e) { @@ -549,6 +555,58 @@ export function useCanvasPlayer( [], ) + /** + * 按需解码当前播放位置 ±1 个片段。 + * 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。 + * 使用"先标记再解码"模式防止并发重复解码,失败时移除标记允许重试。 + */ + const decodeAroundPosition = useCallback( + async (currentTime: number) => { + const metas = segmentMetaRef.current + if (!metas || metas.length === 0) return + + // 记录当前代数,seek 后代数变化则中止 + const gen = decodeGenerationRef.current + + let targetIdx = -1 + let acc = 0 + for (let i = 0; i < metas.length; i++) { + const dur = metas[i].globalEndTime - metas[i].globalStartTime + if (currentTime < acc + dur) { + targetIdx = i + break + } + acc += dur + } + if (targetIdx === -1) targetIdx = metas.length - 1 + + for ( + let i = Math.max(0, targetIdx - 1); + i <= Math.min(metas.length - 1, targetIdx + 1); + i++ + ) { + // seek 已作废当前解码任务 + if (decodeGenerationRef.current !== gen) return + if (decodedSegmentsRef.current.has(i)) continue + const meta = metas[i] + const buffer = segmentDataRef.current.get(meta.assetId) + if (!buffer) continue + // 先标记为解码中,防止下一帧渲染时重复发起解码 + decodedSegmentsRef.current.add(i) + try { + await decodeSegment(buffer, meta) + } catch (e) { + // 解码失败则移除标记,允许后续重试 + decodedSegmentsRef.current.delete(i) + console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e) + } + // await 后再次检查代数,seek 期间不更新标记 + if (decodeGenerationRef.current !== gen) return + } + }, + [decodeSegment], + ) + // ── 标题绘制 ── const drawTitle = useCallback( ( @@ -650,6 +708,8 @@ export function useCanvasPlayer( const elapsed = (performance.now() - playStartRef.current) / 1000 const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration) + // getCurrentFrame 返回 FrameQueue 内部引用,帧生命周期由 FrameQueue 管理 + // (push 淘汰旧帧时 close、clear 时全部 close),渲染层不应 close const frame = frameQueueRef.current.getCurrentFrame(currentTime) ctx.clearRect(0, 0, canvas.width, canvas.height) @@ -673,6 +733,8 @@ export function useCanvasPlayer( } return s }) + // 按需解码当前 ±1 片段 + decodeAroundPosition(currentTime) } if (currentTime >= totalDuration) { @@ -681,12 +743,19 @@ export function useCanvasPlayer( } rafRef.current = requestAnimationFrame(renderFrame) - }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect]) + }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition]) // ── 播放控制 ── const play = useCallback(async () => { if (!state.hasSupport || isDestroyedRef.current) return + // 重播场景:currentTime 已回到起点但 decodedSegmentsRef 仍有旧标记 + // 此时 FrameQueue 中旧帧已被淘汰,需清空标记让 decodeAroundPosition 重新解码 + if (state.currentTime <= 0.1 && decodedSegmentsRef.current.size > 0) { + decodeGenerationRef.current++ + decodedSegmentsRef.current.clear() + } + setState((s) => ({ ...s, isPlaying: true })) playStartRef.current = performance.now() playStartOffsetRef.current = state.currentTime @@ -700,15 +769,19 @@ export function useCanvasPlayer( }, []) const seek = useCallback( - (time: number) => { + async (time: number) => { const clampedTime = Math.max(0, Math.min(time, totalDuration)) setState((s) => ({ ...s, currentTime: clampedTime })) playStartOffsetRef.current = clampedTime playStartRef.current = performance.now() - // seek 后清空帧队列,等待新帧解码 + // seek 时递增解码代数,作废正在进行的异步解码 + decodeGenerationRef.current++ + // 清空帧队列(clear 内部会 close 所有帧)+ 清空已解码标记 frameQueueRef.current.clear() + decodedSegmentsRef.current.clear() + await decodeAroundPosition(clampedTime) }, - [totalDuration], + [totalDuration, decodeAroundPosition], ) const destroy = useCallback(() => { @@ -719,9 +792,12 @@ export function useCanvasPlayer( decoderRef.current.close() } + // 递增代数中止进行中的异步解码,清空帧队列(clear 内部 close 所有帧) + decodeGenerationRef.current++ frameQueueRef.current.clear() segmentDataRef.current.clear() segmentMetaRef.current = [] + decodedSegmentsRef.current.clear() }, []) // ── 预加载下一个片段的数据 ── @@ -797,12 +873,27 @@ export function useCanvasPlayer( videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight } } - // 4. 依次解码每个片段 - for (const meta of metas) { + // 4. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载 + // 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏 + decodedSegmentsRef.current.clear() + const initGen = decodeGenerationRef.current + const initialDecodeCount = Math.min(metas.length, 3) + for (let i = 0; i < initialDecodeCount; i++) { + if (cancelled) break + // seek 或 destroy 已作废当前初始化 + if (decodeGenerationRef.current !== initGen) break + const meta = metas[i] const buffer = segmentDataRef.current.get(meta.assetId) if (!buffer) continue - await decodeSegment(buffer, meta) - if (cancelled) break + // 先标记为解码中,防止重复解码 + decodedSegmentsRef.current.add(i) + try { + await decodeSegment(buffer, meta) + } catch (e) { + // 解码失败则移除标记,允许后续重试 + decodedSegmentsRef.current.delete(i) + console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e) + } } if (!cancelled) { diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 4f6d8b2be..d050126c7 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -114,9 +114,22 @@ export function useStep6Cover({ const anyErr = err as any const statusCode = anyErr?.response?.status - // 400 错误:后端缺少预览视频,自动创建后重试 - if (statusCode === 400) { - console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...") + // 400 错误:精确判断是否为"预览缺失",避免误判其他 400 错误 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errCode = anyErr?.response?.data?.code as string | undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const errMsg = (anyErr?.response?.data?.message || + anyErr?.response?.data?.detail || + "") as string + const isPreviewMissing = + statusCode === 400 && + (errCode?.includes("PREVIEW") || + /预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test( + errMsg, + )) + + if (isPreviewMissing) { + console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...") message.info("正在准备预览视频,请稍候...") try { const previewResp = await createPreview({ @@ -124,23 +137,34 @@ export function useStep6Cover({ asset_ids: assetIds, duration: duration || 30, }) - // 轮询等待预览渲染完成 + // 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底 await new Promise((resolve, reject) => { - const poll = setInterval(async () => { + let finished = false + const done = (fn: () => void) => { + if (finished) return + finished = true + clearTimeout(timeoutId) + fn() + } + const timeoutId = setTimeout(() => { + done(() => reject(new Error("预览生成超时,请稍后重试"))) + }, 120_000) + const poll = async () => { + if (finished) return try { const status = await getPreviewStatus(previewResp.task_id) if (status.status === "completed") { - clearInterval(poll) - resolve() + done(() => resolve()) } else if (status.status === "failed") { - clearInterval(poll) - reject(new Error(status.error_message || "预览渲染失败")) + done(() => reject(new Error(status.error_message || "预览渲染失败"))) + } else { + setTimeout(poll, 2000) } } catch (e) { - clearInterval(poll) - reject(e) + done(() => reject(e)) } - }, 3000) + } + poll() }) message.success("预览视频就绪,重新生成封面...") // 重试封面生成