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
2 changed files with 118 additions and 22 deletions
@@ -112,12 +112,29 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
[segments],
)
// WebCodecs 解码失败后强制走 video fallback
const [forceVideoFallback, setForceVideoFallback] = useState(false)
const handleCanvasError = useCallback((err: Error) => {
console.error("[FrontendPreviewPlayer] Canvas decode Error, switching to video fallback:", err)
setForceVideoFallback(true)
}, [])
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
canvasRef,
canvasSegments,
useWebCodecs ? canvasTitle : undefined,
useWebCodecs && !forceVideoFallback ? canvasTitle : undefined,
handleCanvasError,
)
// WebCodecs 报告解码失败时自动切换到 video fallback
useEffect(() => {
if (canvasState.hasDecodeError && !forceVideoFallback) {
console.warn("[FrontendPreviewPlayer] hasDecodeError detected, forcing video fallback")
setForceVideoFallback(true)
}
}, [canvasState.hasDecodeError, forceVideoFallback])
// ── Video 播放器(fallback 路径) ──
const {
isPlaying: videoIsPlaying,
@@ -130,12 +147,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
videoRefs,
} = useSegmentScheduler(segments)
// 选择哪条路径的状态
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
// ── 配音音频同步 ──
const audioRef = useRef<HTMLAudioElement | null>(null)
@@ -172,7 +190,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
}, [isPlaying, currentTime])
// 片段切换时同步音频(仅 fallback 路径需要)
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
useEffect(() => {
const audio = audioRef.current
if (!audio || !audio.src || !isPlaying) return
@@ -181,7 +199,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
const handleSeekTo = useCallback(
(time: number) => {
if (useWebCodecs) {
if (effectiveUseWebCodecs) {
canvasControls.seek(time)
} else {
videoSeekTo(time)
@@ -191,11 +209,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
audio.currentTime = time
}
},
[useWebCodecs, canvasControls, videoSeekTo],
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
)
const handleTogglePlay = useCallback(() => {
if (useWebCodecs) {
if (effectiveUseWebCodecs) {
if (canvasState.isPlaying) {
canvasControls.pause()
} else {
@@ -204,7 +222,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
} else {
videoTogglePlayPause()
}
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
// ── 进度条拖拽 ──
const [isDragging, setIsDragging] = useState(false)
@@ -250,7 +268,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── Canvas ResizeObserver ──
const canvasContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!useWebCodecs || !canPlay) return
if (!effectiveUseWebCodecs || !canPlay) return
const container = canvasContainerRef.current
const canvas = canvasRef.current
if (!container || !canvas) return
@@ -272,7 +290,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
})
ro.observe(container)
return () => ro.disconnect()
}, [useWebCodecs, canPlay])
}, [effectiveUseWebCodecs, canPlay])
// ── 未就绪 ──
if (!ready || !assets.length) {
@@ -298,6 +316,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
// ── 无播放片段 ──
if (!canPlay) {
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
return (
<div
className="xx-preview-empty"
@@ -316,6 +335,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
<p style={{ color: "rgba(255,255,255,0.8)" }}>...</p>
</>
) : showDecodeError ? (
<>
<PlayCircleOutlined style={{ fontSize: 48, color: "#ef4444", marginBottom: 12 }} />
<p className="xx-preview-empty-title" style={{ color: "rgba(255,255,255,0.9)" }}>
</p>
<p
className="xx-preview-empty-desc"
style={{ color: "rgba(255,255,255,0.6)", maxWidth: 300, textAlign: "center" }}
>
{canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"}
</p>
</>
) : (
<>
<PlayCircleOutlined
@@ -332,7 +364,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
return (
<>
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
{useWebCodecs && (
{effectiveUseWebCodecs && (
<div
ref={canvasContainerRef}
style={{
@@ -353,8 +385,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
</div>
)}
{/* ── Video 渲染层(fallback 路径) ── */}
{!useWebCodecs &&
{/* ── Video 渲染层(fallback 路径,或 WebCodecs 解码失败时自动切换 ── */}
{!effectiveUseWebCodecs &&
segments.map((seg, i) => (
<video
key={seg.assetId}
@@ -423,7 +455,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
zIndex: 10,
}}
>
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
{effectiveUseWebCodecs
? "Canvas"
: forceVideoFallback
? "Canvas 解码失败,已切换原生播放"
: `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
</div>
{/* 控制条 */}
@@ -12,6 +12,21 @@ import type { Movie, Sample } from "mp4box"
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
const MAX_INIT_FRAMES = 60
/**
* 规范化 mp4box 提取的 codec 字符串为 WebCodecs 兼容格式
* mp4box 返回的 codec 可能包含 mp4box 特有后缀(如 avc1.640028),
* WebCodecs 要求标准 ISO BMFF codec string(如 avc1.640028
* 大部分情况下格式一致,但需要做防御性处理
*/
function normalizeCodecString(codec: string): string {
// mp4box 有时返回带空格的 codec,去掉
const trimmed = codec.trim()
// HEVC: mp4box 可能返回 hev1.1.6.L93.B0 或 hvc1.1.6.L93.B0
// WebCodecs 接受 hev1.x.x 或 hvc1.x.x,两者都可
// H.264: mp4box 返回 avc1.640028WebCodecs 也接受
return trimmed
}
// ── MP4 Box 解析辅助函数 ──
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
@@ -172,6 +187,10 @@ export interface CanvasPlayerState {
duration: number
isReady: boolean
isBuffering: boolean
/** WebCodecs 解码失败时为 true,调用方应 fallback 到原生 video 播放 */
hasDecodeError: boolean
/** 解码错误信息(用于 UI 展示) */
errorMessage: string
}
export interface CanvasPlayerControls {
@@ -209,6 +228,7 @@ export function useCanvasPlayer(
stroke?: boolean
shadow?: boolean
},
onError?: (error: Error) => void,
) {
const [state, setState] = useState<CanvasPlayerState>({
hasSupport: isWebCodecsSupported(),
@@ -217,6 +237,8 @@ export function useCanvasPlayer(
duration: 0,
isReady: false,
isBuffering: false,
hasDecodeError: false,
errorMessage: "",
})
// ── 内部引用 ──
@@ -234,6 +256,8 @@ export function useCanvasPlayer(
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
const isDestroyedRef = useRef(false)
const lastProgressUpdateRef = useRef<number>(0)
const onErrorRef = useRef(onError)
onErrorRef.current = onError
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
@@ -367,7 +391,7 @@ export function useCanvasPlayer(
globalEndTime: globalStart + (segment.endTime - segment.startTime),
trackId: videoTrack.id ?? 1,
timescale: videoTrack.timescale ?? 90000,
codec: videoTrack.codec ?? "avc1.42E01E",
codec: normalizeCodecString(videoTrack.codec ?? "avc1.42E01E"),
videoWidth: videoTrack.track_width || 1280,
videoHeight: videoTrack.track_height || 720,
description,
@@ -477,7 +501,15 @@ export function useCanvasPlayer(
})
},
error: (e: DOMException) => {
console.error("[useCanvasPlayer] Decoder error:", e)
console.error("[useCanvasPlayer] Decoder error callback:", e)
const error = new Error(`VideoDecoder error: ${e.message || e.name || "unknown"}`)
setState((s) => ({
...s,
isBuffering: false,
hasDecodeError: true,
errorMessage: `视频解码器错误: ${e.message || "解码异常"}`,
}))
onErrorRef.current?.(error)
},
})
@@ -487,6 +519,7 @@ export function useCanvasPlayer(
descriptionByteLength: meta.description?.byteLength,
videoWidth: meta.videoWidth,
videoHeight: meta.videoHeight,
codecCharCodes: meta.codec.split("").map((c) => c.charCodeAt(0)),
})
try {
@@ -499,7 +532,26 @@ export function useCanvasPlayer(
// 标记缓冲结束,让 UI 开始渲染
setState((s) => ({ ...s, isBuffering: false }))
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed for segment:", err)
const error = err instanceof Error ? err : new Error(String(err))
console.error(
"[useCanvasPlayer] Decoder configure failed for segment:",
meta.assetId,
error,
)
console.error("[useCanvasPlayer] Failed codec config:", {
codec: meta.codec,
descriptionByteLength: meta.description?.byteLength,
videoWidth: meta.videoWidth,
videoHeight: meta.videoHeight,
})
setState((s) => ({
...s,
isBuffering: false,
isReady: false,
hasDecodeError: true,
errorMessage: `视频解码失败: ${error.message || "不支持的编解码器"}`,
}))
onErrorRef.current?.(error)
return
}
@@ -546,9 +598,17 @@ export function useCanvasPlayer(
} catch (e) {
decodeErrors++
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
// 连续 3 次解码失败,放弃当前片段
// 连续 3 次解码失败,放弃当前片段并报告错误
if (decodeErrors >= 3) {
console.error("[useCanvasPlayer] Too many decode errors, aborting segment")
const error = new Error(`视频解码连续失败 ${decodeErrors} 次,片段: ${meta.assetId}`)
setState((s) => ({
...s,
isBuffering: false,
hasDecodeError: true,
errorMessage: `视频解码失败: 连续 ${decodeErrors} 次错误`,
}))
onErrorRef.current?.(error)
break
}
}