Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 8b53c4a130 fix: 修复并发解码竞态——先标记再解码,失败时移除标记
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 / Build Staging Web 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 31s
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 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 / PR Build Worker Image (pull_request) Has been skipped
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 / 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 14s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 20s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 40s
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 / 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 39s
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 / PR Build Worker Image (pull_request) Has been skipped
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 / 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
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
4 changed files with 163 additions and 420 deletions
@@ -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) {
@@ -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("预览视频就绪,重新生成封面...")
// 重试封面生成
-97
View File
@@ -233,103 +233,6 @@ def ingest_asset(job_id: str) -> dict:
job_id,
thumb_err,
)
# ── HEVC 自动转码为 1080p H.264 ──────────────────────────────
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
# 失败时降级使用原始文件,不阻塞上传流程
if media_type == "video" and extract_success and local_file and local_file.exists():
codec = (metadata.get("codec") or "").lower()
if codec in ("hevc", "h265", "hvh1"):
logger.info(
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
codec,
job_id,
)
_tc_tmp = None
try:
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
_tc_tmp = Path(_tc_tmp_file.name)
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
_cmd = [
"ffmpeg",
"-y",
"-i",
str(local_file),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-vf",
"scale='if(gt(ih,1080),-2,iw)':'if(gt(ih,1080),1080,ih)'",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(_tc_tmp),
]
_proc = subprocess.run(
_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
timeout=300,
)
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
from video_processing.oss_helpers import upload_to_oss
_p = Path(job.storage_key)
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
_url = upload_to_oss(_tc_tmp, _new_key)
if _url:
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
_new_metadata, _new_extract_success = extract_media_metadata(
str(_tc_tmp),
media_type,
)
if _new_extract_success:
job.storage_key = _new_key
metadata = _new_metadata
extract_success = _new_extract_success
logger.info(
"HEVC→H.264 转码完成: job_id=%s key=%s",
job_id,
_new_key[:80],
)
else:
logger.warning(
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
job_id,
)
else:
_tail = _proc.stderr[-300:] if _proc.stderr else ""
logger.warning(
"FFmpeg 转码失败 rc=%s,降级原始文件: job_id=%s",
_proc.returncode,
job_id,
)
except subprocess.TimeoutExpired:
logger.warning(
"FFmpeg 转码超时 (300s),降级原始文件: job_id=%s",
job_id,
)
except Exception as _e:
logger.warning(
"HEVC 转码异常(降级原始文件): job_id=%s err=%s",
job_id,
_e,
)
finally:
if _tc_tmp and _tc_tmp.exists():
try:
_tc_tmp.unlink()
except OSError:
pass
finally:
if local_file and local_file.exists():
try:
-255
View File
@@ -1,255 +0,0 @@
"""HEVC 自动转码逻辑单元测试 (ingest.py)
测试覆盖:
- HEVC 编码检测逻辑
- 转码后文件命名规则
- 元数据提取失败时的脏数据防护
- FFmpeg 超时/错误降级策略
- 安全修复(tempfile、subprocess
- Scale filter 逻辑
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
class TestHEVCAutoTranscode:
"""测试 ingest_asset 中的 HEVC 自动转码逻辑"""
def test_hevc_detection_keywords(self):
"""验证 HEVC 编码的所有关键词"""
hevc_keywords = ("hevc", "h265", "hvh1")
assert "hevc" in hevc_keywords
assert "h265" in hevc_keywords
assert "hvh1" in hevc_keywords
assert "h264" not in hevc_keywords
assert "avc1" not in hevc_keywords
def test_h264_not_detected_as_hevc(self):
"""H.264 视频不应触发转码"""
codec = "h264"
hevc_keywords = ("hevc", "h265", "hvh1")
assert codec not in hevc_keywords, "H.264 不应触发转码"
def test_transcode_storage_key_naming(self):
"""验证转码后文件命名规则"""
original_key = "uploads/video_123/test.mp4"
p = Path(original_key)
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
assert new_key == "uploads/video_123/test_h264.mp4"
def test_transcode_storage_key_naming_complex_path(self):
"""验证复杂路径的命名规则"""
original_key = "uploads/2026/08/20/abc123/video_4k.mov"
p = Path(original_key)
new_key = str(p.parent / (p.stem + "_h264" + p.suffix))
assert new_key == "uploads/2026/08/20/abc123/video_4k_h264.mov"
def test_metadata_failure_no_dirty_data(self):
"""验证元数据提取失败时不更新 storage_key(避免脏数据)
这是 AI Code Review 发现的 BUG 修复:
- 旧逻辑:先更新 storage_key,再提取元数据 → 可能产生脏数据
- 新逻辑:先提取元数据,确认成功后再更新 storage_key
"""
original_storage_key = "uploads/test/video.mp4"
new_storage_key = "uploads/test/video_h264.mp4"
# 初始状态
job_storage_key = original_storage_key
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
# 模拟转码成功
transcode_success = True
# 模拟元数据提取失败
new_metadata = {}
new_extract_success = False
# 修复后的逻辑:先提取元数据,确认成功后再更新
if transcode_success:
if new_extract_success:
job_storage_key = new_storage_key
metadata = new_metadata
# 如果元数据提取失败,不更新 job_storage_key
# 验证:storage_key 保持原值,没有脏数据
assert job_storage_key == original_storage_key
assert metadata["codec"] == "hevc" # 保持原始元数据
def test_metadata_success_updates_storage_key(self):
"""验证元数据提取成功时正确更新 storage_key"""
original_storage_key = "uploads/test/video.mp4"
new_storage_key = "uploads/test/video_h264.mp4"
job_storage_key = original_storage_key
metadata = {"codec": "hevc", "width": 3840, "height": 2160}
# 模拟转码成功
transcode_success = True
# 模拟元数据提取成功
new_metadata = {"codec": "h264", "width": 1920, "height": 1080}
new_extract_success = True
# 修复后的逻辑
if transcode_success:
if new_extract_success:
job_storage_key = new_storage_key
metadata = new_metadata
# 验证:storage_key 和 metadata 都更新为新值
assert job_storage_key == new_storage_key
assert metadata["codec"] == "h264"
assert metadata["width"] == 1920
@patch("subprocess.run")
def test_ffmpeg_timeout_degradation(self, mock_subprocess):
"""验证 FFmpeg 超时降级使用原始文件"""
mock_subprocess.side_effect = subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
# 模拟降级逻辑
transcode_success = False
try:
raise subprocess.TimeoutExpired(cmd="ffmpeg", timeout=300)
except subprocess.TimeoutExpired:
transcode_success = False
assert not transcode_success, "超时应该导致转码失败"
@patch("subprocess.run")
def test_ffmpeg_error_degradation(self, mock_subprocess):
"""验证 FFmpeg 执行失败降级使用原始文件"""
mock_subprocess.return_value = MagicMock(
returncode=1,
stderr="Error: Invalid data found when processing input",
)
result = mock_subprocess.return_value
transcode_success = result.returncode == 0
assert not transcode_success, "FFmpeg 返回非零退出码应该导致转码失败"
def test_scale_filter_logic_4k_video(self):
"""验证 4K 视频会被缩放到 1080p"""
ih = 2160
should_scale = ih > 1080
assert should_scale, "4K 视频应该被缩放"
def test_scale_filter_logic_1080p_video(self):
"""验证 1080p 视频不会被缩放"""
ih = 1080
should_scale = ih > 1080
assert not should_scale, "1080p 视频不应该被缩放"
def test_scale_filter_logic_720p_video(self):
"""验证 720p 视频不会被缩放"""
ih = 720
should_scale = ih > 1080
assert not should_scale, "720p 视频不应该被缩放"
def test_tempfile_security_fix(self):
"""验证使用 NamedTemporaryFile 替代 mktemp(安全修复)
AI Code Review 发现的安全漏洞:
- tempfile.mktemp 存在 TOCTOU 竞态条件
- 应该使用 NamedTemporaryFile(delete=False)
"""
import tempfile
with patch("tempfile.NamedTemporaryFile") as mock_ntf:
mock_file = MagicMock()
mock_file.name = "/tmp/test_h264.mp4"
mock_ntf.return_value = mock_file
# 新代码的调用方式
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
_tc_tmp = Path(_tc_tmp_file.name)
_tc_tmp_file.close()
# 验证使用了 NamedTemporaryFile
mock_ntf.assert_called_once_with(delete=False, suffix="_h264.mp4")
def test_subprocess_output_handling(self):
"""验证 subprocess 输出处理(避免内存溢出)
AI Code Review 发现的稳定性风险:
- capture_output=True 会将所有输出加载到内存
- 应该使用 stdout=DEVNULL, stderr=PIPE
"""
import subprocess as sp
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
# 新代码的调用方式
sp.run(
["ffmpeg", "-i", "input.mp4", "output.mp4"],
stdout=sp.DEVNULL,
stderr=sp.PIPE,
text=True,
timeout=300,
)
# 验证使用了 stdout=DEVNULL, stderr=PIPE
call_kwargs = mock_run.call_args[1]
assert call_kwargs.get("stdout") == sp.DEVNULL
assert call_kwargs.get("stderr") == sp.PIPE
assert call_kwargs.get("timeout") == 300
def test_ffmpeg_command_parameters(self):
"""验证 FFmpeg 命令参数正确性"""
expected_params = [
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
]
# 验证所有关键参数都在命令中
cmd = ["ffmpeg", "-y", "-i", "input.mp4"]
cmd.extend(expected_params)
cmd.append("output.mp4")
assert "-c:v" in cmd
assert "libx264" in cmd
assert "-crf" in cmd
assert "18" in cmd
assert "-pix_fmt" in cmd
assert "yuv420p" in cmd
assert "-movflags" in cmd
assert "+faststart" in cmd
def test_hevc_codec_case_insensitive(self):
"""验证 HEVC 检测不区分大小写"""
test_cases = ["hevc", "HEVC", "Hevc", "h265", "H265", "hvh1", "HVH1"]
hevc_keywords = ("hevc", "h265", "hvh1")
for codec in test_cases:
assert codec.lower() in hevc_keywords, f"{codec} 应该被检测为 HEVC"
def test_non_hevc_codecs(self):
"""验证非 HEVC 编码不会触发转码"""
non_hevc_codecs = ["h264", "avc1", "vp9", "av1", "mpeg4", ""]
hevc_keywords = ("hevc", "h265", "hvh1")
for codec in non_hevc_codecs:
assert codec.lower() not in hevc_keywords, f"{codec} 不应触发转码"