diff --git a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx index 92d4b16df..708695cb1 100644 --- a/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx +++ b/apps/web/src/pages/generate/components/FrontendPreviewPlayer.tsx @@ -112,12 +112,29 @@ const FrontendPreviewPlayer: React.FC = ({ [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 = ({ 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(null) @@ -172,7 +190,7 @@ const FrontendPreviewPlayer: React.FC = ({ }, [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 = ({ const handleSeekTo = useCallback( (time: number) => { - if (useWebCodecs) { + if (effectiveUseWebCodecs) { canvasControls.seek(time) } else { videoSeekTo(time) @@ -191,11 +209,11 @@ const FrontendPreviewPlayer: React.FC = ({ 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 = ({ } 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 = ({ // ── Canvas ResizeObserver ── const canvasContainerRef = useRef(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 = ({ }) ro.observe(container) return () => ro.disconnect() - }, [useWebCodecs, canPlay]) + }, [effectiveUseWebCodecs, canPlay]) // ── 未就绪 ── if (!ready || !assets.length) { @@ -298,6 +316,7 @@ const FrontendPreviewPlayer: React.FC = ({ // ── 无播放片段 ── if (!canPlay) { + const showDecodeError = forceVideoFallback && canvasState.hasDecodeError return (
= ({

加载中...

+ ) : showDecodeError ? ( + <> + +

+ 视频解码失败 +

+

+ {canvasState.errorMessage || "当前浏览器不支持该视频编码格式,请刷新重试"} +

+ ) : ( <> = ({ return ( <> {/* ── Canvas 渲染层(WebCodecs 路径) ── */} - {useWebCodecs && ( + {effectiveUseWebCodecs && (
= ({
)} - {/* ── Video 渲染层(fallback 路径) ── */} - {!useWebCodecs && + {/* ── Video 渲染层(fallback 路径,或 WebCodecs 解码失败时自动切换) ── */} + {!effectiveUseWebCodecs && segments.map((seg, i) => (
{/* 控制条 */} diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts index 15deeb87a..1d35e84d4 100644 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts @@ -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({ 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(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 } }