fix: Canvas播放器修复 - 片段自动切换+宽高比+移除死代码+进度节流
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 43s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m48s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m49s
AI Code Review / AI Code Review (pull_request) Failing after 1m50s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m5s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 6m41s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 11s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 43s

1. 片段自动切换:初始化时加载所有片段数据并逐段解码,帧时间戳按全局偏移对齐
2. Canvas宽高比:computeDrawRect计算letterbox/pillarbox,保持视频原始比例
3. 移除未使用的audioCtxRef/audioSourceRef(音频由外部HTMLAudio处理)
4. 进度更新节流到200ms(5fps),减少React re-render
5. FrameQueue容量从5提升到10,多片段场景更流畅
6. fallback路径优化预加载:当前片段auto/下一个metadata/其余none
This commit is contained in:
张宏杰
2026-08-19 12:48:36 +08:00
parent 89a56eaf96
commit a07d6c9a87
2 changed files with 246 additions and 122 deletions
@@ -354,7 +354,9 @@ const FrontendPreviewPlayer: React.FC<FrontendPreviewPlayerProps> = ({
ref={(el) => {
videoRefs.current[i] = el
}}
preload="auto"
preload={
i === videoCurrentSegIdx ? "auto" : i === videoCurrentSegIdx + 1 ? "metadata" : "none"
}
src={seg.videoUrl}
style={{
position: "absolute",
@@ -1,7 +1,6 @@
/**
* Canvas + WebCodecs 播放器核心 Hook
* MP4 → mp4box.js 解封装 → VideoDecoder 解码帧 → Canvas 绘制
* 音频走 Web Audio API 独立同步播放
*
* 浏览器不支持 WebCodecs 时返回 hasSupport=false,由调用方 fallback
*/
@@ -12,7 +11,7 @@ import type { Movie, Sample } from "mp4box"
// ── 帧队列(环形缓冲区) ──
interface FrameEntry {
frame: VideoFrame
pts: number // 显示时间戳(秒)
pts: number // 全局时间戳(秒),已按片段偏移对齐
duration: number // 帧持续时长(秒)
}
@@ -25,7 +24,6 @@ class FrameQueue {
}
push(entry: FrameEntry) {
// 如果队列已满,丢弃最旧的帧
while (this.frames.length >= this.maxSize) {
const old = this.frames.shift()
old?.frame.close()
@@ -35,18 +33,15 @@ class FrameQueue {
/** 获取当前时间戳应显示的帧 */
getCurrentFrame(timestamp: number): VideoFrame | null {
// 找到 pts <= timestamp 的最新帧
let best: FrameEntry | null = null
let bestIdx = -1
for (let i = 0; i < this.frames.length; i++) {
const f = this.frames[i]
if (f.pts <= timestamp + 0.01) {
// 10ms 容差
best = f
bestIdx = i
}
}
// 释放已消费帧之前的所有帧
for (let i = 0; i < bestIdx; i++) {
this.frames[i].frame.close()
}
@@ -68,19 +63,33 @@ class FrameQueue {
}
}
// ── 片段元数据 ──
interface SegmentMeta {
assetId: string
videoUrl: string
/** 该片段在全局时间轴上的起始时间(秒) */
globalStartTime: number
/** 该片段在全局时间轴上的结束时间(秒) */
globalEndTime: number
/** 视频轨道 ID */
trackId: number
/** 视频轨道 timescale */
timescale: number
/** 编解码器 */
codec: string
/** 视频宽度(像素) */
videoWidth: number
/** 视频高度(像素) */
videoHeight: number
}
// ── 播放器状态 ──
export interface CanvasPlayerState {
/** 是否支持 WebCodecs */
hasSupport: boolean
/** 是否正在播放 */
isPlaying: boolean
/** 当前播放时间(秒) */
currentTime: number
/** 总时长(秒) */
duration: number
/** 是否已加载(可以播放) */
isReady: boolean
/** 是否正在缓冲 */
isBuffering: boolean
}
@@ -131,15 +140,15 @@ export function useCanvasPlayer(
// ── 内部引用 ──
const decoderRef = useRef<VideoDecoder | null>(null)
const frameQueueRef = useRef(new FrameQueue(5))
const audioCtxRef = useRef<AudioContext | null>(null)
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null)
const frameQueueRef = useRef(new FrameQueue(10))
const rafRef = useRef<number>(0)
const playStartRef = useRef<number>(0)
const playStartOffsetRef = useRef<number>(0)
const segmentDataRef = useRef<Map<string, ArrayBuffer>>(new Map())
const videoTrackRef = useRef<{ id: number; timescale: number; codec: string } | null>(null)
const segmentMetaRef = useRef<SegmentMeta[]>([])
const videoDimRef = useRef<{ width: number; height: number }>({ width: 0, height: 0 })
const isDestroyedRef = useRef(false)
const lastProgressUpdateRef = useRef<number>(0)
// 计算总时长
const totalDuration = segments.reduce((sum, seg) => sum + (seg.endTime - seg.startTime), 0)
@@ -162,92 +171,47 @@ export function useCanvasPlayer(
}
}, [])
// ── 初始化 VideoDecoder ──
const initDecoder = useCallback(async (codec: string, width: number, height: number) => {
if (!isWebCodecsSupported()) return false
// ── 解封装单个片段,提取轨道元数据 ──
const demuxSegment = useCallback(
(buffer: ArrayBuffer, segIndex: number): SegmentMeta | null => {
const segment = segments[segIndex]
if (!segment) return null
const decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
frameQueueRef.current.push({
frame,
pts: frame.timestamp / 1_000_000,
duration: (frame.duration ?? 0) / 1_000_000,
})
},
error: (e: DOMException) => {
console.error("[useCanvasPlayer] Decoder error:", e)
},
})
// 计算全局偏移
let globalStart = 0
for (let i = 0; i < segIndex; i++) {
globalStart += segments[i].endTime - segments[i].startTime
}
try {
await decoder.configure({
codec,
codedWidth: width,
codedHeight: height,
})
decoderRef.current = decoder
return true
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed:", err)
return false
}
}, [])
// ── 解封装 MP4 并送入解码器 ──
const demuxAndDecode = useCallback(
async (buffer: ArrayBuffer) => {
const mp4File = createFile()
let meta: SegmentMeta | null = null
mp4File.onReady = (info: Movie) => {
const videoTrack = info.videoTracks[0]
if (!videoTrack) {
console.error("[useCanvasPlayer] No video track found")
console.error("[useCanvasPlayer] No video track found for segment", segIndex)
return
}
videoTrackRef.current = {
id: videoTrack.id,
meta = {
assetId: segment.assetId,
videoUrl: segment.videoUrl,
globalStartTime: globalStart,
globalEndTime: globalStart + (segment.endTime - segment.startTime),
trackId: videoTrack.id,
timescale: videoTrack.timescale,
codec: videoTrack.codec,
videoWidth: videoTrack.track_width || 1280,
videoHeight: videoTrack.track_height || 720,
}
const width = videoTrack.track_width || 1280
const height = videoTrack.track_height || 720
initDecoder(videoTrack.codec, width, height)
// 提取所有 samplesstart() 会同步触发 onSamples
mp4File.setExtractionOptions(videoTrack.id, null, {
nbSamples: videoTrack.nb_samples,
})
mp4File.start()
}
mp4File.onSamples = (trackId: number, _user: unknown, samples: Sample[]) => {
if (isDestroyedRef.current) return
const videoTrack = videoTrackRef.current
if (!videoTrack || trackId !== videoTrack.id) return
const decoder = decoderRef.current
if (!decoder || decoder.state === "closed") return
for (const sample of samples) {
if (!sample.data) continue
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: (sample.cts / videoTrack.timescale) * 1_000_000,
duration: (sample.duration / videoTrack.timescale) * 1_000_000,
data: sample.data.buffer as ArrayBuffer,
})
try {
decoder.decode(chunk)
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
}
mp4File.onError = (_module: string, message: string) => {
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
}
@@ -257,8 +221,112 @@ export function useCanvasPlayer(
;(buffer as any).fileStart = 0
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mp4File.appendBuffer(buffer as any)
return meta
},
[initDecoder],
[segments],
)
// ── 初始化 VideoDecoder 并解码指定片段 ──
const decodeSegment = useCallback(
async (buffer: ArrayBuffer, meta: SegmentMeta): Promise<void> => {
if (isDestroyedRef.current) return
const mp4File = createFile()
let decoderReady = false
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
const decoder = new VideoDecoder({
output: (frame: VideoFrame) => {
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] Decoder error:", e)
},
})
try {
await decoder.configure({
codec: meta.codec,
codedWidth: meta.videoWidth,
codedHeight: meta.videoHeight,
})
decoderRef.current = decoder
decoderReady = true
// 更新视频尺寸(用于 aspect ratio
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
videoDimRef.current = { width: meta.videoWidth, height: meta.videoHeight }
}
} catch (err) {
console.error("[useCanvasPlayer] Decoder configure failed for segment:", err)
return
}
if (!decoderReady) return
// 收集 samples
const samplesCollected: Sample[] = []
mp4File.onReady = (info: Movie) => {
const videoTrack = info.videoTracks[0]
if (!videoTrack) return
mp4File.setExtractionOptions(videoTrack.id, null, {
nbSamples: videoTrack.nb_samples,
})
mp4File.start()
}
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
samplesCollected.push(...samples)
}
mp4File.onError = (_module: string, message: string) => {
console.error(`[useCanvasPlayer] MP4Box decode error: ${message}`)
}
// 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)
// 等待 samples 收集完成(mp4box start() 是同步的)
await new Promise((resolve) => setTimeout(resolve, 10))
// 送入解码器
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) continue
if (decoder.state === "closed") break
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: (sample.cts / meta.timescale) * 1_000_000,
duration: (sample.duration / meta.timescale) * 1_000_000,
data: sample.data.buffer as ArrayBuffer,
})
try {
decoder.decode(chunk)
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
// flush 确保所有帧输出
try {
await decoder.flush()
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
},
[],
)
// ── 标题绘制 ──
@@ -302,7 +370,6 @@ export function useCanvasPlayer(
ctx.fillText(title.text, canvas.width / 2, y)
// 重置阴影
ctx.shadowColor = "transparent"
ctx.shadowBlur = 0
ctx.shadowOffsetX = 0
@@ -311,6 +378,37 @@ export function useCanvasPlayer(
[],
)
// ── 计算保持宽高比的绘制矩形(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
@@ -325,21 +423,29 @@ export function useCanvasPlayer(
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (frame) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(frame, 0, 0, canvas.width, canvas.height)
const rect = computeDrawRect(canvas.width, canvas.height)
ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh)
frame.close()
}
if (titleSettings?.text) {
drawTitle(ctx, canvas, titleSettings)
}
setState((s) => {
if (Math.abs(s.currentTime - currentTime) > 0.1) {
return { ...s, currentTime }
}
return s
})
// 进度更新节流到 200ms5fps),减少 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 (currentTime >= totalDuration) {
setState((s) => ({ ...s, isPlaying: false }))
@@ -347,30 +453,22 @@ export function useCanvasPlayer(
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle])
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
// ── 播放控制 ──
const play = useCallback(async () => {
if (!state.hasSupport || isDestroyedRef.current) return
if (!audioCtxRef.current) {
audioCtxRef.current = new AudioContext()
}
setState((s) => ({ ...s, isPlaying: true }))
playStartRef.current = performance.now()
playStartOffsetRef.current = state.currentTime
lastProgressUpdateRef.current = 0
rafRef.current = requestAnimationFrame(renderFrame)
}, [state.hasSupport, state.currentTime, renderFrame])
const pause = useCallback(() => {
setState((s) => ({ ...s, isPlaying: false }))
cancelAnimationFrame(rafRef.current)
if (audioSourceRef.current && audioCtxRef.current) {
audioSourceRef.current.stop()
audioSourceRef.current = null
}
}, [])
const seek = useCallback(
@@ -379,6 +477,8 @@ export function useCanvasPlayer(
setState((s) => ({ ...s, currentTime: clampedTime }))
playStartOffsetRef.current = clampedTime
playStartRef.current = performance.now()
// seek 后清空帧队列,等待新帧解码
frameQueueRef.current.clear()
},
[totalDuration],
)
@@ -392,21 +492,11 @@ export function useCanvasPlayer(
}
frameQueueRef.current.clear()
if (audioSourceRef.current) {
audioSourceRef.current.stop()
audioSourceRef.current = null
}
if (audioCtxRef.current) {
audioCtxRef.current.close()
audioCtxRef.current = null
}
segmentDataRef.current.clear()
segmentMetaRef.current = []
}, [])
// ── 预加载下一个片段 ──
// ── 预加载下一个片段的数据 ──
const preloadNext = useCallback(
async (currentIndex: number) => {
const nextIdx = currentIndex + 1
@@ -418,18 +508,50 @@ export function useCanvasPlayer(
[segments, loadSegment],
)
// ── 初始化:加载第一个片段并解封装 ──
// ── 初始化:加载并解码所有片段 ──
useEffect(() => {
if (!state.hasSupport || segments.length === 0) return
const init = async () => {
await loadSegment(segments[0])
const buffer = segmentDataRef.current.get(segments[0].assetId)
if (buffer) {
await demuxAndDecode(buffer)
setState((s) => ({ ...s, duration: totalDuration, isReady: true }))
preloadNext(0)
setState((s) => ({ ...s, isBuffering: true }))
// 1. 加载所有片段数据
for (const seg of segments) {
await loadSegment(seg)
}
if (isDestroyedRef.current) return
// 2. 解析每个片段的轨道元数据
const metas: SegmentMeta[] = []
for (let i = 0; i < segments.length; i++) {
const buffer = segmentDataRef.current.get(segments[i].assetId)
if (!buffer) continue
const meta = demuxSegment(buffer, i)
if (meta) metas.push(meta)
}
if (isDestroyedRef.current || metas.length === 0) {
setState((s) => ({ ...s, isBuffering: false }))
return
}
segmentMetaRef.current = metas
// 3. 设置视频尺寸(用第一个片段的尺寸)
if (metas[0].videoWidth > 0 && metas[0].videoHeight > 0) {
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
}
// 4. 依次解码每个片段
for (const meta of metas) {
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
await decodeSegment(buffer, meta)
if (isDestroyedRef.current) break
}
setState((s) => ({ ...s, duration: totalDuration, isReady: true, isBuffering: false }))
}
init()