Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b53c4a130 | |||
| 632c3043bc |
@@ -44,15 +44,29 @@ function findCodecConfigRecursive(
|
||||
|
||||
const visualSampleEntryTypes = ["avc1", "avc3", "hvc1", "hev1"]
|
||||
const isVisualSampleEntry = visualSampleEntryTypes.includes(type)
|
||||
|
||||
|
||||
if (isVisualSampleEntry) {
|
||||
// VisualSampleEntry: 前 78 字节是固定字段,子 box 在 78 字节之后
|
||||
// 先尝试 offset+8+78,如果没找到再尝试 offset+8(兼容不同 MP4 结构)
|
||||
console.log("[findCodecConfig] VisualSampleEntry:", type, "at", offset, "size", size, "trying offset+8+78")
|
||||
console.log(
|
||||
"[findCodecConfig] VisualSampleEntry:",
|
||||
type,
|
||||
"at",
|
||||
offset,
|
||||
"size",
|
||||
size,
|
||||
"trying offset+8+78",
|
||||
)
|
||||
const childResult1 = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
|
||||
if (childResult1) return childResult1
|
||||
|
||||
console.log("[findCodecConfig] VisualSampleEntry:", type, "at", offset, "trying offset+8 (fallback)")
|
||||
|
||||
console.log(
|
||||
"[findCodecConfig] VisualSampleEntry:",
|
||||
type,
|
||||
"at",
|
||||
offset,
|
||||
"trying offset+8 (fallback)",
|
||||
)
|
||||
const childResult2 = findCodecConfigRecursive(buffer, offset + 8, offset + size)
|
||||
if (childResult2) return childResult2
|
||||
} else {
|
||||
@@ -203,6 +217,10 @@ export function useCanvasPlayer(
|
||||
// ── 内部引用 ──
|
||||
const decoderRef = useRef<VideoDecoder | null>(null)
|
||||
const frameQueueRef = useRef(new FrameQueue(10))
|
||||
/** 已解码的片段索引集合,用于按需解码 */
|
||||
const decodedSegmentsRef = useRef(new Set<number>())
|
||||
/** 上一帧引用,用于在绘制新帧前释放上一帧防止内存泄漏 */
|
||||
const lastDrawnFrameRef = useRef<VideoFrame | null>(null)
|
||||
const rafRef = useRef<number>(0)
|
||||
const playStartRef = useRef<number>(0)
|
||||
const playStartOffsetRef = useRef<number>(0)
|
||||
@@ -517,6 +535,52 @@ export function useCanvasPlayer(
|
||||
[],
|
||||
)
|
||||
|
||||
/**
|
||||
* 按需解码当前播放位置 ±1 个片段。
|
||||
* 在渲染循环中定期调用,避免一次性解码所有片段导致环形缓冲区溢出丢帧。
|
||||
*/
|
||||
const decodeAroundPosition = useCallback(
|
||||
async (currentTime: number) => {
|
||||
const metas = segmentMetaRef.current
|
||||
if (!metas || metas.length === 0) return
|
||||
|
||||
// 找到当前时间对应的片段索引
|
||||
let targetIdx = -1
|
||||
let acc = 0
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const dur = metas[i].globalEndTime - metas[i].globalStartTime
|
||||
if (currentTime < acc + dur) {
|
||||
targetIdx = i
|
||||
break
|
||||
}
|
||||
acc += dur
|
||||
}
|
||||
if (targetIdx === -1) targetIdx = metas.length - 1
|
||||
|
||||
// 解码当前 ±1 片段
|
||||
for (
|
||||
let i = Math.max(0, targetIdx - 1);
|
||||
i <= Math.min(metas.length - 1, targetIdx + 1);
|
||||
i++
|
||||
) {
|
||||
if (decodedSegmentsRef.current.has(i)) continue
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
// 先标记为解码中,防止下一帧重复发起解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
try {
|
||||
await decodeSegment(buffer, meta)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 按需解码片段 ${i} 失败:`, e)
|
||||
}
|
||||
}
|
||||
},
|
||||
[decodeSegment],
|
||||
)
|
||||
|
||||
// ── 标题绘制 ──
|
||||
const drawTitle = useCallback(
|
||||
(
|
||||
@@ -620,11 +684,19 @@ export function useCanvasPlayer(
|
||||
|
||||
const frame = frameQueueRef.current.getCurrentFrame(currentTime)
|
||||
|
||||
// 释放上一帧,防止 VideoFrame 内存泄漏
|
||||
if (lastDrawnFrameRef.current) {
|
||||
lastDrawnFrameRef.current.close()
|
||||
lastDrawnFrameRef.current = null
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
if (frame) {
|
||||
const rect = computeDrawRect(canvas.width, canvas.height)
|
||||
ctx.drawImage(frame, rect.dx, rect.dy, rect.dw, rect.dh)
|
||||
// 保持引用,下一帧绘制时再释放
|
||||
lastDrawnFrameRef.current = frame
|
||||
}
|
||||
|
||||
if (titleSettings?.text) {
|
||||
@@ -641,6 +713,8 @@ export function useCanvasPlayer(
|
||||
}
|
||||
return s
|
||||
})
|
||||
// 按需解码当前 ±1 片段
|
||||
decodeAroundPosition(currentTime)
|
||||
}
|
||||
|
||||
if (currentTime >= totalDuration) {
|
||||
@@ -649,7 +723,7 @@ export function useCanvasPlayer(
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(renderFrame)
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
|
||||
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
|
||||
|
||||
// ── 播放控制 ──
|
||||
const play = useCallback(async () => {
|
||||
@@ -668,15 +742,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
|
||||
}
|
||||
// 清空已解码标记,重新解码 seek 目标区域
|
||||
decodedSegmentsRef.current.clear()
|
||||
await decodeAroundPosition(clampedTime)
|
||||
},
|
||||
[totalDuration],
|
||||
[totalDuration, decodeAroundPosition],
|
||||
)
|
||||
|
||||
const destroy = useCallback(() => {
|
||||
@@ -687,9 +768,16 @@ export function useCanvasPlayer(
|
||||
decoderRef.current.close()
|
||||
}
|
||||
|
||||
// 释放上一帧引用
|
||||
if (lastDrawnFrameRef.current) {
|
||||
lastDrawnFrameRef.current.close()
|
||||
lastDrawnFrameRef.current = null
|
||||
}
|
||||
|
||||
frameQueueRef.current.clear()
|
||||
segmentDataRef.current.clear()
|
||||
segmentMetaRef.current = []
|
||||
decodedSegmentsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// ── 预加载下一个片段的数据 ──
|
||||
@@ -765,12 +853,24 @@ export function useCanvasPlayer(
|
||||
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
|
||||
}
|
||||
|
||||
// 4. 依次解码每个片段
|
||||
for (const meta of metas) {
|
||||
// 4. 按需解码:初始只解码前 3 个片段,后续通过 decodeAroundPosition 动态加载
|
||||
// 避免一次性全量解码导致 frameQueue 环形缓冲区旧帧被丢弃引发黑屏
|
||||
decodedSegmentsRef.current.clear()
|
||||
const initialDecodeCount = Math.min(metas.length, 3)
|
||||
for (let i = 0; i < initialDecodeCount; i++) {
|
||||
if (cancelled) break
|
||||
const meta = metas[i]
|
||||
const buffer = segmentDataRef.current.get(meta.assetId)
|
||||
if (!buffer) continue
|
||||
await decodeSegment(buffer, meta)
|
||||
if (cancelled) break
|
||||
// 先标记为解码中,防止重复解码
|
||||
decodedSegmentsRef.current.add(i)
|
||||
try {
|
||||
await decodeSegment(buffer, meta)
|
||||
} catch (e) {
|
||||
// 解码失败则移除标记,允许后续重试
|
||||
decodedSegmentsRef.current.delete(i)
|
||||
console.warn(`[useCanvasPlayer] 初始化解码片段 ${i} 失败:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
|
||||
@@ -114,9 +114,22 @@ export function useStep6Cover({
|
||||
const anyErr = err as any
|
||||
const statusCode = anyErr?.response?.status
|
||||
|
||||
// 400 错误:后端缺少预览视频,自动创建后重试
|
||||
if (statusCode === 400) {
|
||||
console.log("[Step6] 后端返回 400,尝试自动创建预览渲染任务...")
|
||||
// 400 错误:精确判断是否为"预览缺失",避免误判其他 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") ||
|
||||
/预览.*(?:缺失|不存在|未找到)|(?:missing|not found|does not exist).*preview/i.test(
|
||||
errMsg,
|
||||
))
|
||||
|
||||
if (isPreviewMissing) {
|
||||
console.log("[Step6] 检测到预览缺失,尝试自动创建预览渲染任务...")
|
||||
message.info("正在准备预览视频,请稍候...")
|
||||
try {
|
||||
const previewResp = await createPreview({
|
||||
@@ -124,23 +137,39 @@ export function useStep6Cover({
|
||||
asset_ids: assetIds,
|
||||
duration: duration || 30,
|
||||
})
|
||||
// 轮询等待预览渲染完成
|
||||
// 轮询等待预览渲染完成,双重超时保护
|
||||
const maxPolls = 60 // 最多轮询 60 次(每 2 秒,共 120 秒)
|
||||
let pollCount = 0
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 总超时保护:120 秒后强制 reject
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(poll)
|
||||
reject(new Error("预览生成超时,请稍后重试"))
|
||||
}, 120_000)
|
||||
const poll = setInterval(async () => {
|
||||
pollCount++
|
||||
try {
|
||||
const status = await getPreviewStatus(previewResp.task_id)
|
||||
if (status.status === "completed") {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
resolve()
|
||||
} else if (status.status === "failed") {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(new Error(status.error_message || "预览渲染失败"))
|
||||
}
|
||||
if (pollCount >= maxPolls) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(new Error("预览生成超时,请稍后重试"))
|
||||
}
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
clearInterval(poll)
|
||||
reject(e)
|
||||
}
|
||||
}, 3000)
|
||||
}, 2000)
|
||||
})
|
||||
message.success("预览视频就绪,重新生成封面...")
|
||||
// 重试封面生成
|
||||
|
||||
Reference in New Issue
Block a user