Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b203d84956 |
@@ -112,12 +112,29 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
[segments],
|
[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(
|
const { state: canvasState, controls: canvasControls } = useCanvasPlayer(
|
||||||
canvasRef,
|
canvasRef,
|
||||||
canvasSegments,
|
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 路径) ──
|
// ── Video 播放器(fallback 路径) ──
|
||||||
const {
|
const {
|
||||||
isPlaying: videoIsPlaying,
|
isPlaying: videoIsPlaying,
|
||||||
@@ -130,12 +147,13 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
videoRefs,
|
videoRefs,
|
||||||
} = useSegmentScheduler(segments)
|
} = useSegmentScheduler(segments)
|
||||||
|
|
||||||
// 选择哪条路径的状态
|
// 选择哪条路径的状态(WebCodecs 解码失败时强制走 video fallback)
|
||||||
const isPlaying = useWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
const effectiveUseWebCodecs = useWebCodecs && !forceVideoFallback
|
||||||
const currentTime = useWebCodecs ? canvasState.currentTime : videoCurrentTime
|
const isPlaying = effectiveUseWebCodecs ? canvasState.isPlaying : videoIsPlaying
|
||||||
const totalDuration = useWebCodecs ? canvasState.duration : videoTotalDuration
|
const currentTime = effectiveUseWebCodecs ? canvasState.currentTime : videoCurrentTime
|
||||||
const canPlay = useWebCodecs ? canvasState.isReady : videoCanPlay
|
const totalDuration = effectiveUseWebCodecs ? canvasState.duration : videoTotalDuration
|
||||||
const isBuffering = useWebCodecs ? canvasState.isBuffering : false
|
const canPlay = effectiveUseWebCodecs ? canvasState.isReady : videoCanPlay
|
||||||
|
const isBuffering = effectiveUseWebCodecs ? canvasState.isBuffering : false
|
||||||
|
|
||||||
// ── 配音音频同步 ──
|
// ── 配音音频同步 ──
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
@@ -172,7 +190,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
}, [isPlaying, currentTime])
|
}, [isPlaying, currentTime])
|
||||||
|
|
||||||
// 片段切换时同步音频(仅 fallback 路径需要)
|
// 片段切换时同步音频(仅 fallback 路径需要)
|
||||||
const segmentSyncKey = useWebCodecs ? -1 : videoCurrentSegIdx
|
const segmentSyncKey = effectiveUseWebCodecs ? -1 : videoCurrentSegIdx
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio || !audio.src || !isPlaying) return
|
if (!audio || !audio.src || !isPlaying) return
|
||||||
@@ -181,7 +199,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
|
|
||||||
const handleSeekTo = useCallback(
|
const handleSeekTo = useCallback(
|
||||||
(time: number) => {
|
(time: number) => {
|
||||||
if (useWebCodecs) {
|
if (effectiveUseWebCodecs) {
|
||||||
canvasControls.seek(time)
|
canvasControls.seek(time)
|
||||||
} else {
|
} else {
|
||||||
videoSeekTo(time)
|
videoSeekTo(time)
|
||||||
@@ -191,11 +209,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
audio.currentTime = time
|
audio.currentTime = time
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[useWebCodecs, canvasControls, videoSeekTo],
|
[effectiveUseWebCodecs, canvasControls, videoSeekTo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleTogglePlay = useCallback(() => {
|
const handleTogglePlay = useCallback(() => {
|
||||||
if (useWebCodecs) {
|
if (effectiveUseWebCodecs) {
|
||||||
if (canvasState.isPlaying) {
|
if (canvasState.isPlaying) {
|
||||||
canvasControls.pause()
|
canvasControls.pause()
|
||||||
} else {
|
} else {
|
||||||
@@ -204,7 +222,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
videoTogglePlayPause()
|
videoTogglePlayPause()
|
||||||
}
|
}
|
||||||
}, [useWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
}, [effectiveUseWebCodecs, canvasState.isPlaying, canvasControls, videoTogglePlayPause])
|
||||||
|
|
||||||
// ── 进度条拖拽 ──
|
// ── 进度条拖拽 ──
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
@@ -250,7 +268,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
// ── Canvas ResizeObserver ──
|
// ── Canvas ResizeObserver ──
|
||||||
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
const canvasContainerRef = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!useWebCodecs || !canPlay) return
|
if (!effectiveUseWebCodecs || !canPlay) return
|
||||||
const container = canvasContainerRef.current
|
const container = canvasContainerRef.current
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!container || !canvas) return
|
if (!container || !canvas) return
|
||||||
@@ -272,7 +290,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
})
|
})
|
||||||
ro.observe(container)
|
ro.observe(container)
|
||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [useWebCodecs, canPlay])
|
}, [effectiveUseWebCodecs, canPlay])
|
||||||
|
|
||||||
// ── 未就绪 ──
|
// ── 未就绪 ──
|
||||||
if (!ready || !assets.length) {
|
if (!ready || !assets.length) {
|
||||||
@@ -298,6 +316,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
|
|
||||||
// ── 无播放片段 ──
|
// ── 无播放片段 ──
|
||||||
if (!canPlay) {
|
if (!canPlay) {
|
||||||
|
const showDecodeError = forceVideoFallback && canvasState.hasDecodeError
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="xx-preview-empty"
|
className="xx-preview-empty"
|
||||||
@@ -316,6 +335,19 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
|
<LoadingOutlined style={{ fontSize: 48, color: "#fff", marginBottom: 12 }} spin />
|
||||||
<p style={{ color: "rgba(255,255,255,0.8)" }}>加载中...</p>
|
<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
|
<PlayCircleOutlined
|
||||||
@@ -332,7 +364,7 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
{/* ── Canvas 渲染层(WebCodecs 路径) ── */}
|
||||||
{useWebCodecs && (
|
{effectiveUseWebCodecs && (
|
||||||
<div
|
<div
|
||||||
ref={canvasContainerRef}
|
ref={canvasContainerRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -353,8 +385,8 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Video 渲染层(fallback 路径) ── */}
|
{/* ── Video 渲染层(fallback 路径,或 WebCodecs 解码失败时自动切换) ── */}
|
||||||
{!useWebCodecs &&
|
{!effectiveUseWebCodecs &&
|
||||||
segments.map((seg, i) => (
|
segments.map((seg, i) => (
|
||||||
<video
|
<video
|
||||||
key={seg.assetId}
|
key={seg.assetId}
|
||||||
@@ -423,7 +455,11 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{useWebCodecs ? "Canvas" : `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
{effectiveUseWebCodecs
|
||||||
|
? "Canvas"
|
||||||
|
: forceVideoFallback
|
||||||
|
? "Canvas 解码失败,已切换原生播放"
|
||||||
|
: `片段 ${videoCurrentSegIdx + 1}/${segments.length}`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 控制条 */}
|
{/* 控制条 */}
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ import type { Movie, Sample } from "mp4box"
|
|||||||
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
|
/** 初始化预解码最大帧数(约 2 秒 @30fps),后续帧通过 decodeAroundPosition 按需解码 */
|
||||||
const MAX_INIT_FRAMES = 60
|
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 解析辅助函数 ──
|
||||||
|
|
||||||
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
|
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
|
||||||
@@ -172,6 +187,10 @@ export interface CanvasPlayerState {
|
|||||||
duration: number
|
duration: number
|
||||||
isReady: boolean
|
isReady: boolean
|
||||||
isBuffering: boolean
|
isBuffering: boolean
|
||||||
|
/** WebCodecs 解码失败时为 true,调用方应 fallback 到原生 video 播放 */
|
||||||
|
hasDecodeError: boolean
|
||||||
|
/** 解码错误信息(用于 UI 展示) */
|
||||||
|
errorMessage: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasPlayerControls {
|
export interface CanvasPlayerControls {
|
||||||
@@ -209,6 +228,7 @@ export function useCanvasPlayer(
|
|||||||
stroke?: boolean
|
stroke?: boolean
|
||||||
shadow?: boolean
|
shadow?: boolean
|
||||||
},
|
},
|
||||||
|
onError?: (error: Error) => void,
|
||||||
) {
|
) {
|
||||||
const [state, setState] = useState<CanvasPlayerState>({
|
const [state, setState] = useState<CanvasPlayerState>({
|
||||||
hasSupport: isWebCodecsSupported(),
|
hasSupport: isWebCodecsSupported(),
|
||||||
@@ -217,6 +237,8 @@ export function useCanvasPlayer(
|
|||||||
duration: 0,
|
duration: 0,
|
||||||
isReady: false,
|
isReady: false,
|
||||||
isBuffering: 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 videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
|
||||||
const isDestroyedRef = useRef(false)
|
const isDestroyedRef = useRef(false)
|
||||||
const lastProgressUpdateRef = useRef<number>(0)
|
const lastProgressUpdateRef = useRef<number>(0)
|
||||||
|
const onErrorRef = useRef(onError)
|
||||||
|
onErrorRef.current = onError
|
||||||
|
|
||||||
// 计算总时长
|
// 计算总时长
|
||||||
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
|
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),
|
globalEndTime: globalStart + (segment.endTime - segment.startTime),
|
||||||
trackId: videoTrack.id ?? 1,
|
trackId: videoTrack.id ?? 1,
|
||||||
timescale: videoTrack.timescale ?? 90000,
|
timescale: videoTrack.timescale ?? 90000,
|
||||||
codec: videoTrack.codec ?? "avc1.42E01E",
|
codec: normalizeCodecString(videoTrack.codec ?? "avc1.42E01E"),
|
||||||
videoWidth: videoTrack.track_width || 1280,
|
videoWidth: videoTrack.track_width || 1280,
|
||||||
videoHeight: videoTrack.track_height || 720,
|
videoHeight: videoTrack.track_height || 720,
|
||||||
description,
|
description,
|
||||||
@@ -477,7 +501,15 @@ export function useCanvasPlayer(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e: DOMException) => {
|
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,
|
descriptionByteLength: meta.description?.byteLength,
|
||||||
videoWidth: meta.videoWidth,
|
videoWidth: meta.videoWidth,
|
||||||
videoHeight: meta.videoHeight,
|
videoHeight: meta.videoHeight,
|
||||||
|
codecCharCodes: meta.codec.split("").map((c) => c.charCodeAt(0)),
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -499,7 +532,26 @@ export function useCanvasPlayer(
|
|||||||
// 标记缓冲结束,让 UI 开始渲染
|
// 标记缓冲结束,让 UI 开始渲染
|
||||||
setState((s) => ({ ...s, isBuffering: false }))
|
setState((s) => ({ ...s, isBuffering: false }))
|
||||||
} catch (err) {
|
} 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,9 +598,17 @@ export function useCanvasPlayer(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
decodeErrors++
|
decodeErrors++
|
||||||
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
|
console.warn(`[useCanvasPlayer] Decode chunk error (${decodeErrors}):`, e)
|
||||||
// 连续 3 次解码失败,放弃当前片段
|
// 连续 3 次解码失败,放弃当前片段并报告错误
|
||||||
if (decodeErrors >= 3) {
|
if (decodeErrors >= 3) {
|
||||||
console.error("[useCanvasPlayer] Too many decode errors, aborting segment")
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user