From f729524ad05ee1f99b978447c1c81d9d658dd846 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 20 Aug 2026 11:28:10 +0800 Subject: [PATCH 1/3] =?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=20v3?= =?UTF-8?q?=EF=BC=88=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+=E8=BD=AE=E8=AF=A2=E8=B6=85?= =?UTF-8?q?=E6=97=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useCanvasPlayer (PR #1437 + #1438): - 初始化解码改为按需模式:仅解码前3片段 + decodeAroundPosition 动态加载 ±1 片段 - 解码标记采用先add再decode模式(防并发重入),catch中delete允许失败重试 - 新增 lastDrawnFrameRef 追踪上一帧,绘制新帧前 close() 释放防止内存泄漏 - seek 时清空帧队列+释放上一帧+重置解码标记+重新解码目标区域 - destroy 增加帧清理和解码状态重置 useStep6Cover (PR #1439): - 轮询增加 pollCount(max 60) + setTimeout 120s 双重超时保护 - 所有退出路径均正确 clearTimeout + clearInterval - 400错误精确匹配预览缺失模式(errCode/message正则),避免误触发自动修复 Closes: #1437, #1438, #1439 --- .../pages/generate/hooks/useCanvasPlayer.ts | 104 ++++++++++++++++-- .../src/pages/generate/hooks/useStep6Cover.ts | 38 ++++++- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 24b0868b1..d927a2621 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()) + /** 上一帧引用,绘制新帧前释放防止 VideoFrame 内存泄漏 */ + const lastDrawnFrameRef = useRef(null) 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,51 @@ 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 + + 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( ( @@ -652,11 +703,18 @@ 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) { @@ -673,6 +731,8 @@ export function useCanvasPlayer( } return s }) + // 按需解码当前 ±1 片段 + decodeAroundPosition(currentTime) } if (currentTime >= totalDuration) { @@ -681,7 +741,7 @@ export function useCanvasPlayer( } rafRef.current = requestAnimationFrame(renderFrame) - }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect]) + }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition]) // ── 播放控制 ── const play = useCallback(async () => { @@ -700,15 +760,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(() => { @@ -719,9 +786,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() }, []) // ── 预加载下一个片段的数据 ── @@ -797,12 +871,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..129f489b7 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,38 @@ export function useStep6Cover({ asset_ids: assetIds, duration: duration || 30, }) - // 轮询等待预览渲染完成 + // 轮询等待预览渲染完成,双重超时保护 + const maxPolls = 60 + let pollCount = 0 await new Promise((resolve, 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("预览视频就绪,重新生成封面...") // 重试封面生成 -- 2.54.0 From e3e57d236a463903fca5adf4a4b1876e34ca0086 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 20 Aug 2026 11:44:32 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20Code=20Rev?= =?UTF-8?q?iew=20v4=EF=BC=88VideoFrame=20=E6=89=80=E6=9C=89=E6=9D=83+?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E5=B9=B6=E5=8F=91=E7=AB=9E=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 lastDrawnFrameRef:getCurrentFrame 返回 FrameQueue 内部引用,帧生命周期由 FrameQueue 管理(push 淘汰/close 时释放),渲染层不应 close - 引入 decodeGenerationRef 计数器:seek/destroy 时递增,decodeAroundPosition 在 await 前后检查代数是否匹配,避免并发竞态 - 初始化解码循环同样加入 generation 检查 --- .../pages/generate/hooks/useCanvasPlayer.ts | 40 +++++++++---------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index d927a2621..9c260776a 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -220,8 +220,8 @@ export function useCanvasPlayer( const frameQueueRef = useRef(new FrameQueue(10)) /** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */ const decodedSegmentsRef = useRef(new Set()) - /** 上一帧引用,绘制新帧前释放防止 VideoFrame 内存泄漏 */ - const lastDrawnFrameRef = useRef(null) + /** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */ + const decodeGenerationRef = useRef(0) const rafRef = useRef(0) const playStartRef = useRef(0) const playStartOffsetRef = useRef(0) @@ -565,6 +565,9 @@ export function useCanvasPlayer( 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++) { @@ -582,6 +585,8 @@ export function useCanvasPlayer( 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) @@ -595,6 +600,8 @@ export function useCanvasPlayer( decodedSegmentsRef.current.delete(i) console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e) } + // await 后再次检查代数,seek 期间不更新标记 + if (decodeGenerationRef.current !== gen) return } }, [decodeSegment], @@ -701,20 +708,15 @@ 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) - // 释放上一帧 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) { @@ -765,13 +767,10 @@ export function useCanvasPlayer( setState((s) => ({ ...s, currentTime: clampedTime })) playStartOffsetRef.current = clampedTime playStartRef.current = performance.now() - // seek 后清空帧队列 + 释放上一帧 + // seek 时递增解码代数,作废正在进行的异步解码 + decodeGenerationRef.current++ + // 清空帧队列(clear 内部会 close 所有帧)+ 清空已解码标记 frameQueueRef.current.clear() - if (lastDrawnFrameRef.current) { - lastDrawnFrameRef.current.close() - lastDrawnFrameRef.current = null - } - // 清空已解码标记,重新解码 seek 目标区域 decodedSegmentsRef.current.clear() await decodeAroundPosition(clampedTime) }, @@ -786,12 +785,8 @@ export function useCanvasPlayer( decoderRef.current.close() } - // 释放上一帧引用 - if (lastDrawnFrameRef.current) { - lastDrawnFrameRef.current.close() - lastDrawnFrameRef.current = null - } - + // 递增代数中止进行中的异步解码,清空帧队列(clear 内部 close 所有帧) + decodeGenerationRef.current++ frameQueueRef.current.clear() segmentDataRef.current.clear() segmentMetaRef.current = [] @@ -874,9 +869,12 @@ export function useCanvasPlayer( // 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 -- 2.54.0 From 9cdbe6e97d202d0d4da58f6696e6240cfe7b53ea Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 20 Aug 2026 11:49:24 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20AI=20Code=20Rev?= =?UTF-8?q?iew=20v5=EF=BC=88=E8=BD=AE=E8=AF=A2=E8=AF=B7=E6=B1=82=E9=87=8D?= =?UTF-8?q?=E5=8F=A0+=E9=87=8D=E6=92=AD=E9=BB=91=E5=B1=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useStep6Cover: setInterval 改为递归 setTimeout,确保上一次请求完成后再发起下一次,避免请求风暴 - useCanvasPlayer play(): 检测重播场景(currentTime 接近0但 decodedSegmentsRef 非空)时清空标记并递增 generation,让 decodeAroundPosition 重新解码 --- .../pages/generate/hooks/useCanvasPlayer.ts | 7 ++++ .../src/pages/generate/hooks/useStep6Cover.ts | 40 +++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 9c260776a..b3f69f117 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -749,6 +749,13 @@ export function useCanvasPlayer( 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 diff --git a/apps/web/src/pages/generate/hooks/useStep6Cover.ts b/apps/web/src/pages/generate/hooks/useStep6Cover.ts index 129f489b7..d050126c7 100644 --- a/apps/web/src/pages/generate/hooks/useStep6Cover.ts +++ b/apps/web/src/pages/generate/hooks/useStep6Cover.ts @@ -137,38 +137,34 @@ export function useStep6Cover({ asset_ids: assetIds, duration: duration || 30, }) - // 轮询等待预览渲染完成,双重超时保护 - const maxPolls = 60 - let pollCount = 0 + // 轮询等待预览渲染完成:递归 setTimeout 避免请求重叠 + 120s 超时兜底 await new Promise((resolve, reject) => { + let finished = false + const done = (fn: () => void) => { + if (finished) return + finished = true + clearTimeout(timeoutId) + fn() + } const timeoutId = setTimeout(() => { - clearInterval(poll) - reject(new Error("预览生成超时,请稍后重试")) + done(() => reject(new Error("预览生成超时,请稍后重试"))) }, 120_000) - const poll = setInterval(async () => { - pollCount++ + const poll = async () => { + if (finished) return try { const status = await getPreviewStatus(previewResp.task_id) if (status.status === "completed") { - clearTimeout(timeoutId) - clearInterval(poll) - resolve() + done(() => 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("预览生成超时,请稍后重试")) + done(() => reject(new Error(status.error_message || "预览渲染失败"))) + } else { + setTimeout(poll, 2000) } } catch (e) { - clearTimeout(timeoutId) - clearInterval(poll) - reject(e) + done(() => reject(e)) } - }, 2000) + } + poll() }) message.success("预览视频就绪,重新生成封面...") // 重试封面生成 -- 2.54.0