Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia b203d84956 fix: WebCodecs 解码失败时自动 fallback 到原生 video 播放
- useCanvasPlayer 新增 hasDecodeError/errorMessage 状态和 onError 回调
- decodeSegment catch 块不再静默失败,更新状态并通知上层
- VideoDecoder error 回调同步报告错误状态
- 连续 3 次解码失败时报告 hasDecodeError
- FrontendPreviewPlayer 检测 hasDecodeError 后自动切换 video fallback
- 解码失败时显示具体错误信息而非静默'暂无可播放素材'
- 新增 normalizeCodecString 函数规范化 codec 字符串
- configure 调用增加 codec 字符码调试日志
2026-08-21 14:43:01 +08:00
3 changed files with 102 additions and 136 deletions
@@ -394,7 +394,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
preload={
i === videoCurrentSegIdx ? "auto" : i === videoCurrentSegIdx + 1 ? "metadata" : "none"
}
src={seg.videoUrl}
style={{
position: "absolute",
@@ -9,6 +9,8 @@ import { createFile } from "mp4box"
import type { Movie, Sample } from "mp4box"
// ── 常量 ──
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
const MAX_INIT_FRAMES = 60
/**
* 规范化 mp4box 提取的 codec 字符串为 WebCodecs 兼容格式
@@ -241,11 +243,9 @@ export function useCanvasPlayer(
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(1024))
const frameQueueRef = useRef(new FrameQueue(600))
/** 已解码的片段索引集合,用于按需解码(先标记防重入,失败时移除允许重试) */
const decodedSegmentsRef = useRef(new Set<number>())
/** 每个片段已解码的帧数,用于 decodeAroundPosition 继续未完成解码 */
const decodedFrameCountsRef = useRef(new Map<number, number>())
/** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */
const decodeGenerationRef = useRef(0)
const rafRef = useRef<number>(0)
@@ -478,10 +478,8 @@ export function useCanvasPlayer(
// ── 初始化 VideoDecoder 并解码指定片段 ──
const decodeSegment = useCallback(
async (_buffer: ArrayBuffer, meta: SegmentMeta, maxFrames?: number): Promise<void> => {
console.log(`[DIAG_v2] >>> decodeSegment ENTER assetId=${meta.assetId}`)
if (isDestroyedRef.current) return
const gen = decodeGenerationRef.current
let decoderReady = false
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
@@ -573,26 +571,12 @@ export function useCanvasPlayer(
let decodedCount = 0
let skippedCount = 0
let decodeErrors = 0
// 永远从 0 开始,确保第一个 sample 是关键帧
for (let si = 0; si < samplesCollected.length; si++) {
const sample = samplesCollected[si]
// seek/重播时旧的 decodeSegment 立即退出,不往帧队列推废帧
if (decodeGenerationRef.current !== gen) {
console.log(
`[useCanvasPlayer] decodeSegment generation changed, aborting ${meta.assetId}`,
)
break
}
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) {
skippedCount++
continue
}
// Strict Mode 双执行时,destroy() 会关闭 decoder
// 如果 decoder 已关闭,直接返回,让第二轮 init 重新解码
if (decoder.state === "closed") {
console.warn("[useCanvasPlayer] Decoder closed during decode, skipping segment")
return
}
if (decoder.state === "closed") break
// 初始化阶段限制解码帧数,避免帧缓冲溢出
if (maxFrames && decodedCount >= maxFrames) {
console.log(
@@ -609,20 +593,9 @@ export function useCanvasPlayer(
})
try {
await Promise.race([
decoder.decode(chunk),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("decode chunk timeout 3s")), 3000),
),
])
await decoder.decode(chunk) // 修复:await 捕获异步错误
decodedCount++
} catch (e) {
if (e instanceof Error && e.message === "decode chunk timeout 3s") {
console.warn(
`[useCanvasPlayer] Decode chunk timeout at sample ${decodedCount}, aborting segment ${meta.assetId}`,
)
break
}
decodeErrors++
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
// 连续 3 次解码失败,放弃当前片段并报告错误
@@ -644,26 +617,71 @@ export function useCanvasPlayer(
`[useCanvasPlayer] Segment ${meta.assetId}: decoded ${decodedCount}, skipped ${skippedCount}, errors ${decodeErrors}, decoder.state=${decoder.state}`,
)
// 记录该片段已解码帧数(用于 decodeAroundPosition 续解码)
const totalDecoded = decodedCount
const metaArr = segmentMetaRef.current
for (let idx = 0; idx < metaArr.length; idx++) {
if (metaArr[idx].assetId === meta.assetId) {
if (totalDecoded >= samplesCollected.length) {
decodedFrameCountsRef.current.set(idx, Infinity)
} else {
decodedFrameCountsRef.current.set(idx, totalDecoded)
}
break
// flush 仅在解码器状态正常时执行
if (decoder.state === "configured") {
try {
await decoder.flush()
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`)
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
}
// 不做 flush,每个片段独立解码器,flush 在某些 Chromium 版本下会永久挂起
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: decode done, skip flush`)
},
[],
)
/**
* 按需解码当前播放位置 ±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, 300)
} catch (e) {
// 解码失败则移除标记,允许后续重试
decodedSegmentsRef.current.delete(i)
console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e)
}
// await 后再次检查代数,seek 期间不更新标记
if (decodeGenerationRef.current !== gen) return
}
},
[decodeSegment],
)
// ── 标题绘制 ──
const drawTitle = useCallback(
(
@@ -790,6 +808,8 @@ export function useCanvasPlayer(
}
return s
})
// 按需解码当前 ±1 片段
decodeAroundPosition(currentTime)
}
if (currentTime >= totalDuration) {
@@ -798,29 +818,29 @@ 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
// 播放结束后重播 或 从起点重新播放:重置到起点,清空解码缓存
if (state.currentTime >= totalDuration - 0.1 || state.currentTime <= 0.1) {
// 重播:帧已在 init 阶段全部解码并按绝对时间戳存入队列,直接复用
playStartOffsetRef.current = 0
setState((s) => ({ ...s, currentTime: 0 }))
// 重播场景:currentTime 已回到起点但 decodedSegmentsRef 仍有旧标记
// 此时 FrameQueue 中旧帧已被淘汰,需清空标记让 decodeAroundPosition 重新解码
if (state.currentTime <= 0.1 && decodedSegmentsRef.current.size > 0) {
decodeGenerationRef.current++
decodedSegmentsRef.current.clear()
// 同步清空帧缓冲,避免旧帧残留导致 getCurrentFrame 返回 null
frameQueueRef.current.clear()
}
setState((s) => ({ ...s, isPlaying: true }))
playStartRef.current = performance.now()
if (state.currentTime < 0.1) {
playStartOffsetRef.current = 0
} else {
playStartOffsetRef.current = state.currentTime
}
playStartOffsetRef.current = state.currentTime
lastProgressUpdateRef.current = 0
rafRef.current = requestAnimationFrame(renderFrame)
}, [state.hasSupport, state.currentTime, totalDuration, renderFrame])
// 立即触发一次按需解码,不等渲染循环 200ms 节流
decodeAroundPosition(state.currentTime)
}, [state.hasSupport, state.currentTime, renderFrame, decodeAroundPosition])
const pause = useCallback(() => {
setState((s) => ({ ...s, isPlaying: false }))
@@ -833,9 +853,14 @@ export function useCanvasPlayer(
setState((s) => ({ ...s, currentTime: clampedTime }))
playStartOffsetRef.current = clampedTime
playStartRef.current = performance.now()
// init 阶段已全量解码,seek 直接定位到对应帧即可
// seek 时递增解码代数,作废正在进行的异步解码
decodeGenerationRef.current++
// 清空帧队列(clear 内部会 close 所有帧)+ 清空已解码标记
frameQueueRef.current.clear()
decodedSegmentsRef.current.clear()
await decodeAroundPosition(clampedTime)
},
[totalDuration],
[totalDuration, decodeAroundPosition],
)
const destroy = useCallback(() => {
@@ -846,13 +871,12 @@ export function useCanvasPlayer(
decoderRef.current.close()
}
// 清空帧队列(clear 内部 close 所有帧)
// ✅ Strict Mode 修复:destroy 不再递增 generation,避免双执行导致 decode loop 误退出
// 递增代数中止进行中的异步解码,清空帧队列(clear 内部 close 所有帧)
decodeGenerationRef.current++
frameQueueRef.current.clear()
segmentDataRef.current.clear()
segmentMetaRef.current = []
decodedSegmentsRef.current.clear()
decodedFrameCountsRef.current.clear()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -878,24 +902,10 @@ export function useCanvasPlayer(
}
let cancelled = false
console.log("[useCanvasPlayer] Init start, segments:", segments.length)
const init = async () => {
// ✅ 关键修复:重置销毁标记,允许新的 init 周期正常工作
// destroy() 在 useEffect cleanup 中被调用,将 isDestroyedRef 设为 true
// 如果不重置,后续的 loadSegment / decodeSegment 会立即 return
isDestroyedRef.current = false
// ✅ Strict Mode 修复:init 不再递增 generation
// seek() 和 play() 仍保留 generation 递增用于中止异步解码
// 重置错误状态,避免上一轮的解码错误影响新的 init 周期
setState((s) => ({
...s,
isBuffering: true,
hasDecodeError: false,
errorMessage: "",
isReady: false,
}))
console.log("[useCanvasPlayer] Init start v2_DIAG, segments:", segments.length)
setState((s) => ({ ...s, isBuffering: true }))
// 1. 加载所有片段数据
for (const seg of segments) {
@@ -940,61 +950,29 @@ export function useCanvasPlayer(
// 3. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载
// 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏
decodedSegmentsRef.current.clear()
decodedFrameCountsRef.current.clear()
const initGen = decodeGenerationRef.current
const initialDecodeCount = Math.min(metas.length, 3)
console.log(
`[useCanvasPlayer] Starting decode loop: ${initialDecodeCount} segments, metas: ${metas.length}`,
)
for (let i = 0; i < initialDecodeCount; i++) {
console.log(
`[DIAG_v2] Loop i=${i}, cancelled=${cancelled}, gen=${decodeGenerationRef.current}, initGen=${initGen}`,
)
if (cancelled) {
console.log("[DIAG_v2] Break: cancelled")
break
}
if (cancelled) break
// seek 或 destroy 已作废当前初始化
if (decodeGenerationRef.current !== initGen) {
console.log("[DIAG_v2] Break: gen mismatch")
break
}
if (decodeGenerationRef.current !== initGen) break
const meta = metas[i]
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) {
console.log("[DIAG_v2] Skip: no buffer for", meta.assetId)
continue
}
if (!buffer) continue
// 先标记为解码中,防止重复解码
decodedSegmentsRef.current.add(i)
console.log(`[DIAG_v2] Calling decodeSegment(${meta.assetId})...`)
try {
await decodeSegment(buffer, meta)
console.log(`[DIAG_v2] decodeSegment(${meta.assetId}) returned OK`)
await decodeSegment(buffer, meta, MAX_INIT_FRAMES)
} catch (e) {
// 解码失败则移除标记,允许后续重试
decodedSegmentsRef.current.delete(i)
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
}
// ✅ 每次 decode 后也检查 cancelled,防止组件已卸载仍继续
if (cancelled) {
console.log("[DIAG_v2] Break after decode: cancelled")
break
}
}
console.log(
`[useCanvasPlayer] Decode loop finished, decodedSegments:`,
decodedSegmentsRef.current,
"cancelled:",
cancelled,
)
if (!cancelled) {
console.log("[useCanvasPlayer] Init complete, isReady = true, duration:", totalDuration)
console.log("[useCanvasPlayer] Init complete, isReady = true")
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
} else {
console.warn("[useCanvasPlayer] Init was cancelled before completion")
}
}
@@ -143,7 +143,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
return
}
// 等待 canplay 事件(预加载机制应已提前触发缓冲)
// 等待 canplay 事件
const onCanPlay = () => {
video.removeEventListener("canplay", onCanPlay)
clearTimeout(timeoutId)
@@ -151,15 +151,15 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
resolve()
}
// 3 秒超时保护(预加载正常情况下不需要等太久)
// 10 秒超时保护
const timeoutId = setTimeout(() => {
video.removeEventListener("canplay", onCanPlay)
console.warn(
`[useSegmentScheduler] 片段 ${index} 预加载超时 (3s), readyState=${video.readyState},强制切换`,
`[useSegmentScheduler] 片段 ${index} 预加载超时 (10s), readyState=${video.readyState}`,
)
setCurrentSegmentIndex(index)
resolve()
}, 3000)
}, 10000)
video.addEventListener("canplay", onCanPlay)
})
@@ -178,20 +178,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments[currentSegmentIndex]
if (!seg) return
// 预加载下一个片段:距离出点 3 秒时,提前设置下一个视频的 currentTime 触发缓冲
const nextIndex = currentSegmentIndex + 1
if (nextIndex < segments.length) {
const nextVideo = videoRefs.current[nextIndex]
const timeToEnd = seg.endTime - video.currentTime
if (timeToEnd <= 3 && nextVideo && nextVideo.readyState < 3) {
const nextSeg = segments[nextIndex]
// 只在还没 seek 过时设置(避免反复 seek)
if (Math.abs(nextVideo.currentTime - nextSeg.startTime) > 1) {
nextVideo.currentTime = nextSeg.startTime
}
}
}
// 检查是否到达出点(容差 0.15s)
if (video.currentTime >= seg.endTime - 0.15) {
video.pause()