@@ -28,32 +28,43 @@ function findCodecConfig(buffer: ArrayBuffer, start: number, end: number): Array
// 容器 box( fullbox 多 4 字节)
const containerBoxes = [ "trak" , "mdia" , "minf" , "stbl" ]
if ( containerBoxes . includes ( type ) ) {
// stbl 是 fullbox,跳过 4 字节 version/flags
const contentStart = offset + 8
// fullbox: size(4) + type(4) + version(1) + flags(3) = 12 bytes header
const contentStart = offset + 12
const result = findCodecConfig ( buffer , contentStart , offset + size )
if ( result ) return result
} else if ( type === "stsd" ) {
// SampleDescriptionBox 是 fullbox: 8 header + 4 version/flags + 4 entry_count
const entryCount = view . getUint32 ( offset + 12 )
let entryOffset = offset + 16
console . log ( "[findCodecConfig] stsd found:" , {
entryCount ,
stsdOffset : offset ,
stsdSize : size ,
} )
for ( let i = 0 ; i < entryCount && entryOffset < offset + size ; i ++ ) {
const entrySize = view . getUint32 ( entryOffset )
const entryType = String . fromCharCode (
view . getUint8 ( entryOffset + 4 ) ,
view . getUint8 ( entryOffset + 5 ) ,
view . getUint8 ( entryOffset + 6 ) ,
view . getUint8 ( entryOffset + 7 ) ,
)
console . log ( "[findCodecConfig] entry:" , { index : i , entryOffset , entrySize , entryType } )
// 视觉样本条目: 8 header + 6 reserved + 2 data_ref_index + remaining
// 子 box 从 entryOffset + 16 + 62 开始 (skip reserved + data_ref_index + predefined)
// 实际结构: 8(header) + 6(reserved) + 2(data_ref_index) + 16(predefined+reserved) + 2(width) + 2(height) + ...
// 子 box 从 entryOffset + 8 + 6 + 2 + 16 + 2 + 2 + 2 + 2 + 4 + 2 + 2 + 2 + 2 = entryOffset + 78
// 更简单的做法:扫描 entry 内的子 box
const entryEnd = entryOffset + entrySize
const subBoxStart = entryOffset + 8 + 70 // 跳过 header(8) + v isual s ample e ntry fixed fields(70)
const subBoxStart = entryOffset + 8 + 78 // V isualS ampleE ntry 固定字段共 78 字节(ISO 14496-12)
const result = findCodecConfig ( buffer , subBoxStart , entryEnd )
if ( result ) return result
entryOffset += entrySize
}
} else if ( type === "avcC" || type === "hvcC" ) {
// 找到目标 box,返回其内容(不含 box header)
const dataStart = offset + 8
const dataSize = size - 8
return buffer . slice ( dataStart , dataStart + dataSize )
// 找到目标 box,返回完整 box(含 header)
// 返回完整 box(含 size + type header),WebCodecs HEVC decoder 需要
return buffer . slice ( offset , offset + size )
}
if ( size === 0 ) break
@@ -137,6 +148,8 @@ interface SegmentMeta {
videoHeight : number
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
description? : ArrayBuffer
/** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */
samples : Sample [ ]
}
// ── 播放器状态 ──
@@ -205,6 +218,9 @@ export function useCanvasPlayer(
const videoDimRef = useRef < { width : number ; height : number } > ( { width : 0 , height : 0 } )
const isDestroyedRef = useRef ( false )
const lastProgressUpdateRef = useRef < number > ( 0 )
const descriptionCache = useRef < Map < string , ArrayBuffer > > ( new Map ( ) )
const decodedSegmentsRef = useRef ( new Set < number > ( ) )
const lastDrawnFrameRef = useRef < VideoFrame | null > ( null )
// 计算总时长
const totalDuration = segments . reduce ( ( sum , seg ) = > sum + ( seg . endTime - seg . startTime ) , 0 )
@@ -244,7 +260,15 @@ export function useCanvasPlayer(
view . getUint8 ( offset + 7 ) ,
)
if ( type === "moov" ) {
return findCodecConfig ( buffer , offset + 8 , offset + size )
const result = findCodecConfig ( 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
@@ -255,11 +279,15 @@ export function useCanvasPlayer(
return undefined
} , [ ] )
// ── 解封装单个片段,提取轨道元数据 ──
// ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ──
// ✅ 关键修复:改为异步函数,等待 MP4Box.js 的 onSamples 回调完成后再返回
const demuxSegment = useCallback (
( buffer : ArrayBuffer , segIndex : number ) : SegmentMeta | null = > {
const segment = segments [ segIndex ]
if ( ! segment ) return null
async ( buffer : ArrayBuffer , segIndex : number ) : Promise < SegmentMeta | null > = > {
const segment = segments ? . [ segIndex ]
if ( ! segment ) {
console . warn ( "[useCanvasPlayer] No segment at index" , segIndex )
return null
}
// 计算全局偏移
let globalStart = 0
@@ -268,59 +296,164 @@ export function useCanvasPlayer(
}
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 for segment" , segIndex )
return
return new Promise < SegmentMeta | null > ( ( 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 也需要)
let description = extractCodecDescription ( buffer )
// 如果当前分片没有 description,尝试从缓存获取
if ( ! description ) {
for ( const cached of descriptionCache . current . values ( ) ) {
description = cached
break
}
}
// ✅ 如果 description 缺失,无法解码 HEVC
if ( ! description ) {
console . error (
` [useCanvasPlayer] No description found for segment ${ segIndex } , cannot decode HEVC ` ,
)
clearTimeout ( timeout )
resolved = true
resolve ( null )
return
}
// 缓存 description 供后续分片使用
descriptionCache . current . set ( segment . assetId , description )
meta = {
assetId : segment.assetId ,
videoUrl : segment.videoUrl ,
globalStartTime : globalStart ,
globalEndTime : globalStart + ( segment . endTime - segment . startTime ) ,
trackId : videoTrack.id ? ? 1 ,
timescale : videoTrack.timescale ? ? 90000 ,
codec : 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 ( )
}
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
const description = extractCodecDescription ( buffer )
mp4File . onSamples = ( _trackId : number , _user : unknown , samples : Sample [ ] ) = > {
if ( resolved ) return // ✅ 防止重复 resolve
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 ,
description ,
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 )
}
// 提取所有 samples( start() 会同步触发 onSamples)
mp4File . setExtractionOptions ( videoTrack . id , null , {
nbSamples : videoTrack.nb_samples ,
} )
mp4File . start ( )
}
mp4File . onError = ( _module : string , message : string ) = > {
console . error ( ` [useCanvasPlayer] MP4Box error: ${ message } ` )
clearTimeout ( timeout )
resolved = true
resolve ( null )
}
mp4File . onError = ( _module : string , message : string ) = > {
console . error ( ` [useCanvasPlayer] MP4Box error: ${ message } ` )
}
// mp4box 需要在 buffer 上设置 fileStart 属性
// 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 )
return meta
// 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 ] ,
)
// ── 初始化 VideoDecoder 并解码指定片段 ──
const decodeSegment = useCallback (
async ( buffer : ArrayBuffer , meta : SegmentMeta ) : Promise < void > = > {
async ( _buffer : ArrayBuffer , meta : SegmentMeta ) : Promise < void > = > {
if ( isDestroyedRef . current ) return
const mp4File = createFile ( )
let decoderReady = false
// 配置解码器(每个片段可能需要不同的 codec/分辨率)
@@ -339,6 +472,14 @@ export function useCanvasPlayer(
} ,
} )
console . log ( "[useCanvasPlayer] configure:" , {
codec : meta.codec ,
description : meta.description ,
descriptionByteLength : meta.description?.byteLength ,
videoWidth : meta.videoWidth ,
videoHeight : meta.videoHeight ,
} )
try {
await decoder . configure ( {
codec : meta.codec ,
@@ -360,45 +501,24 @@ export function useCanvasPlayer(
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 ( )
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta . samples
if ( samplesCollected . length === 0 ) {
console . warn ( "[useCanvasPlayer] No samples to decode for segment" , meta . assetId )
return
}
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
if ( ! sample . data ) continue
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 ,
timestamp : ( ( sample . cts ? ? 0 ) / ( meta . timescale || 90000 ) ) * 1 _000_000 ,
duration : ( ( sample . duration ? ? 0 ) / ( meta . timescale || 90000 ) ) * 1 _000_000 ,
data : sample.data ,
} )
try {
@@ -431,7 +551,8 @@ export function useCanvasPlayer(
ctx . textAlign = "center"
// 按 "/" 分割为多行("/" 作为手动换行符)
const lines = title . text . split ( "/" )
const lines = title . text . split ( /[//⁄∕]/ )
console . log ( "[drawTitle] 原始标题:" , JSON . stringify ( title . text ) , "分割后:" , lines )
const lineHeight = fontSize * 1.3
const totalHeight = lines . length * lineHeight
@@ -506,6 +627,43 @@ export function useCanvasPlayer(
[ ] ,
)
// ── 按需解码:根据当前播放位置解码附近片段 ──
const decodeAroundPosition = useCallback (
async ( currentTime : number ) = > {
const metas = segmentMetaRef . current
if ( metas . length === 0 ) return
// 找到当前时间对应的片段
let currentIdx = 0
for ( let i = 0 ; i < metas . length ; i ++ ) {
if ( currentTime >= metas [ i ] . globalStartTime && currentTime < metas [ i ] . globalEndTime ) {
currentIdx = i
break
}
if ( currentTime >= metas [ metas . length - 1 ] . globalEndTime ) {
currentIdx = metas . length - 1
}
}
// 解码当前片段及前后各 1 个片段
const startIdx = Math . max ( 0 , currentIdx - 1 )
const endIdx = Math . min ( metas . length - 1 , currentIdx + 1 )
for ( let i = startIdx ; i <= endIdx ; i ++ ) {
if ( decodedSegmentsRef . current . has ( i ) ) continue
if ( isDestroyedRef . current ) break
const meta = metas [ i ]
const buffer = segmentDataRef . current . get ( meta . assetId )
if ( ! buffer ) continue
decodedSegmentsRef . current . add ( i )
await decodeSegment ( buffer , meta )
}
} ,
[ decodeSegment ] ,
)
// ── Canvas 渲染循环 ──
const renderFrame = useCallback ( ( ) = > {
if ( isDestroyedRef . current ) return
@@ -523,6 +681,11 @@ export function useCanvasPlayer(
ctx . clearRect ( 0 , 0 , canvas . width , canvas . height )
if ( frame ) {
// 关闭上一帧,防止 VideoFrame 资源泄漏
if ( lastDrawnFrameRef . current ) {
lastDrawnFrameRef . current . close ( )
}
lastDrawnFrameRef . current = frame
const rect = computeDrawRect ( canvas . width , canvas . height )
ctx . drawImage ( frame , rect . dx , rect . dy , rect . dw , rect . dh )
}
@@ -541,6 +704,9 @@ export function useCanvasPlayer(
}
return s
} )
// 按需解码后续片段(每 200ms 检查一次,避免阻塞渲染)
decodeAroundPosition ( currentTime )
}
if ( currentTime >= totalDuration ) {
@@ -549,7 +715,7 @@ export function useCanvasPlayer(
}
rafRef . current = requestAnimationFrame ( renderFrame )
} , [ canvasRef , totalDuration , titleSettings , drawTitle , computeDrawRect ] )
} , [ canvasRef , totalDuration , titleSettings , drawTitle , computeDrawRect , decodeAroundPosition ] )
// ── 播放控制 ──
const play = useCallback ( async ( ) = > {
@@ -568,15 +734,22 @@ export function useCanvasPlayer(
} , [ ] )
const seek = useCallback (
( time : number ) = > {
async ( time : number ) = > {
const clampedTime = Math . max ( 0 , Math . min ( time , totalDuration ) )
setState ( ( s ) = > ( { . . . s , currentTime : clampedTime } ) )
playStartOffsetRef . current = clampedTime
playStartRef . current = performance . now ( )
// seek 后清空帧队列,等待新帧 解码
// seek 后清空帧队列和已解码标记,触发重新 解码
frameQueueRef . current . clear ( )
if ( lastDrawnFrameRef . current ) {
lastDrawnFrameRef . current . close ( )
lastDrawnFrameRef . current = null
}
decodedSegmentsRef . current . clear ( )
// 立即解码 seek 位置附近的片段
await decodeAroundPosition ( clampedTime )
} ,
[ totalDuration ] ,
[ totalDuration , decodeAroundPosition ] ,
)
const destroy = useCallback ( ( ) = > {
@@ -587,9 +760,15 @@ export function useCanvasPlayer(
decoderRef . current . close ( )
}
if ( lastDrawnFrameRef . current ) {
lastDrawnFrameRef . current . close ( )
lastDrawnFrameRef . current = null
}
frameQueueRef . current . clear ( )
segmentDataRef . current . clear ( )
segmentMetaRef . current = [ ]
descriptionCache . current . clear ( )
decodedSegmentsRef . current . clear ( )
} , [ ] )
// ── 预加载下一个片段的数据 ──
@@ -618,12 +797,12 @@ export function useCanvasPlayer(
if ( isDestroyedRef . current ) return
// 2. 解析每个片段的轨道元数据
// 2. 解析每个片段的轨道元数据( await 等待 onSamples 回调完成)
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 )
const meta = await demuxSegment ( buffer , i )
if ( meta ) metas . push ( meta )
}
@@ -639,10 +818,14 @@ export function useCanvasPlayer(
videoDimRef . current = { width : metas [ 0 ] . videoWidth , height : metas [ 0 ] . videoHeight }
}
// 4. 依次解码每个片段
for ( const meta of metas ) {
// 4. 按需解码:初始只解码前 3 个片段(当前 + 前后各 1),避免环形缓冲区溢出导致黑屏
decodedSegmentsRef . current . clear ( )
const initialEnd = Math . min ( metas . length , 3 )
for ( let i = 0 ; i < initialEnd ; i ++ ) {
const meta = metas [ i ]
const buffer = segmentDataRef . current . get ( meta . assetId )
if ( ! buffer ) continue
decodedSegmentsRef . current . add ( i )
await decodeSegment ( buffer , meta )
if ( isDestroyedRef . current ) break
}