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
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:
@@ -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)
|
||||
|
||||
// 提取所有 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()
|
||||
|
||||
Reference in New Issue
Block a user