diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 6e19db61d..884c76633 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -44,15 +44,29 @@ function findCodecConfigRecursive( const visualSampleEntryTypes = ["avc1", "avc3", "hvc1", "hev1"] const isVisualSampleEntry = visualSampleEntryTypes.includes(type) - + if (isVisualSampleEntry) { // VisualSampleEntry: 前 78 字节是固定字段,子 box 在 78 字节之后 // 先尝试 offset+8+78,如果没找到再尝试 offset+8(兼容不同 MP4 结构) - console.log("[findCodecConfig] VisualSampleEntry:", type, "at", offset, "size", size, "trying offset+8+78") + console.log( + "[findCodecConfig] VisualSampleEntry:", + type, + "at", + offset, + "size", + size, + "trying offset+8+78", + ) const childResult1 = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size) if (childResult1) return childResult1 - - console.log("[findCodecConfig] VisualSampleEntry:", type, "at", offset, "trying offset+8 (fallback)") + + console.log( + "[findCodecConfig] VisualSampleEntry:", + type, + "at", + offset, + "trying offset+8 (fallback)", + ) const childResult2 = findCodecConfigRecursive(buffer, offset + 8, offset + size) if (childResult2) return childResult2 } else { @@ -203,6 +217,10 @@ export function useCanvasPlayer( // ── 内部引用 ── const decoderRef = useRef(null) const frameQueueRef = useRef(new FrameQueue(10)) + /** 已解码的片段索引集合,用于按需解码 */ + const decodedSegmentsRef = useRef(new Set()) + /** 上一帧引用,用于在绘制新帧前释放上一帧防止内存泄漏 */ + const lastDrawnFrameRef = useRef(null) const rafRef = useRef(0) const playStartRef = useRef(0) const playStartOffsetRef = useRef(0) @@ -517,6 +535,52 @@ export function useCanvasPlayer( [], ) + /** + * 按需解码当前播放位置 ±1 个片段。 + * 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。 + */ + const decodeAroundPosition = useCallback( + async (currentTime: number) => { + const metas = segmentMetaRef.current + if (!metas || metas.length === 0) return + + // 找到当前时间对应的片段索引 + 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 + + // 解码当前 ±1 片段 + for ( + let i = Math.max(0, targetIdx - 1); + i <= Math.min(metas.length - 1, targetIdx + 1); + i++ + ) { + 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) + } + } + }, + [decodeSegment], + ) + // ── 标题绘制 ── const drawTitle = useCallback( ( @@ -620,11 +684,19 @@ export function useCanvasPlayer( const frame = frameQueueRef.current.getCurrentFrame(currentTime) + // 释放上一帧,防止 VideoFrame 内存泄漏 + if (lastDrawnFrameRef.current) { + lastDrawnFrameRef.current.close() + lastDrawnFrameRef.current = null + } + ctx.clearRect(0, 0, canvas.width, canvas.height) if (frame) { const rect = computeDrawRect(canvas.width, canvas.height) ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh) + // 保持引用,下一帧绘制时再释放 + lastDrawnFrameRef.current = frame } if (titleSettings?.text) { @@ -641,6 +713,8 @@ export function useCanvasPlayer( } return s }) + // 按需解码当前 ±1 片段 + decodeAroundPosition(currentTime) } if (currentTime >= totalDuration) { @@ -649,7 +723,7 @@ export function useCanvasPlayer( } rafRef.current = requestAnimationFrame(renderFrame) - }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect]) + }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition]) // ── 播放控制 ── const play = useCallback(async () => { @@ -668,15 +742,22 @@ 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 后清空帧队列 + 释放上一帧 frameQueueRef.current.clear() + if (lastDrawnFrameRef.current) { + lastDrawnFrameRef.current.close() + lastDrawnFrameRef.current = null + } + // 清空已解码标记,重新解码 seek 目标区域 + decodedSegmentsRef.current.clear() + await decodeAroundPosition(clampedTime) }, - [totalDuration], + [totalDuration, decodeAroundPosition], ) const destroy = useCallback(() => { @@ -687,9 +768,16 @@ export function useCanvasPlayer( decoderRef.current.close() } + // 释放上一帧引用 + if (lastDrawnFrameRef.current) { + lastDrawnFrameRef.current.close() + lastDrawnFrameRef.current = null + } + frameQueueRef.current.clear() segmentDataRef.current.clear() segmentMetaRef.current = [] + decodedSegmentsRef.current.clear() }, []) // ── 预加载下一个片段的数据 ── @@ -765,12 +853,24 @@ 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 initialDecodeCount = Math.min(metas.length, 3) + for (let i = 0; i < initialDecodeCount; i++) { + if (cancelled) 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..4b59a927f 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,39 @@ export function useStep6Cover({ asset_ids: assetIds, duration: duration || 30, }) - // 轮询等待预览渲染完成 + // 轮询等待预览渲染完成,双重超时保护 + const maxPolls = 60 // 最多轮询 60 次(每 2 秒,共 120 秒) + let pollCount = 0 await new Promise((resolve, reject) => { + // 总超时保护:120 秒后强制 reject + const timeoutId = setTimeout(() => { + clearInterval(poll) + reject(new Error("预览生成超时,请稍后重试")) + }, 120_000) const poll = setInterval(async () => { + pollCount++ try { const status = await getPreviewStatus(previewResp.task_id) if (status.status === "completed") { + clearTimeout(timeoutId) clearInterval(poll) resolve() } else if (status.status === "failed") { + clearTimeout(timeoutId) clearInterval(poll) reject(new Error(status.error_message || "预览渲染失败")) } + if (pollCount >= maxPolls) { + clearTimeout(timeoutId) + clearInterval(poll) + reject(new Error("预览生成超时,请稍后重试")) + } } catch (e) { + clearTimeout(timeoutId) clearInterval(poll) reject(e) } - }, 3000) + }, 2000) }) message.success("预览视频就绪,重新生成封面...") // 重试封面生成