Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoxia 03b6e0e89a style: 修复 prettier 格式问题(useStep6Cover 长行换行)
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 / Check if frontend-only change (pull_request) Successful in 25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
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 / Frontend Unit Tests (pull_request) Successful in 1m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m10s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m6s
CI/CD Pipeline / PR Build Worker Image (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 / Validate - Type Check (mypy) (pull_request) Successful in 2m18s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m36s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m15s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m52s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m51s
AI Code Review / AI Code Review (pull_request) Successful in 6m59s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 8m18s
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 27s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web 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
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 34s
2026-08-19 20:25:32 +08:00
xiaoxia 58a7cc20c9 fix: 修复 AI Code Review 阻塞级问题(useCanvasPlayer 解码策略+资源泄漏+useStep6Cover 轮询超时)
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 22s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m9s
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m1s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m1s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m46s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m58s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m1s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m25s
AI Code Review / AI Code Review (pull_request) Successful in 4m44s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
- useCanvasPlayer: 初始化改为按需解码(前3片段+decodeAroundPosition动态加载)
- useCanvasPlayer: 新增 lastDrawnFrameRef 追踪,绘制新帧前关闭上一帧防止内存泄漏
- useCanvasPlayer: seek 时重置解码状态并重新解码当前区域
- useStep6Cover: 轮询增加 pollCount(max 60) + setTimeout 120s 双重超时保护
- useStep6Cover: 400错误精确匹配预览缺失模式,避免误触发自动修复

Closes: #1437, #1438, #1439
2026-08-19 20:16:37 +08:00
2 changed files with 99 additions and 10 deletions
@@ -219,6 +219,8 @@ export function useCanvasPlayer(
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)
@@ -625,6 +627,43 @@ 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
@@ -642,6 +681,11 @@ 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)
}
@@ -660,6 +704,9 @@ export function useCanvasPlayer(
}
return s
})
// 按需解码后续片段(每 200ms 检查一次,避免阻塞渲染)
decodeAroundPosition(currentTime)
}
if (currentTime >= totalDuration) {
@@ -668,7 +715,7 @@ export function useCanvasPlayer(
}
rafRef.current = requestAnimationFrame(renderFrame)
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect])
}, [canvasRef, totalDuration, titleSettings, drawTitle, computeDrawRect, decodeAroundPosition])
// ── 播放控制 ──
const play = useCallback(async () => {
@@ -687,15 +734,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
}
decodedSegmentsRef.current.clear()
// 立即解码 seek 位置附近的片段
await decodeAroundPosition(clampedTime)
},
[totalDuration],
[totalDuration, decodeAroundPosition],
)
const destroy = useCallback(() => {
@@ -706,10 +760,15 @@ 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()
}, [])
// ── 预加载下一个片段的数据 ──
@@ -759,10 +818,14 @@ export function useCanvasPlayer(
videoDimRef.current = { width: metas[0].videoWidth, height: metas[0].videoHeight }
}
// 4. 依次解码每个片段
for (const meta of metas) {
// 4. 按需解码:初始只解码前 3 个片段(当前 + 前后各 1),避免环形缓冲区溢出导致黑屏
decodedSegmentsRef.current.clear()
const initialEnd = Math.min(metas.length, 3)
for (let i = 0; i < initialEnd; i++) {
const meta = metas[i]
const buffer = segmentDataRef.current.get(meta.assetId)
if (!buffer) continue
decodedSegmentsRef.current.add(i)
await decodeSegment(buffer, meta)
if (isDestroyedRef.current) break
}
@@ -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 错误:仅当确认为"预览缺失"类错误时才执行自动修复
// 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(预览缺失),尝试自动创建预览渲染任务...")
message.info("正在准备预览视频,请稍候...")
try {
const previewResp = await createPreview({
@@ -124,9 +137,17 @@ 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") {
@@ -141,6 +162,11 @@ export function useStep6Cover({
reject(e)
}
}, 3000)
// 总超时保护:2 分钟后强制终止
setTimeout(() => {
clearInterval(poll)
reject(new Error("预览生成超时,请稍后重试"))
}, 120_000)
})
message.success("预览视频就绪,重新生成封面...")
// 重试封面生成