Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b203d84956 |
@@ -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.640028,WebCodecs 也接受
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user