fix: 修复 AI Code Review 阻塞级问题 v3(useCanvasPlayer 解码策略+资源泄漏+useStep6Cover 轮询超时) #1450
@@ -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("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
|
||||
Reference in New Issue
Block a user