From 58a7cc20c9b7431c66e895e590d7f8e410f4d9ff Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 19 Aug 2026 20:16:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20Code=20Rev?= =?UTF-8?q?iew=20=E9=98=BB=E5=A1=9E=E7=BA=A7=E9=97=AE=E9=A2=98=EF=BC=88use?= =?UTF-8?q?CanvasPlayer=20=E8=A7=A3=E7=A0=81=E7=AD=96=E7=95=A5+=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=B3=84=E6=BC=8F+useStep6Cover=20=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E8=B6=85=E6=97=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCanvasPlayer: 初始化改为按需解码(前3片段+decodeAroundPosition动态加载) - useCanvasPlayer: 新增 lastDrawnFrameRef 追踪,绘制新帧前关闭上一帧防止内存泄漏 - useCanvasPlayer: seek 时重置解码状态并重新解码当前区域 - useStep6Cover: 轮询增加 pollCount(max 60) + setTimeout 120s 双重超时保护 - useStep6Cover: 400错误精确匹配预览缺失模式,避免误触发自动修复 Closes: #1437, #1438, #1439 --- .../pages/generate/hooks/useCanvasPlayer.ts | 75 +++++++++++++++++-- .../src/pages/generate/hooks/useStep6Cover.ts | 32 +++++++- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 12933fc01..3fc1fd1c5 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -219,6 +219,8 @@ export function useCanvasPlayer( const isDestroyedRef = useRef(false) const lastProgressUpdateRef = useRef(0) const descriptionCache = useRef>(new Map()) + const decodedSegmentsRef = useRef(new Set()) + const lastDrawnFrameRef = useRef(null) // 计算总时长 const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0) @@ -625,6 +627,43 @@ export function useCanvasPlayer( [], ) + // ── 按需解码:根据当前播放位置解码附近片段 ── + const decodeAroundPosition = useCallback( + async (currentTime: number) => { + const metas = segmentMetaRef.current + if (metas.length === 0) return + + // 找到当前时间对应的片段 + let currentIdx = 0 + for (let i = 0; i < metas.length; i++) { + if (currentTime >= metas[i].globalStartTime && currentTime < metas[i].globalEndTime) { + currentIdx = i + break + } + if (currentTime >= metas[metas.length - 1].globalEndTime) { + currentIdx = metas.length - 1 + } + } + + // 解码当前片段及前后各 1 个片段 + const startIdx = Math.max(0, currentIdx - 1) + const endIdx = Math.min(metas.length - 1, currentIdx + 1) + + for (let i = startIdx; i <= endIdx; i++) { + if (decodedSegmentsRef.current.has(i)) continue + if (isDestroyedRef.current) break + + const meta = metas[i] + const buffer = segmentDataRef.current.get(meta.assetId) + if (!buffer) continue + + decodedSegmentsRef.current.add(i) + await decodeSegment(buffer, meta) + } + }, + [decodeSegment], + ) + // ── Canvas 渲染循环 ── const renderFrame = useCallback(() => { if (isDestroyedRef.current) return @@ -642,6 +681,11 @@ export function useCanvasPlayer( ctx.clearRect(0, 0, canvas.width, canvas.height) if (frame) { + // 关闭上一帧,防止 VideoFrame 资源泄漏 + if (lastDrawnFrameRef.current) { + lastDrawnFrameRef.current.close() + } + lastDrawnFrameRef.current = frame const rect = computeDrawRect(canvas.width, canvas.height) ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh) } @@ -660,6 +704,9 @@ export function useCanvasPlayer( } return s }) + + // 按需解码后续片段(每 200ms 检查一次,避免阻塞渲染) + decodeAroundPosition(currentTime) } if (currentTime >= totalDuration) { @@ -668,7 +715,7 @@ export function useCanvasPlayer( } rafRef.current = requestAnimationFrame(renderFrame) - }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect]) + }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition]) // ── 播放控制 ── const play = useCallback(async () => { @@ -687,15 +734,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 + } + decodedSegmentsRef.current.clear() + // 立即解码 seek 位置附近的片段 + await decodeAroundPosition(clampedTime) }, - [totalDuration], + [totalDuration, decodeAroundPosition], ) const destroy = useCallback(() => { @@ -706,10 +760,15 @@ export function useCanvasPlayer( decoderRef.current.close() } + if (lastDrawnFrameRef.current) { + lastDrawnFrameRef.current.close() + lastDrawnFrameRef.current = null + } frameQueueRef.current.clear() segmentDataRef.current.clear() segmentMetaRef.current = [] descriptionCache.current.clear() + decodedSegmentsRef.current.clear() }, []) // ── 预加载下一个片段的数据 ── @@ -759,10 +818,14 @@ export function useCanvasPlayer( videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight } } - // 4. 依次解码每个片段 - for (const meta of metas) { + // 4. 按需解码:初始只解码前 3 个片段(当前 + 前后各 1),避免环形缓冲区溢出导致黑屏 + decodedSegmentsRef.current.clear() + const initialEnd = Math.min(metas.length, 3) + for (let i = 0; i < initialEnd; i++) { + const meta = metas[i] const buffer = segmentDataRef.current.get(meta.assetId) if (!buffer) continue + decodedSegmentsRef.current.add(i) await decodeSegment(buffer, meta) if (isDestroyedRef.current) break } diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 4f6d8b2be..d65d7aefe 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -114,9 +114,20 @@ export function useStep6Cover({ const anyErr = err as any const statusCode = anyErr?.response?.status - // 400 错误:后端缺少预览视频,自动创建后重试 - if (statusCode === 400) { - console.log("[Step6] 后端返回 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") || + errCode?.includes("preview") || + /预览.*(不存在|缺失|未找到|not found)/i.test(errMsg) || + /preview.*(not found|missing|not exist)/i.test(errMsg)) + + if (isPreviewMissing) { + console.log("[Step6] 后端返回 400(预览缺失),尝试自动创建预览渲染任务...") message.info("正在准备预览视频,请稍候...") try { const previewResp = await createPreview({ @@ -124,9 +135,17 @@ export function useStep6Cover({ asset_ids: assetIds, duration: duration || 30, }) - // 轮询等待预览渲染完成 + // 轮询等待预览渲染完成(最大 60 次 / 总超时 2 分钟) await new Promise((resolve, reject) => { + let pollCount = 0 + const maxPolls = 60 const poll = setInterval(async () => { + pollCount++ + if (pollCount > maxPolls) { + clearInterval(poll) + reject(new Error("预览生成超时,请稍后重试")) + return + } try { const status = await getPreviewStatus(previewResp.task_id) if (status.status === "completed") { @@ -141,6 +160,11 @@ export function useStep6Cover({ reject(e) } }, 3000) + // 总超时保护:2 分钟后强制终止 + setTimeout(() => { + clearInterval(poll) + reject(new Error("预览生成超时,请稍后重试")) + }, 120_000) }) message.success("预览视频就绪,重新生成封面...") // 重试封面生成 -- 2.54.0 From 03b6e0e89a95bae30ae64438316fc900eee4c74a Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 19 Aug 2026 20:25:32 +0800 Subject: [PATCH 2/2] =?UTF-8?q?style:=20=E4=BF=AE=E5=A4=8D=20prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E9=97=AE=E9=A2=98=EF=BC=88useStep6Cover=20?= =?UTF-8?q?=E9=95=BF=E8=A1=8C=E6=8D=A2=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/pages/generate/hooks/useStep6Cover.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index d65d7aefe..33f9694d9 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -118,7 +118,9 @@ export function useStep6Cover({ // 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 errMsg = (anyErr?.response?.data?.message || + anyErr?.response?.data?.detail || + "") as string const isPreviewMissing = statusCode === 400 && (errCode?.includes("PREVIEW") || -- 2.54.0