Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 8b53c4a130 fix: 修复并发解码竞态——先标记再解码,失败时移除标记
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 31s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m55s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m10s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m43s
AI Code Review / AI Code Review (pull_request) Successful in 2m49s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m36s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 3m45s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m47s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 11m36s
CI/CD Pipeline / CI Gate (pull_request) Successful in 14s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 652h8m38s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 652h8m41s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 652h8m41s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 652h18m7s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 652h19m44s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 652h20m15s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 652h8m38s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 652h8m39s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 652h20m17s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 652h20m17s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 652h19m46s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 652h20m11s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 652h20m13s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 652h42m26s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 652h42m28s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 652h53m59s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 652h54m3s
decodeAroundPosition 和初始化循环中,将 decodedSegmentsRef.add() 移到
await decodeSegment() 之前(先标记防重入),catch 中 delete() 移除标记
(允许失败后重试)。解决渲染循环中解码未完成时下一帧重复发起解码的竞态。

Closes: #1437
2026-08-20 11:05:53 +08:00
xiaoxia 632c3043bc fix: 修复 AI Code Review 阻塞级问题 v2(解码策略+资源泄漏+轮询超时)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 39s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m34s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m53s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
AI Code Review / AI Code Review (pull_request) Successful in 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 2m23s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m53s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m45s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 7m39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 6s
CI/CD Pipeline / Production Browser E2E (pull_request) Failing after 663h57m27s
CI/CD Pipeline / Deploy Production (pull_request) Failing after 663h57m28s
CI/CD Pipeline / Build Production Worker Image (pull_request) Failing after 663h57m29s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 663h57m31s
CI/CD Pipeline / Build Production Web Image (pull_request) Failing after 663h57m29s
CI/CD Pipeline / PR Build Worker Image (pull_request) Failing after 664h4m27s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 664h4m30s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Failing after 664h5m4s
CI/CD Pipeline / Staging API Integration Tests (pull_request) Failing after 664h5m5s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Failing after 664h5m9s
CI/CD Pipeline / Staging E2E Tests (pull_request) Failing after 664h5m6s
CI/CD Pipeline / Build Staging Web Image (pull_request) Failing after 664h5m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Failing after 664h5m10s
CI/CD Pipeline / Canary Release to Production (pull_request) Failing after 664h31m12s
CI/CD Pipeline / Build Production API Image (pull_request) Failing after 664h31m15s
CI/CD Pipeline / PR Build API Image (pull_request) Failing after 664h38m14s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 664h38m52s
useCanvasPlayer:
- 初始化解码改为按需模式:仅解码前3片段 + decodeAroundPosition 动态加载 ±1 片段
- 新增 decodedSegmentsRef 追踪已解码片段,seek 时清空并重新解码
- 新增 lastDrawnFrameRef 追踪上一帧,绘制新帧前 close() 释放防止内存泄漏
- 解码标记移至 await decodeSegment() 之后,失败时不标记以便重试
- destroy 增加帧清理和状态重置

useStep6Cover:
- 轮询增加 pollCount(max 60) + setTimeout 120s 双重超时保护
- 400错误精确匹配预览缺失模式(errCode/message正则),避免误触发自动修复

Closes: #1437, #1438, #1439
2026-08-19 23:20:53 +08:00
7 changed files with 163 additions and 86 deletions
-2
View File
@@ -82,8 +82,6 @@ export interface CreateGenerationTaskRequest {
stroke?: boolean
shadow?: boolean
}
/** 来源剪辑计划 ID,后端据此从 edit_plan_clips 读取素材/音频/标题配置 */
source_edit_plan_id?: string
}
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
@@ -72,7 +72,6 @@ const GeneratePage: React.FC = () => {
autoSubtitles,
bgm,
editPlanId,
sourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
@@ -159,7 +158,6 @@ const GeneratePage: React.FC = () => {
} = useGenerateVideo({
titleSettings,
selectedTemplate,
sourceEditPlanId,
selectedMaterials,
materialMode,
smartSelectedIds,
@@ -6,8 +6,6 @@ import type { TitleSettings } from "../../types"
export interface UseGenerateVideoProps {
titleSettings: TitleSettings
selectedTemplate: string
/** 来源剪辑计划 ID,仅取 URL edit_plan_id,无则 null 让后端兜底 */
sourceEditPlanId?: string | null
selectedMaterials: string[]
materialMode: "manual" | "auto"
smartSelectedIds: string[]
@@ -10,30 +10,12 @@ import type { Movie, Sample } from "mp4box"
// ── MP4 Box 解析辅助函数 ──
// MP4 标准容器 box 列表(递归时会进入这些 box 内部搜索子 box)
const MP4_CONTAINER_TYPES = [
"moov",
"trak",
"mdia",
"minf",
"stbl",
"stsd",
"dinf",
"edts",
"udta",
"meta",
"tref",
]
const VISUAL_SAMPLE_ENTRY_TYPES = ["avc1", "avc3", "hvc1", "hev1"]
/**
* 递归搜索 box 树,找到 hvcC 或 avcC box 并返回其配置数据(不含 8 字节 box header)
*
* MP4 box 嵌套结构:moov → trak → mdia → minf → stbl → stsd → hev1 → hvcC
* - 普通容器 box 从 offset+8 开始递归
* - stsd 有额外 8 字节头(version/flags 4B + entry_count 4B),从 offset+16 开始
* - VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节是固定字段,子 box 从 offset+8+78 开始
* 普通容器 box 从 offset+8 开始递归;VisualSampleEntry (avc1/avc3/hvc1/hev1) 前 78 字节
* 是固定字段(ISO 14496-12),子 box 从 offset+8+78 开始
*/
function findCodecConfigRecursive(
buffer: ArrayBuffer,
@@ -60,18 +42,35 @@ function findCodecConfigRecursive(
return buffer.slice(offset + 8, offset + size)
}
// VisualSampleEntry:前 78 字节是固定字段,子 box 在 78 字节之后
if (VISUAL_SAMPLE_ENTRY_TYPES.includes(type)) {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
if (childResult) return childResult
}
// stsd:额外 8 字节头(version/flags 4B + entry_count 4B),子 box 在 offset+16
else if (type === "stsd") {
const childResult = findCodecConfigRecursive(buffer, offset + 8 + 8, offset + size)
if (childResult) return childResult
}
// 标准容器 box:从 offset+8 开始递归
else if (MP4_CONTAINER_TYPES.includes(type)) {
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",
)
const childResult1 = findCodecConfigRecursive(buffer, offset + 8 + 78, offset + size)
if (childResult1) return childResult1
console.log(
"[findCodecConfig] VisualSampleEntry:",
type,
"at",
offset,
"trying offset+8 (fallback)",
)
const childResult2 = findCodecConfigRecursive(buffer, offset + 8, offset + size)
if (childResult2) return childResult2
} else {
// 普通容器 box,从 offset+8 开始递归
const childResult = findCodecConfigRecursive(buffer, offset + 8, offset + size)
if (childResult) return childResult
}
@@ -218,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)
@@ -483,8 +486,6 @@ export function useCanvasPlayer(
})
decoderRef.current = decoder
decoderReady = true
// 标记缓冲结束,让 UI 开始渲染
setState((s) => ({ ...s, isBuffering: false }))
// 更新视频尺寸(用于 aspect ratio)
if (meta.videoWidth > 0 && meta.videoHeight > 0) {
@@ -499,24 +500,17 @@ export function useCanvasPlayer(
// 使用 demuxSegment 中已提取并过滤的 samples(前端切片)
const samplesCollected = meta.samples
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: ${samplesCollected.length} samples to decode`,
)
if (samplesCollected.length === 0) {
console.warn("[useCanvasPlayer] No samples to decode for segment", meta.assetId)
return
}
// 送入解码器
let decodedCount = 0
let skippedCount = 0
for (const sample of samplesCollected) {
if (!sample.data || isDestroyedRef.current) {
skippedCount++
continue
}
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,
@@ -526,24 +520,14 @@ export function useCanvasPlayer(
try {
decoder.decode(chunk)
decodedCount++
} catch (e) {
console.warn("[useCanvasPlayer] Decode chunk error:", e)
}
}
console.log(
`[useCanvasPlayer] Segment ${meta.assetId}: decoded ${decodedCount}, skipped ${skippedCount}, decoder.state=${decoder.state}`,
)
// flush 超时保护:10秒
// flush 确保所有帧输出
try {
await Promise.race([
decoder.flush(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("flush timeout 10s")), 10_000),
),
])
console.log(`[useCanvasPlayer] Segment ${meta.assetId}: flush complete`)
await decoder.flush()
} catch (e) {
console.warn("[useCanvasPlayer] Decoder flush error:", e)
}
@@ -551,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(
(
@@ -654,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) {
@@ -675,6 +713,8 @@ export function useCanvasPlayer(
}
return s
})
// 按需解码当前 ±1 片段
decodeAroundPosition(currentTime)
}
if (currentTime >= totalDuration) {
@@ -683,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 () => {
@@ -702,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(() => {
@@ -721,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()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -799,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) {
@@ -82,14 +82,6 @@ export interface GenerateFormState {
editPlanId: string | null
planConfigStr: string | null
/**
* 传给后端的 source_edit_plan_id。
* 仅使用 URL 中的 edit_plan_id(从剪辑模板编辑器跳转时携带)。
* URL 没有时传 null,后端正式生成 API 会通过 template_id+user_id 兜底查找正确的 plan。
* 注意:selectedTemplate 是模板 ID,不是 edit_plan_id,不能作为此值传递。
*/
sourceEditPlanId: string | null
/* 预览弹窗 */
previewVideo: GeneratedVideo | null
setPreviewVideo: (v: GeneratedVideo | null) => void
@@ -101,8 +93,6 @@ export const useGenerateFormState = (): GenerateFormState => {
const [searchParams] = useSearchParams()
const editPlanId = searchParams.get("edit_plan_id")
const planConfigStr = searchParams.get("plan_config")
/* ── source_edit_plan_id:仅取 URL 参数,无则 null 让后端兜底 ── */
const sourceEditPlanId = editPlanId || null
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
@@ -200,7 +190,6 @@ export const useGenerateFormState = (): GenerateFormState => {
bgm,
editPlanId,
planConfigStr,
sourceEditPlanId,
previewVideo,
setPreviewVideo,
previewModalOpen,
@@ -100,7 +100,6 @@ export function useGenerateVideo(props: UseGenerateVideoProps) {
custom_title: props.titleSettings?.title || "",
duration: props.duration || undefined,
video_ratio: props.videoRatio,
...(props.sourceEditPlanId ? { source_edit_plan_id: props.sourceEditPlanId } : {}),
...(props.titleSettings?.title
? {
title_config: {
@@ -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("预览视频就绪,重新生成封面...")
// 重试封面生成