@@ -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 )
// 提取所有 samples( start() 会同步触发 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
} )
// 进度更新节流到 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 ( 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 ( )