From 7e34fcf472e310df07d4005f71cf74e9f8219944 Mon Sep 17 00:00:00 2001 From: LingYing Agent Date: Mon, 14 Sep 2026 02:31:18 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E5=88=A0=E9=99=A4=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=20useCanvasPlayer=EF=BC=88WebCodecs=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E8=B7=AF=E5=BE=84=EF=BC=8C1039=E8=A1=8C=E9=9B=B6?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E5=BC=95=E7=94=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCanvasPlayer 仅被 FrontendPreviewPlayer 条件引用(useWebCodecs=false 永不开启),重构后生产代码零引用 - 清理测试文件中对应的 vi.mock - 保留 useSegmentScheduler 为唯一调度器,前端始终走原生 video 硬件解码路径 --- .../pages/generate/hooks/useCanvasPlayer.ts | 1039 ----------------- .../FrontendPreviewPlayer.audio.test.tsx | 2 +- 2 files changed, 1 insertion(+), 1040 deletions(-) delete mode 100644 apps/web/src/pages/generate/hooks/useCanvasPlayer.ts diff --git a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts b/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts deleted file mode 100644 index 2bb6c1f71..000000000 --- a/apps/web/src/pages/generate/hooks/useCanvasPlayer.ts +++ /dev/null @@ -1,1039 +0,0 @@ -/** - * Canvas + WebCodecs 播放器核心 Hook - * MP4 → mp4box.js 解封装 → VideoDecoder 解码帧 → Canvas 绘制 - * - * 浏览器不支持 WebCodecs 时返回 hasSupport=false,由调用方 fallback - */ -import { useRef, useCallback, useEffect, useState } from "react" -import { createFile } from "mp4box" -import type { Movie, Sample } from "mp4box" - -// ── 常量 ── - -/** - * 规范化 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) -const MP4_CONTAINER_TYPES = [ - "moov", - "trak", - "mdia", - "minf", - "stbl", - "stsd", - "dinf", - "edts", - "udta", - "meta", - "tref", -] - -const VISUAL_SAMPLE_ENTRY_TYPES = ["avc1", "avc3", "hvc1", "hev1"] - -/** - * 递归搜索 box 树,找到 hvcC 或 avcC box 并返回其配置数据(不含 8 字节 box header) - * - * MP4 box 嵌套结构:moov → trak → mdia → minf → stbl → stsd → hev1 → hvcC - * - 普通容器 box 从 offset+8 开始递归 - * - stsd 有额外 8 字节头(version/flags 4B + entry_count 4B),从 offset+16 开始 - * - VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节是固定字段,子 box 从 offset+8+78 开始 - */ -function findCodecConfigRecursive( - buffer: ArrayBuffer, - start: number, - end: number, -): ArrayBuffer | undefined { - const view = new DataView(buffer) - let offset = start - - while (offset < end - 8) { - const size = view.getUint32(offset) - if (size < 8 || offset + size > end) break - - const type = String.fromCharCode( - view.getUint8(offset + 4), - view.getUint8(offset + 5), - view.getUint8(offset + 6), - view.getUint8(offset + 7), - ) - - // 找到目标 codec 配置 box,返回内容(不含 8 字节 header) - if (type === "avcC" || type === "hvcC") { - console.log("[findCodecConfig] Found", type, "at offset", offset, "size", size) - return buffer.slice(offset + 8, offset + size) - } - - // VisualSampleEntry:前 78 字节是固定字段,子 box 在 78 字节之后 - if (VISUAL_SAMPLE_ENTRY_TYPES.includes(type)) { - const childResult = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size) - if (childResult) return childResult - } - // stsd:额外 8 字节头(version/flags 4B + entry_count 4B),子 box 在 offset+16 - else if (type === "stsd") { - const childResult = findCodecConfigRecursive(buffer, offset + 8 + 8, offset + size) - if (childResult) return childResult - } - // 标准容器 box:从 offset+8 开始递归 - else if (MP4_CONTAINER_TYPES.includes(type)) { - const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size) - if (childResult) return childResult - } - - offset += size - } - return undefined -} - -// ── 帧队列(环形缓冲区) ── -interface FrameEntry { - frame: VideoFrame - pts: number // 全局时间戳(秒),已按片段偏移对齐 - duration: number // 帧持续时长(秒) -} - -class FrameQueue { - private frames: FrameEntry[] = [] - private maxSize: number - - constructor(maxSize = 200) { - this.maxSize = maxSize - } - - push(entry: FrameEntry) { - while (this.frames.length >= this.maxSize) { - const old = this.frames.shift() - old?.frame.close() - } - this.frames.push(entry) - } - - /** 获取当前时间戳应显示的帧(二分查找,O(log n)) */ - getCurrentFrame(timestamp: number): VideoFrame | null { - if (this.frames.length === 0) return null - - const target = timestamp + 0.01 - - // 找到最后一个 pts <= target 的帧(右边界) - let lo = 0, - hi = this.frames.length - 1, - bestIdx = -1 - while (lo <= hi) { - const mid = (lo + hi) >> 1 - if (this.frames[mid].pts <= target) { - bestIdx = mid - lo = mid + 1 - } else { - hi = mid - 1 - } - } - - if (bestIdx < 0) return null - - // 关闭并移除 bestIdx 之前的所有已播放帧 - for (let i = 0; i < bestIdx; i++) { - this.frames[i].frame.close() - } - this.frames.splice(0, bestIdx) - - // 此时 bestIdx 对应帧已在索引 0 - return this.frames[0]?.frame ?? null - } - - clear() { - for (const f of this.frames) { - f.frame.close() - } - this.frames = [] - } - - get size() { - return this.frames.length - } -} - -// ── 片段元数据 ── -interface SegmentMeta { - assetId: string - videoUrl: string - /** 该片段在全局时间轴上的起始时间(秒) */ - globalStartTime: number - /** 该片段在全局时间轴上的结束时间(秒) */ - globalEndTime: number - /** 视频轨道 ID */ - trackId: number - /** 视频轨道 timescale */ - timescale: number - /** 编解码器 */ - codec: string - /** 视频宽度(像素) */ - videoWidth: number - /** 视频高度(像素) */ - videoHeight: number - /** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */ - description?: ArrayBuffer - /** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */ - samples: Sample[] -} - -// ── 播放器状态 ── -export interface CanvasPlayerState { - hasSupport: boolean - isPlaying: boolean - currentTime: number - duration: number - isReady: boolean - isBuffering: boolean - /** WebCodecs 解码失败时为 true,调用方应 fallback 到原生 video 播放 */ - hasDecodeError: boolean - /** 解码错误信息(用于 UI 展示) */ - errorMessage: string -} - -export interface CanvasPlayerControls { - play: () => void - pause: () => void - seek: (time: number) => void - destroy: () => void -} - -interface SegmentSource { - assetId: string - videoUrl: string - startTime: number - endTime: number -} - -/** 检测浏览器是否支持 WebCodecs VideoDecoder */ -export function isWebCodecsSupported(): boolean { - return typeof window !== "undefined" && "VideoDecoder" in window && "VideoFrame" in window -} - -/** - * useCanvasPlayer — Canvas + WebCodecs 播放器核心 - */ -export function useCanvasPlayer( - canvasRef: React.RefObject, - segments: SegmentSource[], - titleSettings?: { - text: string - fontSize: number - fontFamily: string - color: string - position: "top" | "center" | "bottom" | "custom" - bold?: boolean - stroke?: boolean - shadow?: boolean - }, - onError?: (error: Error) => void, - enabled: boolean = true, -) { - const [state, setState] = useState({ - hasSupport: enabled && isWebCodecsSupported(), - isPlaying: false, - currentTime: 0, - duration: 0, - isReady: false, - isBuffering: false, - hasDecodeError: false, - errorMessage: "", - }) - - // ── 内部引用 ── - const decoderRef = useRef(null) - const frameQueueRef = useRef(new FrameQueue(200)) - /** 每个片段持久化解码器,避免每次新建导致关键帧错误 */ - const segmentDecodersRef = useRef(new Map()) - /** 每个片段已送入解码器的 sample 游标(用于续解码) */ - const segmentSampleCursorRef = useRef(new Map()) - /** 后台补充解码是否正在运行(防重入) */ - const isFeedingRef = useRef(false) - /** 解码代数计数器,seek 时递增以作废正在进行的异步解码 */ - const decodeGenerationRef = useRef(0) - const rafRef = useRef(0) - const playStartRef = useRef(0) - const playStartOffsetRef = useRef(0) - const segmentDataRef = useRef>(new Map()) - const segmentMetaRef = useRef([]) - 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) - - // ── 加载 MP4 文件数据 ── - const loadSegment = useCallback(async (segment: SegmentSource): Promise => { - if (isDestroyedRef.current) return - if (segmentDataRef.current.has(segment.assetId)) return - - setState((s) => ({ ...s, isBuffering: true })) - - try { - const resp = await fetch(segment.videoUrl) - const buffer = await resp.arrayBuffer() - segmentDataRef.current.set(segment.assetId, buffer) - } catch (err) { - console.error("[useCanvasPlayer] Failed to fetch segment:", err) - } finally { - setState((s) => ({ ...s, isBuffering: false })) - } - }, []) - - // ── 从 MP4 buffer 提取编解码器配置数据(avcC / hvcC) ── - // WebCodecs VideoDecoder 对 HEVC/H.265 必须提供 description 字段 - const extractCodecDescription = useCallback((buffer: ArrayBuffer): ArrayBuffer | undefined => { - try { - const view = new DataView(buffer) - let offset = 0 - - // 查找 moov box - while (offset < buffer.byteLength - 8) { - const size = view.getUint32(offset) - const type = String.fromCharCode( - view.getUint8(offset + 4), - view.getUint8(offset + 5), - view.getUint8(offset + 6), - view.getUint8(offset + 7), - ) - if (type === "moov") { - const result = findCodecConfigRecursive(buffer, offset + 8, offset + size) - console.log("[useCanvasPlayer] extractCodecDescription:", { - moovOffset: offset, - moovSize: size, - searchRange: [offset + 8, offset + size], - found: !!result, - resultByteLength: result?.byteLength, - }) - return result - } - if (size === 0) break - offset += size - } - } catch (e) { - console.warn("[useCanvasPlayer] extractCodecDescription failed:", e) - } - return undefined - }, []) - - // ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ── - // ✅ 关键修复:改为异步函数,等待 MP4Box.js 的 onSamples 回调完成后再返回 - const demuxSegment = useCallback( - async (buffer: ArrayBuffer, segIndex: number): Promise => { - const segment = segments?.[segIndex] - if (!segment) { - console.warn("[useCanvasPlayer] No segment at index", segIndex) - return null - } - - // 计算全局偏移 - let globalStart = 0 - for (let i = 0; i < segIndex; i++) { - globalStart += segments[i].endTime - segments[i].startTime - } - - const mp4File = createFile() - - return new Promise((resolve) => { - let meta: SegmentMeta | null = null - let resolved = false - - // ✅ 超时保护:5秒后如果 onSamples 没有触发,返回 null - const timeout = setTimeout(() => { - if (!resolved) { - console.error( - `[useCanvasPlayer] Timeout: onSamples not triggered for segment ${segIndex}`, - ) - resolved = true - resolve(null) - } - }, 5000) - - mp4File.onReady = (info: Movie) => { - const videoTrack = info?.videoTracks?.[0] - - console.log("[useCanvasPlayer] demuxSegment:", { - segIndex, - startTime: segment.startTime, - endTime: segment.endTime, - nbSamples: videoTrack?.nb_samples, - codec: videoTrack?.codec, - videoWidth: videoTrack?.track_width, - videoHeight: videoTrack?.track_height, - }) - - if (!videoTrack) { - console.warn("[useCanvasPlayer] No video track found for segment", segIndex) - clearTimeout(timeout) - resolved = true - resolve(null) - return - } - - // 提取编解码器配置数据(HEVC 必需,H.264 也需要) - const description = extractCodecDescription(buffer) - - // ✅ 如果 description 缺失,无法解码 HEVC - if (!description) { - console.error( - `[useCanvasPlayer] No description found for segment ${segIndex}, cannot decode HEVC`, - ) - clearTimeout(timeout) - resolved = true - resolve(null) - return - } - - meta = { - assetId: segment.assetId, - videoUrl: segment.videoUrl, - globalStartTime: globalStart, - globalEndTime: globalStart + (segment.endTime - segment.startTime), - trackId: videoTrack.id ?? 1, - timescale: videoTrack.timescale ?? 90000, - codec: normalizeCodecString(videoTrack.codec ?? "avc1.42E01E"), - videoWidth: videoTrack.track_width || 1280, - videoHeight: videoTrack.track_height || 720, - description, - samples: [], - } - - // 提取所有 samples - mp4File.setExtractionOptions(videoTrack.id ?? 1, null, { - nbSamples: Infinity, // 提取所有 sample - }) - mp4File.start() - } - - mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => { - if (resolved) return // ✅ 防止重复 resolve - - if (!meta) { - clearTimeout(timeout) - resolved = true - resolve(null) - return - } - - // 前端切片:按 [startTime, endTime] 时间范围过滤样本 - const timescale = meta.timescale - const startCts = segment.startTime * timescale - const endCts = segment.endTime * timescale - - // 过滤出时间范围内的样本 - let filtered = samples.filter((s) => (s?.cts ?? 0) >= startCts && (s?.cts ?? 0) < endCts) - - // 确保从关键帧开始(跳过第一个 sync 之前的非关键帧) - let foundSync = false - filtered = filtered.filter((s) => { - if (s.is_sync) { - foundSync = true - return true - } - return foundSync - }) - - // Fallback:如果时间范围内没有样本,使用全部样本从第一个关键帧开始 - if (filtered.length === 0) { - console.warn( - `[useCanvasPlayer] No samples in range [${segment.startTime}s, ${segment.endTime}s] for segment ${segIndex}, fallback to all from keyframe`, - ) - let sync = false - filtered = samples.filter((s) => { - if (s.is_sync) { - sync = true - return true - } - return sync - }) - } - - meta.samples = filtered - console.log( - `[useCanvasPlayer] Segment ${segIndex}: ${filtered.length}/${samples.length} samples (range ${segment.startTime}s-${segment.endTime}s)`, - ) - - // ✅ 关键修复:等待 onSamples 完成后再返回 - clearTimeout(timeout) - resolved = true - resolve(meta) - } - - mp4File.onError = (_module: string, message: string) => { - console.error(`[useCanvasPlayer] MP4Box error: ${message}`) - clearTimeout(timeout) - resolved = true - resolve(null) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(buffer as any).fileStart = 0 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mp4File.appendBuffer(buffer as any) - }) - }, - [segments, extractCodecDescription], - ) - - // ── 解码片段的一批帧(使用持久化解码器,支持从断点续解码) ── - /** - * @param segIdx 片段索引 - * @param maxFrames 本次最多解码多少帧 - */ - const decodeSegmentBatch = useCallback( - async (segIdx: number, maxFrames: number = 60): Promise => { - if (isDestroyedRef.current) return 0 - const metas = segmentMetaRef.current - const meta = metas[segIdx] - if (!meta) return 0 - const buffer = segmentDataRef.current.get(meta.assetId) - if (!buffer) return 0 - - const gen = decodeGenerationRef.current - let decoder = segmentDecodersRef.current.get(segIdx) - let cursor = segmentSampleCursorRef.current.get(segIdx) ?? 0 - const samples = meta.samples - - // 如果还没有解码器,新建一个(从关键帧开始,不会报 key frame 错误) - if (!decoder || decoder.state === "closed") { - decoder = new VideoDecoder({ - output: (frame: VideoFrame) => { - if (videoDimRef.current.width === 0 || videoDimRef.current.height === 0) { - videoDimRef.current = { width: frame.codedWidth, height: frame.codedHeight } - } - const localTime = frame.timestamp / 1_000_000 - const globalTime = localTime + meta.globalStartTime - frameQueueRef.current.push({ - frame, - pts: globalTime, - duration: (frame.duration ?? 0) / 1_000_000, - }) - }, - error: (e: DOMException) => { - console.error(`[useCanvasPlayer] Segment ${segIdx} decoder error:`, e) - // 重置该片段的解码器和游标,允许重试 - segmentDecodersRef.current.delete(segIdx) - segmentSampleCursorRef.current.set(segIdx, 0) - }, - }) - - try { - await decoder.configure({ - codec: meta.codec, - ...(meta.description ? { description: meta.description } : {}), - }) - } catch (err) { - console.error(`[useCanvasPlayer] Segment ${segIdx} configure failed:`, err) - const error = err instanceof Error ? err : new Error(String(err)) - setState((s) => ({ - ...s, - isBuffering: false, - hasDecodeError: true, - errorMessage: `视频解码失败: ${error.message || "不支持的编解码器"}`, - })) - onErrorRef.current?.(error) - return 0 - } - - segmentDecodersRef.current.set(segIdx, decoder) - cursor = 0 - // 标记缓冲结束 - setState((s) => ({ ...s, isBuffering: false })) - } - - if (decoder.state !== "configured") return 0 - - // 从 cursor 继续喂 sample(流水线批量提交,不 await 单个 decode) - let decoded = 0 - let si = cursor - while (si < samples.length && decoded < maxFrames) { - if (decodeGenerationRef.current !== gen || isDestroyedRef.current) break - if ((decoder.state as string) === "closed") break - // 帧队列快满时停止提交(这才是真正的背压) - if (frameQueueRef.current.size >= 180) break - // 解码器内部队列积压过多时短暂让出线程(阈值64,给硬件足够流水线深度) - if (decoder.decodeQueueSize > 64) { - await new Promise((r) => setTimeout(r, 5)) - continue - } - - const sample = samples[si] - si++ - if (!sample.data) continue - - const chunk = new EncodedVideoChunk({ - type: sample.is_sync ? "key" : "delta", - timestamp: ((sample.cts ?? 0) / (meta.timescale || 90000)) * 1_000_000, - duration: ((sample.duration ?? 0) / (meta.timescale || 90000)) * 1_000_000, - data: sample.data, - }) - - try { - decoder.decode(chunk) - decoded++ - } catch (e) { - console.warn(`[useCanvasPlayer] Segment ${segIdx} decode error:`, e) - break - } - } - - cursor = si - segmentSampleCursorRef.current.set(segIdx, cursor) - // 等待解码器输出帧(最多500ms) - if (decoded > 0 && (decoder.state as string) === "configured") { - let waited = 0 - while (frameQueueRef.current.size < Math.min(decoded, 10) && waited < 500) { - await new Promise((r) => setTimeout(r, 20)) - waited += 20 - if (isDestroyedRef.current) break - } - } - console.log( - `[useCanvasPlayer] Segment ${segIdx} decoded ${decoded} frames, queue size: ${frameQueueRef.current.size}`, - ) - return decoded - }, - [], - ) - - // ── 后台持续补充帧 ── - /** - * 根据当前播放时间,确保队列中有足够缓冲 - * 播放循环每 200ms 调用一次 - */ - const feedFrames = useCallback( - async (currentTime: number) => { - if (isFeedingRef.current) return - isFeedingRef.current = true - try { - const metas = segmentMetaRef.current - if (!metas || metas.length === 0) return - - // 队列帧数充足时不解码(目标:保持 >= 80 帧缓冲) - if (frameQueueRef.current.size >= 80) return - - // 找到当前播放的片段 - let targetIdx = 0 - 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 - } - - // 依次补充:当前片段 → 下一个片段 → 再下一个 - for (let offset = 0; offset <= 2; offset++) { - const idx = targetIdx + offset - if (idx >= metas.length) break - if (frameQueueRef.current.size >= 180) break - await decodeSegmentBatch(idx, 60) - } - } finally { - isFeedingRef.current = false - } - }, - [decodeSegmentBatch], - ) - - // ── 标题绘制 ── - const drawTitle = useCallback( - ( - ctx: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, - title: NonNullable, - ) => { - const fontSize = (title.fontSize / 720) * canvas.height - ctx.font = `${title.bold ? "bold" : "normal"} ${fontSize}px ${title.fontFamily}` - ctx.fillStyle = title.color - ctx.textAlign = "center" - - // 按 "/" 分割为多行("/" 作为手动换行符) - const lines = title.text.split(/[//⁄∕]/) - const lineHeight = fontSize * 1.3 - const totalHeight = lines.length * lineHeight - - // 根据 position 计算第一行的 Y 坐标 - let startY: number - switch (title.position) { - case "top": - startY = fontSize + canvas.height * 0.08 - break - case "bottom": - startY = canvas.height - canvas.height * 0.08 - totalHeight + lineHeight - break - case "center": - default: - startY = (canvas.height - totalHeight) / 2 + lineHeight - break - } - - if (title.shadow) { - ctx.shadowColor = "rgba(0,0,0,0.8)" - ctx.shadowBlur = 4 - ctx.shadowOffsetX = 2 - ctx.shadowOffsetY = 2 - } - - lines.forEach((line, idx) => { - const y = startY + idx * lineHeight - if (title.stroke) { - ctx.strokeStyle = "#000000" - ctx.lineWidth = 1 - ctx.strokeText(line, canvas.width / 2, y) - } - ctx.fillText(line, canvas.width / 2, y) - }) - - ctx.shadowColor = "transparent" - ctx.shadowBlur = 0 - ctx.shadowOffsetX = 0 - ctx.shadowOffsetY = 0 - }, - [], - ) - - // ── 计算保持宽高比的绘制矩形(letterbox / pillarbox) ── - const computeDrawRect = useCallback( - (canvasW: number, canvasH: number): { dx: number; dy: number; dw: number; dh: number } => { - const vw = videoDimRef.current.width - const vh = videoDimRef.current.height - if (vw <= 0 || vh <= 0) return { dx: 0, dy: 0, dw: canvasW, dh: canvasH } - - const canvasAspect = canvasW / canvasH - const videoAspect = vw / vh - - let dw: number, dh: number - if (canvasAspect > videoAspect) { - // canvas 更宽 → pillarbox(左右留黑) - dh = canvasH - dw = canvasH * videoAspect - } else { - // canvas 更高 → letterbox(上下留黑) - dw = canvasW - dh = canvasW / videoAspect - } - - return { - dx: (canvasW - dw) / 2, - dy: (canvasH - dh) / 2, - dw, - dh, - } - }, - [], - ) - - // ── Canvas 渲染循环 ── - const renderFrame = useCallback(() => { - if (isDestroyedRef.current) return - - const canvas = canvasRef.current - if (!canvas) return - const ctx = canvas.getContext("2d") - if (!ctx) return - - const elapsed = (performance.now() - playStartRef.current) / 1000 - const currentTime = Math.min(playStartOffsetRef.current + elapsed, totalDuration) - - // getCurrentFrame 返回 FrameQueue 内部引用,帧生命周期由 FrameQueue 管理 - // (push 淘汰旧帧时 close、clear 时全部 close),渲染层不应 close - const frame = frameQueueRef.current.getCurrentFrame(currentTime) - - ctx.clearRect(0, 0, canvas.width, canvas.height) - - if (frame) { - const rect = computeDrawRect(canvas.width, canvas.height) - ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh) - } - - if (titleSettings?.text) { - drawTitle(ctx, canvas, titleSettings) - } - - // 进度更新节流到 200ms(5fps),减少 React re-render - const now = performance.now() - if (now - lastProgressUpdateRef.current >= 200) { - lastProgressUpdateRef.current = now - setState((s) => { - if (Math.abs(s.currentTime - currentTime) > 0.01) { - return { ...s, currentTime } - } - return s - }) - // 后台补充帧:队列不足时自动续解码 - if (frameQueueRef.current.size < 80) { - void feedFrames(currentTime) - } - } - - if (currentTime >= totalDuration) { - setState((s) => ({ ...s, isPlaying: false })) - return - } - - rafRef.current = requestAnimationFrame(renderFrame) - }, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, feedFrames]) - - // ── 播放控制 ── - const play = useCallback(async () => { - if (!state.hasSupport || isDestroyedRef.current) return - - // 重播:必须关闭旧解码器、清空队列、重置游标,从头重新解码 - if (state.currentTime >= totalDuration - 0.1 || state.currentTime <= 0.1) { - decodeGenerationRef.current++ - // 关闭所有持久化解码器 - for (const d of segmentDecodersRef.current.values()) { - try { - if (d.state !== "closed") d.close() - } catch { - /* noop */ - } - } - segmentDecodersRef.current.clear() - segmentSampleCursorRef.current.clear() - frameQueueRef.current.clear() - playStartOffsetRef.current = 0 - setState((s) => ({ ...s, currentTime: 0 })) - // 重新初始化解码 - const metas = segmentMetaRef.current - const initialDecodeCount = Math.min(metas.length, 2) - for (let i = 0; i < initialDecodeCount; i++) { - await decodeSegmentBatch(i, 60) - } - } - - setState((s) => ({ ...s, isPlaying: true })) - playStartRef.current = performance.now() - if (state.currentTime < 0.1) { - playStartOffsetRef.current = 0 - } else { - playStartOffsetRef.current = state.currentTime - } - lastProgressUpdateRef.current = 0 - rafRef.current = requestAnimationFrame(renderFrame) - }, [state.hasSupport, state.currentTime, totalDuration, renderFrame, decodeSegmentBatch]) - - const pause = useCallback(() => { - setState((s) => ({ ...s, isPlaying: false })) - cancelAnimationFrame(rafRef.current) - }, []) - - const seek = useCallback( - async (time: number) => { - const clampedTime = Math.max(0, Math.min(time, totalDuration)) - decodeGenerationRef.current++ - // 关闭所有解码器、清空队列、重置游标 - for (const d of segmentDecodersRef.current.values()) { - try { - if (d.state !== "closed") d.close() - } catch { - /* noop */ - } - } - segmentDecodersRef.current.clear() - segmentSampleCursorRef.current.clear() - frameQueueRef.current.clear() - setState((s) => ({ ...s, currentTime: clampedTime })) - playStartOffsetRef.current = clampedTime - playStartRef.current = performance.now() - // 找到 seek 目标片段,从该片段开始解码 - const metas = segmentMetaRef.current - let targetIdx = 0, - acc = 0 - for (let i = 0; i < metas.length; i++) { - const dur = metas[i].globalEndTime - metas[i].globalStartTime - if (clampedTime < acc + dur) { - targetIdx = i - break - } - acc += dur - } - await decodeSegmentBatch(targetIdx, 60) - await decodeSegmentBatch(Math.min(targetIdx + 1, metas.length - 1), 60) - }, - [totalDuration, decodeSegmentBatch], - ) - - const destroy = useCallback(() => { - isDestroyedRef.current = true - cancelAnimationFrame(rafRef.current) - - // 关闭所有持久化解码器 - for (const d of segmentDecodersRef.current.values()) { - try { - if (d.state !== "closed") d.close() - } catch { - /* noop */ - } - } - segmentDecodersRef.current.clear() - segmentSampleCursorRef.current.clear() - - if (decoderRef.current && decoderRef.current.state !== "closed") { - decoderRef.current.close() - } - - frameQueueRef.current.clear() - segmentDataRef.current.clear() - segmentMetaRef.current = [] - }, []) - - // ── 预加载下一个片段的数据 ── - const preloadNext = useCallback( - async (currentIndex: number) => { - const nextIdx = currentIndex + 1 - if (nextIdx >= segments.length) return - const next = segments[nextIdx] - if (segmentDataRef.current.has(next.assetId)) return - await loadSegment(next) - }, - [segments, loadSegment], - ) - - // ── 初始化:加载并解码所有片段 ── - useEffect(() => { - if (!enabled) return - if (!state.hasSupport || segments.length === 0) { - console.log("[useCanvasPlayer] Skip init:", { - hasSupport: state.hasSupport, - segmentCount: segments.length, - }) - return - } - - let cancelled = false - - 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) - - // 1. 加载所有片段数据 - for (const seg of segments) { - await loadSegment(seg) - if (cancelled) { - console.log("[useCanvasPlayer] Cancelled during loadSegment") - return - } - } - - // 验证 buffer 是否都已存入 - const bufferCheck = segments.map((s) => ({ - assetId: s.assetId, - hasBuffer: segmentDataRef.current.has(s.assetId), - })) - console.log("[useCanvasPlayer] Buffers loaded:", bufferCheck) - - // 2. 解析每个片段的轨道元数据(await 等待 onSamples 回调完成) - const metas: SegmentMeta[] = [] - for (let i = 0; i < segments.length; i++) { - const buffer = segmentDataRef.current.get(segments[i].assetId) - if (!buffer) { - console.warn("[useCanvasPlayer] No buffer for segment", i, segments[i].assetId) - continue - } - const meta = await demuxSegment(buffer, i) - if (cancelled) { - console.log("[useCanvasPlayer] Cancelled during demuxSegment") - return - } - if (meta) metas.push(meta) - } - - if (cancelled || metas.length === 0) { - console.warn("[useCanvasPlayer] Init failed:", { cancelled, metasCount: metas.length }) - setState((s) => ({ ...s, isBuffering: false })) - return - } - - segmentMetaRef.current = metas - - // 3. 初始化解码:关闭旧解码器,前 2 个片段各解 60 帧 - // 后续由 feedFrames 后台补充 - for (const d of segmentDecodersRef.current.values()) { - try { - if (d.state !== "closed") d.close() - } catch { - /* noop */ - } - } - segmentDecodersRef.current.clear() - segmentSampleCursorRef.current.clear() - frameQueueRef.current.clear() - - const initGen = decodeGenerationRef.current - const initialDecodeCount = Math.min(metas.length, 2) - console.log( - `[useCanvasPlayer] Starting init decode: ${initialDecodeCount} segments, metas: ${metas.length}`, - ) - for (let i = 0; i < initialDecodeCount; i++) { - if (cancelled) break - if (decodeGenerationRef.current !== initGen) break - console.log(`[DIAG_v2] Init decode segment ${i}...`) - try { - await decodeSegmentBatch(i, 60) - console.log(`[DIAG_v2] Init decode segment ${i} done`) - } catch (e) { - console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e) - } - if (cancelled) break - } - - console.log(`[useCanvasPlayer] Init decode finished, cancelled:`, cancelled) - - if (!cancelled) { - console.log("[useCanvasPlayer] Init complete, isReady = true, duration:", totalDuration) - setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false })) - } else { - console.warn("[useCanvasPlayer] Init was cancelled before completion") - } - } - - init() - - return () => { - cancelled = true - destroy() - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segments, state.hasSupport]) - - return { - state: { ...state, duration: totalDuration }, - controls: { play, pause, seek, destroy } satisfies CanvasPlayerControls, - preloadNext, - } -} - -export default useCanvasPlayer diff --git a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx index 4551e5bed..1379c33fd 100644 --- a/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx +++ b/apps/web/src/test/pages/generate/FrontendPreviewPlayer.audio.test.tsx @@ -1,7 +1,7 @@ /** * FrontendPreviewPlayer 音频行为单测(Issue #1741 / #1750) * - * useSegmentScheduler/useCanvasPlayer 用 mock 控制播放态,专注验证本组件的音频逻辑: + * useSegmentScheduler 用 mock 控制播放态,专注验证本组件的音频逻辑: * - 有配音时 video 保持 muted(素材原声不与配音混音) * - 无配音时 video 不 muted(素材原声兜底,保证任何情况下播放有声) * - 静音按钮:默认有声;点击后切 muted,aria-label 与图标切换