fix: 修复 AI Code Review 阻塞级问题 v3(useCanvasPlayer 解码策略+资源泄漏+useStep6Cover 轮询超时) #1450

Merged
auto-approve-bot merged 3 commits from fix/ai-code-review-blocking-v3 into develop 2026-08-20 12:01:01 +08:00
2 changed files with 136 additions and 21 deletions
@@ -218,6 +218,10 @@ export function useCanvasPlayer(
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(10))
/** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */
const decodedSegmentsRef = useRef(new Set<number>())
/** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */
const decodeGenerationRef = useRef(0)
const rafRef = useRef<number>(0)
const playStartRef = useRef<number>(0)
const playStartOffsetRef = useRef<number>(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) {
@@ -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<void>((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("预览视频就绪,重新生成封面...")
// 重试封面生成