Compare commits

...

1 Commits

Author SHA1 Message Date
张宏杰 b1afe5608d feat: 前端自主视频切片,不再依赖后端切片
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 API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E 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 1m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m54s
AI Code Review / AI Code Review (pull_request) Failing after 1m55s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m15s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 25s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m39s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m28s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m15s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m53s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 4m35s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m57s
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 6s
- demuxSegment 中按 [startTime, endTime] 时间范围过滤 MP4 samples
- 确保从关键帧开始提取,跳过非关键帧
- samples 存入 SegmentMeta,decodeSegment 直接使用,不再二次解析
- 移除 decodeSegment 中多余的 mp4box 重解析逻辑
- Fallback:时间范围内无样本时使用全部样本从首个关键帧开始
2026-08-19 18:26:39 +08:00
@@ -136,6 +136,8 @@ interface SegmentMeta {
videoHeight: number
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
description?: ArrayBuffer
/** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */
samples: Sample[]
}
// ── 播放器状态 ──
@@ -255,7 +257,7 @@ export function useCanvasPlayer(
return undefined
}, [])
// ── 解封装单个片段,提取轨道元数据 ──
// ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ──
const demuxSegment = useCallback(
(buffer: ArrayBuffer, segIndex: number): SegmentMeta | null => {
const segment = segments[segIndex]
@@ -280,7 +282,7 @@ export function useCanvasPlayer(
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
let description = extractCodecDescription(buffer)
// 如果当前分片没有 description,尝试从缓存获取(后续分片可能不含 moov box
// 如果当前分片没有 description,尝试从缓存获取
if (!description) {
for (const cached of descriptionCache.current.values()) {
description = cached
@@ -288,7 +290,7 @@ export function useCanvasPlayer(
}
}
// 如果找到了 description,缓存起来供后续分片使用
// 缓存 description 供后续分片使用
if (description) {
descriptionCache.current.set(segment.assetId, description)
}
@@ -304,20 +306,62 @@ export function useCanvasPlayer(
videoWidth: videoTrack.track_width || 1280,
videoHeight: videoTrack.track_height || 720,
description,
samples: [],
}
// 提取所有 samplesstart() 会同步触发 onSamples
// 提取所有 samples
mp4File.setExtractionOptions(videoTrack.id, null, {
nbSamples: videoTrack.nb_samples,
})
mp4File.start()
}
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
if (!meta) return
// 前端切片:按 [startTime, endTime] 时间范围过滤样本
const timescale = meta.timescale
const startCts = segment.startTime * timescale
const endCts = segment.endTime * timescale
// 过滤出时间范围内的样本
let filtered = samples.filter((s) => s.cts >= startCts && s.cts < 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)`,
)
}
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
@@ -330,10 +374,9 @@ export function useCanvasPlayer(
// ── 初始化 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/分辨率)
@@ -381,47 +424,18 @@ 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))
// 送入解码器(跳过第一个关键帧之前的非关键帧)
let hasKeyFrame = false
// 送入解码器
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) continue
if (decoder.state === "closed") break
if (!hasKeyFrame && !sample.is_sync) {
console.warn("[useCanvasPlayer] Skipping non-keyframe chunk before first sync")
continue
}
if (sample.is_sync) hasKeyFrame = true
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: (sample.cts / meta.timescale) * 1_000_000,