Compare commits

..

1 Commits

Author SHA1 Message Date
张宏杰 2f58674679 fix: MP4 fullbox偏移修正+标题全角/半角斜杠换行
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API 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 28s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
AI Code Review / AI Code Review (pull_request) Failing after 1m52s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m54s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m25s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m34s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m56s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m26s
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 34s
- findCodecConfig: 容器box内容偏移从+8改为+12(fullbox 12字节头)
- drawTitle: split支持半角/和全角/两种换行符
2026-08-19 14:41:10 +08:00
2 changed files with 91 additions and 300 deletions
@@ -36,35 +36,24 @@ function findCodecConfig(buffer: ArrayBuffer, start: number, end: number): Array
// 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 + 78 // VisualSampleEntry 固定字段共 78 字节(ISO 14496-12
const subBoxStart = entryOffset + 8 + 70 // 跳过 header(8) + visual sample entry fixed fields(70)
const result = findCodecConfig(buffer, subBoxStart, entryEnd)
if (result) return result
entryOffset += entrySize
}
} else if (type === "avcC" || type === "hvcC") {
// 找到目标 box,返回完整 box(含 header
// 返回完整 box(含 size + type header),WebCodecs HEVC decoder 需要
return buffer.slice(offset, offset + size)
// 找到目标 box,返回其内容(不含 box header
const dataStart = offset + 8
const dataSize = size - 8
return buffer.slice(dataStart, dataStart + dataSize)
}
if (size === 0) break
@@ -148,8 +137,6 @@ interface SegmentMeta {
videoHeight: number
/** 解码器配置数据(HEVC hvcC / H.264 avcC),WebCodecs 必需 */
description?: ArrayBuffer
/** 前端提取的样本数据(已按时间范围过滤,从关键帧开始) */
samples: Sample[]
}
// ── 播放器状态 ──
@@ -218,9 +205,6 @@ 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)
@@ -260,15 +244,7 @@ export function useCanvasPlayer(
view.getUint8(offset + 7),
)
if (type === "moov") {
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
return findCodecConfig(buffer, offset + 8, offset + size)
}
if (size === 0) break
offset += size
@@ -279,15 +255,11 @@ export function useCanvasPlayer(
return undefined
}, [])
// ── 解封装单个片段,提取轨道元数据 + 按时间范围过滤样本 ──
// ✅ 关键修复:改为异步函数,等待 MP4Box.js 的 onSamples 回调完成后再返回
// ── 解封装单个片段,提取轨道元数据 ──
const demuxSegment = useCallback(
async (buffer: ArrayBuffer, segIndex: number): Promise<SegmentMeta | null> => {
const segment = segments?.[segIndex]
if (!segment) {
console.warn("[useCanvasPlayer] No segment at index", segIndex)
return null
}
(buffer: ArrayBuffer, segIndex: number): SegmentMeta | null => {
const segment = segments[segIndex]
if (!segment) return null
// 计算全局偏移
let globalStart = 0
@@ -296,164 +268,59 @@ export function useCanvasPlayer(
}
const mp4File = createFile()
let meta: SegmentMeta | null = null
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()
mp4File.onReady = (info: Movie) => {
const videoTrack = info.videoTracks[0]
if (!videoTrack) {
console.error("[useCanvasPlayer] No video track found for segment", segIndex)
return
}
mp4File.onSamples = (_trackId: number, _user: unknown, samples: Sample[]) => {
if (resolved) return // ✅ 防止重复 resolve
// 提取编解码器配置数据(HEVC 必需,H.264 也需要)
const description = extractCodecDescription(buffer)
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)
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,
}
mp4File.onError = (_module: string, message: string) => {
console.error(`[useCanvasPlayer] MP4Box error: ${message}`)
clearTimeout(timeout)
resolved = true
resolve(null)
}
// 提取所有 samplesstart() 会同步触发 onSamples
mp4File.setExtractionOptions(videoTrack.id, null, {
nbSamples: videoTrack.nb_samples,
})
mp4File.start()
}
// 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)
})
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
},
[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/分辨率)
@@ -472,14 +339,6 @@ 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,
@@ -501,24 +360,45 @@ export function useCanvasPlayer(
if (!decoderReady) return
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta.samples
if (samplesCollected.length === 0) {
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
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
if (!sample.data) continue
const chunk = new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: ((sample.cts ?? 0) / (meta.timescale || 90000)) * 1_000_000,
duration: ((sample.duration ?? 0) / (meta.timescale || 90000)) * 1_000_000,
data: sample.data,
timestamp: (sample.cts / meta.timescale) * 1_000_000,
duration: (sample.duration / meta.timescale) * 1_000_000,
data: sample.data.buffer as ArrayBuffer,
})
try {
@@ -551,8 +431,7 @@ export function useCanvasPlayer(
ctx.textAlign = "center"
// 按 "/" 分割为多行("/" 作为手动换行符)
const lines = title.text.split(/[/⁄∕]/)
console.log("[drawTitle] 原始标题:", JSON.stringify(title.text), "分割后:", lines)
const lines = title.text.split(/[/]/)
const lineHeight = fontSize * 1.3
const totalHeight = lines.length * lineHeight
@@ -627,43 +506,6 @@ 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
@@ -681,11 +523,6 @@ 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)
}
@@ -704,9 +541,6 @@ export function useCanvasPlayer(
}
return s
})
// 按需解码后续片段(每 200ms 检查一次,避免阻塞渲染)
decodeAroundPosition(currentTime)
}
if (currentTime >= totalDuration) {
@@ -715,7 +549,7 @@ export function useCanvasPlayer(
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
// ── 播放控制 ──
const play = useCallback(async () => {
@@ -734,22 +568,15 @@ export function useCanvasPlayer(
}, [])
const seek = useCallback(
async (time: number) => {
(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, decodeAroundPosition],
[totalDuration],
)
const destroy = useCallback(() => {
@@ -760,15 +587,9 @@ 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()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -797,12 +618,12 @@ export function useCanvasPlayer(
if (isDestroyedRef.current) return
// 2. 解析每个片段的轨道元数据await 等待 onSamples 回调完成)
// 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 = await demuxSegment(buffer, i)
const meta = demuxSegment(buffer, i)
if (meta) metas.push(meta)
}
@@ -818,14 +639,10 @@ export function useCanvasPlayer(
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
}
// 4. 按需解码:初始只解码前 3 个片段(当前 + 前后各 1),避免环形缓冲区溢出导致黑屏
decodedSegmentsRef.current.clear()
const initialEnd = Math.min(metas.length, 3)
for (let i = 0; i < initialEnd; i++) {
const meta = metas[i]
// 4. 依次解码每个片段
for (const meta of metas) {
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
decodedSegmentsRef.current.add(i)
await decodeSegment(buffer, meta)
if (isDestroyedRef.current) break
}
@@ -114,22 +114,9 @@ export function useStep6Cover({
const anyErr = err as any
const statusCode = anyErr?.response?.status
// 400 错误:仅当确认为"预览缺失"类错误时才执行自动修复
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errCode = anyErr?.response?.data?.code as string | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errMsg = (anyErr?.response?.data?.message ||
anyErr?.response?.data?.detail ||
"") as string
const isPreviewMissing =
statusCode === 400 &&
(errCode?.includes("PREVIEW") ||
errCode?.includes("preview") ||
/预览.*(不存在|缺失|未找到|not found)/i.test(errMsg) ||
/preview.*(not found|missing|not exist)/i.test(errMsg))
if (isPreviewMissing) {
console.log("[Step6] 后端返回 400(预览缺失),尝试自动创建预览渲染任务...")
// 400 错误:后端缺少预览视频,自动创建后重试
if (statusCode === 400) {
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
const previewResp = await createPreview({
@@ -137,17 +124,9 @@ export function useStep6Cover({
asset_ids: assetIds,
duration: duration || 30,
})
// 轮询等待预览渲染完成(最大 60 次 / 总超时 2 分钟)
// 轮询等待预览渲染完成
await new Promise<void>((resolve, reject) => {
let pollCount = 0
const maxPolls = 60
const poll = setInterval(async () => {
pollCount++
if (pollCount > maxPolls) {
clearInterval(poll)
reject(new Error("预览生成超时,请稍后重试"))
return
}
try {
const status = await getPreviewStatus(previewResp.task_id)
if (status.status === "completed") {
@@ -162,11 +141,6 @@ export function useStep6Cover({
reject(e)
}
}, 3000)
// 总超时保护:2 分钟后强制终止
setTimeout(() => {
clearInterval(poll)
reject(new Error("预览生成超时,请稍后重试"))
}, 120_000)
})
message.success("预览视频就绪,重新生成封面...")
// 重试封面生成