@@ -1,9 +1,9 @@
/**
* 素材片段调度器 Hook
* 控制原生 <video> 元素按时间线依次播放素材片段(入点→出点)
* 实现前端预览播放,替代后端 FFmpeg 渲染
* 素材片段调度器 Hook(多 video 元素方案 v2)
* 每个片段对应一个独立 <video> 元素,全部预加载,通过 display 切换实现无缝播放
* 替代单 video + 切 src 方案,消除片段切换延迟
*/
import React , { useState , useRef , useCallback , useEffect , useMemo } from "react"
import { useState , useRef , useCallback , useEffect , useMemo } from "react"
/** 单个播放片段 */
export interface PlaybackSegment {
@@ -27,7 +27,7 @@ export interface SegmentSchedulerState {
currentTime : number
/** 总时长(秒) */
totalDuration : number
/** 当前片段索引(在 segments 数组中的位置) */
/** 当前片段索引 */
currentSegmentIndex : number
/** 当前片段的本地播放时间 */
segmentLocalTime : number
@@ -43,8 +43,8 @@ export interface SegmentSchedulerState {
togglePlayPause : ( ) = > void
/** 跳转到全局时间 */
seekTo : ( time : number ) = > void
/** 绑定到 < video> 元素 */
videoRef : React.RefObject < HTMLVideoElement >
/** 每个片段对应的 video 元素 ref 数组 */
videoRefs : React.MutableRefObject < ( HTMLVideoElement | null ) [ ] >
}
/**
@@ -80,10 +80,17 @@ function buildTimeline(segments: PlaybackSegment[]): number[] {
}
/**
* useSegmentScheduler — 素材片段调度器
* useSegmentScheduler — 多 video 元素版 素材片段调度器
*
* 核心改变:
* - 每个片段对应一个独立 <video> 元素(由组件渲染,ref 传入)
* - 所有 video 在挂载时即设置 src + preload="auto",浏览器自动预加载
* - 切换片段仅改 currentSegmentIndex + display,无需重新 load
* - 实现无缝切换,无加载延迟
*/
export function useSegmentScheduler ( segments : PlaybackSegment [ ] ) : SegmentSchedulerState {
const videoRef = useRef < HTMLVideoElement | null > ( null )
/** 每个片段对应的 video 元素 ref(由组件 JSX 渲染并绑定) */
const videoRefs = useRef < ( HTMLVideoElement | null ) [ ] > ( [ ] )
const [ isPlaying , setIsPlaying ] = useState ( false )
const [ currentTime , setCurrentTime ] = useState ( 0 )
const [ currentSegmentIndex , setCurrentSegmentIndex ] = useState ( 0 )
@@ -91,13 +98,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const rafRef = useRef < number > ( 0 )
const isSeekingRef = useRef ( false )
// ✅ 关键修复:用 ref 标记是否已加载过片段(替代检查 video.src)
// video.src = "" 在浏览器中会被解析为页面 URL,导致 "video.src === ''" 永远为 false
const srcLoadedRef = useRef ( false )
// 预加载用的隐藏 video 元素
const preloadVideoRef = useRef < HTMLVideoElement | null > ( null )
// 计算时间线
const timelineStarts = useMemo ( ( ) = > buildTimeline ( segments ) , [ segments ] )
const totalDuration = useMemo (
@@ -113,122 +113,63 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
? currentTime - ( timelineStarts [ currentSegmentIndex ] || 0 ) + currentSegment.startTime
: 0
/** 切换到指定片段,返回 Promise 在视频可播放后 resolve */
/**
* 切换到指定片段
* 不改变 src(video 已在 JSX 中设置),仅 seek + 等待可播
*/
const switchToSegment = useCallback (
( index : number , seekToLocalTime? : number ) : Promise < void > = > {
return new Promise ( ( resolve ) = > {
const video = videoRef . current
// 暂停当前视频
const prevVideo = videoRefs . current [ currentSegmentIndex ]
if ( prevVideo ) prevVideo . pause ( )
const video = videoRefs . current [ index ]
if ( ! video || index >= segments . length ) {
resolve ( )
return
}
const seg = segments [ index ]
const videoUrl = seg . videoUrl
const localTime = seekToLocalTime ? ? seg . startTime
if ( ! videoUrl || videoUrl === "" ) {
console . error (
` [useSegmentScheduler] 片段 ${ index } 的 videoUrl 为空!assetId: ${ seg . assetId } ` ,
)
// 设置播放位置
video . currentTime = localTime
// 如果已有足够帧数据,直接 resolve
if ( video . readyState >= 2 ) {
setCurrentSegmentIndex ( index )
resolve ( )
return
}
// 检查是否是完整的 HTTP URL
if ( ! videoUrl . startsWith ( "http://" ) && ! videoUrl . startsWith ( "https://" ) ) {
console . warn ( ` [useSegmentScheduler] 片段 ${ index } 的 videoUrl 不是完整 URL: ${ videoUrl } ` )
}
const localTime = seekToLocalTime ? ? seg . startTime
// ✅ 关键修复:清除旧事件监听,防止重复绑定
const cleanup = ( ) = > {
video . removeEventListener ( "loadedmetadata" , onLoaded )
video . removeEventListener ( "error" , onError )
// 等待 canplay 事件
const onCanPlay = ( ) = > {
video . removeEventListener ( "canplay" , onCanPlay )
clearTimeout ( timeoutId )
}
const onLoaded = ( ) = > {
video . currentTime = localTime
srcLoadedRef . current = true
cleanup ( )
console . log (
` [useSegmentScheduler] 片段 ${ index } loadedmetadata, src= ${ videoUrl . substring ( 0 , 80 ) } ` ,
)
setCurrentSegmentIndex ( index )
resolve ( )
}
const onCanPlay = ( ) = > {
video . currentTime = localTime
srcLoadedRef . current = true
cleanup ( )
console . log (
` [useSegmentScheduler] 片段 ${ index } canplay, src= ${ videoUrl . substring ( 0 , 80 ) } ` ,
)
resolve ( )
}
const onError = ( ) = > {
console . error (
` [useSegmentScheduler] 视频加载失败: ${ videoUrl } ` ,
video . error ? . message || ` code= ${ video . error ? . code } ` ,
)
srcLoadedRef . current = true // 标记为已尝试,避免死循环
cleanup ( )
resolve ( )
}
// ✅ 关键修复:10秒超时防止 Promise 永远不 resolve
// 10 秒超时保护
const timeoutId = setTimeout ( ( ) = > {
video . removeEventListener ( "canplay" , onCanPlay )
console . warn (
` [useSegmentScheduler] 片段 ${ index } 加载超时 (10s), readyState= ${ video . readyState } ` ,
` [useSegmentScheduler] 片段 ${ index } 预 加载超时 (10s), readyState= ${ video . readyState } ` ,
)
cleanup ( )
setCurrentSegmentIndex ( index )
resolve ( )
} , 10000 )
// 先绑定事件,再设置 src(避免错过已触发的loadedmetadata)
video . addEventListener ( "loadedmetadata" , onLoaded )
video . addEventListener ( "canplay" , onCanPlay )
video . addEventListener ( "error" , onError )
// 设置 src 并显式调用 load()
video . src = videoUrl
video . load ( )
// ✅ 关键修复:如果 readyState >= 2( HAVE_CURRENT_DATA),说明已有帧数据
// 直接设置 currentTime 并 resolve,不等待事件
if ( video . readyState >= 2 ) {
video . currentTime = localTime
srcLoadedRef . current = true
cleanup ( )
console . log (
` [useSegmentScheduler] 片段 ${ index } 已缓存(readyState= ${ video . readyState } ), 直接播放 ` ,
)
resolve ( )
return
}
setCurrentSegmentIndex ( index )
// 预加载下一段
if ( index + 1 < segments . length ) {
const nextSeg = segments [ index + 1 ]
if ( ! preloadVideoRef . current ) {
preloadVideoRef . current = document . createElement ( "video" )
preloadVideoRef . current . preload = "auto"
}
preloadVideoRef . current . src = nextSeg . videoUrl
}
} )
} ,
[ segments ] ,
[ segments , currentSegmentIndex ] ,
)
/** 播放循环 — 检测片段边界并切换 */
const tick = useCallback ( ( ) = > {
const video = videoRef . current
const video = videoRefs . current [ currentSegmentIndex ]
if ( ! video || isSeekingRef . current ) {
rafRef . current = requestAnimationFrame ( tick )
return
@@ -237,36 +178,23 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
const seg = segments [ currentSegmentIndex ]
if ( ! seg ) return
// 提前 2 秒预加载下一段
if ( currentSegmentIndex + 1 < segments . length ) {
const nextSeg = segments [ currentSegmentIndex + 1 ]
if ( ! preloadVideoRef . current ) {
preloadVideoRef . current = document . createElement ( "video" )
preloadVideoRef . current . preload = "auto"
}
if ( preloadVideoRef . current . src !== nextSeg . videoUrl ) {
preloadVideoRef . current . src = nextSeg . videoUrl
preloadVideoRef . current . load ( )
}
}
// 检查是否到达出点(容差 0.15s)
if ( video . currentTime >= seg . endTime - 0.15 ) {
video . pause ( )
const nextIndex = currentSegmentIndex + 1
if ( nextIndex < segments . length ) {
switchToSegment ( nextIndex ) . then ( ( ) = > {
const v = videoRef . current
if ( v ) {
v . play ( ) . catch ( ( e ) = >
console . warn ( "[useSegmentScheduler] auto-play next segment failed:" , e ) ,
)
const nextVideo = videoRefs . current [ nextIndex ]
if ( nextVideo ) {
nextVideo
. play ( )
. catch ( ( e ) = > console . warn ( "[useSegmentScheduler] auto-play next segment failed:" , e ) )
}
} )
const accumulatedTime =
( timelineStarts [ currentSegmentIndex ] || 0 ) + ( seg . endTime - seg . startTime )
setCurrentTime ( accumulatedTime )
} else {
video . pause ( )
setIsPlaying ( false )
setIsEnded ( true )
setCurrentTime ( totalDuration )
@@ -283,52 +211,38 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 播放 */
const play = useCallback ( async ( ) = > {
const video = videoRef . current
if ( ! video || ! canPlay ) {
console . log ( "[useSegmentScheduler] play: video=" , ! ! video , "canPlay=" , canPlay )
return
}
if ( ! canPlay ) return
setIsEnded ( false )
// ✅ 关键修复:用 ref 标记代替 video.src 检查
if ( ! srcLoadedRef . current ) {
console . log ( "[useSegmentScheduler] 首次播放,加载片段 0..." )
// 确保第一段可播放
const firstVideo = videoRefs . current [ 0 ]
if ( firstVideo && currentSegmentIndex === 0 && firstVideo . readyState < 2 ) {
await switchToSegment ( 0 )
}
const video = videoRefs . current [ currentSegmentIndex ]
if ( ! video ) return
try {
const playPromise = video . play ( )
if ( playPromise !== undefined ) {
await playPromise
}
console . log ( "[useSegmentScheduler] 播放开始, src=" , video . src ? . substring ( 0 , 80 ) )
setIsPlaying ( true )
rafRef . current = requestAnimationFrame ( tick )
} catch ( err ) {
console . warn ( "[useSegmentScheduler] 播放失败:" , err , "src=" , video . src ? . substring ( 0 , 80 ) )
// 如果播放失败,尝试重新加载片段
if ( ! srcLoadedRef . current ) {
srcLoadedRef . current = false
await switchToSegment ( 0 )
try {
await video . play ( )
setIsPlaying ( true )
rafRef . current = requestAnimationFrame ( tick )
} catch ( retryErr ) {
console . error ( "[useSegmentScheduler] 重试播放仍然失败:" , retryErr )
}
}
console . warn ( "[useSegmentScheduler] 播放失败:" , err )
}
} , [ canPlay , switchToSegment , tick ] )
} , [ canPlay , switchToSegment , tick , currentSegmentIndex ] )
/** 暂停 */
const pause = useCallback ( ( ) = > {
const video = videoRef . current
const video = videoRefs . current [ currentSegmentIndex ]
if ( video ) video . pause ( )
setIsPlaying ( false )
cancelAnimationFrame ( rafRef . current )
} , [ ] )
} , [ currentSegmentIndex ] )
/** 切换播放/暂停 */
const togglePlayPause = useCallback ( ( ) = > {
@@ -338,9 +252,8 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if ( isEnded ) {
// 播放结束后再次播放,从头开始
setIsEnded ( false )
srcLoadedRef . current = false // ✅ 重置标记,强制重新加载
switchToSegment ( 0 , segments [ 0 ] ? . startTime ) . then ( ( ) = > {
const video = videoRef . current
const video = videoRefs . current [ 0 ]
if ( video ) {
video . play ( ) . catch ( ( e ) = > console . warn ( "[useSegmentScheduler] restart play failed:" , e ) )
setIsPlaying ( true )
@@ -357,8 +270,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
/** 跳转到指定全局时间 */
const seekTo = useCallback (
async ( time : number ) = > {
const video = videoRef . current
if ( ! video || ! canPlay ) return
if ( ! canPlay ) return
const clampedTime = Math . max ( 0 , Math . min ( time , totalDuration ) )
const { index , localTime } = findSegmentAtTime ( segments , clampedTime )
@@ -368,11 +280,11 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
if ( index !== currentSegmentIndex ) {
await switchToSegment ( index , localTime )
} else {
video . currentTime = localTime
const video = videoRefs . current [ index ]
if ( video ) video . currentTime = localTime
}
setCurrentTime ( clampedTime )
setCurrentSegmentIndex ( index )
setIsEnded ( false )
setTimeout ( ( ) = > {
@@ -382,14 +294,18 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
[ canPlay , totalDuration , segments , currentSegmentIndex , switchToSegment ] ,
)
// 确保 videoRefs 数组长度与 segments 一致
useEffect ( ( ) = > {
videoRefs . current = videoRefs . current . slice ( 0 , segments . length )
while ( videoRefs . current . length < segments . length ) {
videoRefs . current . push ( null )
}
} , [ segments ] )
// 组件卸载时清理
useEffect ( ( ) = > {
return ( ) = > {
cancelAnimationFrame ( rafRef . current )
if ( preloadVideoRef . current ) {
preloadVideoRef . current . src = ""
preloadVideoRef . current = null
}
}
} , [ ] )
@@ -400,13 +316,6 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
setCurrentTime ( 0 )
setCurrentSegmentIndex ( 0 )
setIsEnded ( false )
srcLoadedRef . current = false // ✅ 重置标记
const video = videoRef . current
if ( video ) {
video . pause ( )
video . removeAttribute ( "src" )
video . load ( )
}
} , [ segments ] )
return {
@@ -421,7 +330,7 @@ export function useSegmentScheduler(segments: PlaybackSegment[]): SegmentSchedul
pause ,
togglePlayPause ,
seekTo ,
videoRef ,
videoRefs ,
}
}