From 632c3043bc34a6d37e4d0986ba64be7d73a4caef Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Wed, 19 Aug 2026 23:20:53 +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=20v2?= =?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: - 初始化解码改为按需模式:仅解码前3片段 + decodeAroundPosition 动态加载 ±1 片段 - 新增 decodedSegmentsRef 追踪已解码片段,seek 时清空并重新解码 - 新增 lastDrawnFrameRef 追踪上一帧,绘制新帧前 close() 释放防止内存泄漏 - 解码标记移至 await decodeSegment() 之后,失败时不标记以便重试 - destroy 增加帧清理和状态重置 useStep6Cover: - 轮询增加 pollCount(max 60) + setTimeout 120s 双重超时保护 - 400错误精确匹配预览缺失模式(errCode/message正则),避免误触发自动修复 Closes: #1437, #1438, #1439 --- .../pages/generate/hooks/useCanvasPlayer.ts | 120 ++++++++++++++++-- .../src/pages/generate/hooks/useStep6Cover.ts | 39 +++++- 2 files changed, 142 insertions(+), 17 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 6e19db61d..cb9593659 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,50 @@ 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 + try { + await decodeSegment(buffer, meta) + // 解码成功后才标记,失败时下次重试 + decodedSegmentsRef.current.add(i) + } catch (e) { + console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e) + } + } + }, + [decodeSegment], + ) + // ── 标题绘制 ── const drawTitle = useCallback( ( @@ -620,11 +682,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 +711,8 @@ export function useCanvasPlayer( } return s }) + // 按需解码当前 ±1 片段 + decodeAroundPosition(currentTime) } if (currentTime >= totalDuration) { @@ -649,7 +721,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 +740,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 +766,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 +851,22 @@ 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 + try { + await decodeSegment(buffer, meta) + // 解码成功后才标记,失败时不标记以便后续重试 + decodedSegmentsRef.current.add(i) + } catch (e) { + 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("预览视频就绪,重新生成封面...") // 重试封面生成 -- 2.54.0 From 8b53c4a13076caf32174b8baef01e5b02ce2d472 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 20 Aug 2026 11:05:53 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=A7=A3=E7=A0=81=E7=AB=9E=E6=80=81=E2=80=94=E2=80=94=E5=85=88?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E5=86=8D=E8=A7=A3=E7=A0=81=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E7=A7=BB=E9=99=A4=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeAroundPosition 和初始化循环中,将 decodedSegmentsRef.add() 移到 await decodeSegment() 之前(先标记防重入),catch 中 delete() 移除标记 (允许失败后重试)。解决渲染循环中解码未完成时下一帧重复发起解码的竞态。 Closes: #1437 --- apps/web/src/pages/generate/hooks/useCanvasPlayer.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index cb9593659..884c76633 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -567,11 +567,13 @@ export function useCanvasPlayer( const meta = metas[i] const buffer = segmentDataRef.current.get(meta.assetId) if (!buffer) continue + // 先标记为解码中,防止下一帧重复发起解码 + decodedSegmentsRef.current.add(i) try { await decodeSegment(buffer, meta) - // 解码成功后才标记,失败时下次重试 - decodedSegmentsRef.current.add(i) } catch (e) { + // 解码失败则移除标记,允许后续重试 + decodedSegmentsRef.current.delete(i) console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e) } } @@ -860,11 +862,13 @@ export function useCanvasPlayer( const meta = metas[i] const buffer = segmentDataRef.current.get(meta.assetId) if (!buffer) continue + // 先标记为解码中,防止重复解码 + decodedSegmentsRef.current.add(i) try { await decodeSegment(buffer, meta) - // 解码成功后才标记,失败时不标记以便后续重试 - decodedSegmentsRef.current.add(i) } catch (e) { + // 解码失败则移除标记,允许后续重试 + decodedSegmentsRef.current.delete(i) console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e) } } -- 2.54.0