Files
xiaoxia-saas/tests/unit/test_ingest_hevc_transcode_task.py
T
xiaoxia e68801eb59
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
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 / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (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 / Check if frontend-only change (pull_request) Successful in 2m6s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m18s
AI Code Review / AI Code Review (pull_request) Successful in 9m20s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 13m37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 15m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 21m45s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 29m4s
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 / Integration Tests (pull_request) Successful in 20m45s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m14s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m46s
fix(ingest): 转码滤镜改按长边 1920 封顶(修复超宽屏误降级)+ 合并 ffprobe
Code Review 阻塞问题修复 + 建议采纳:

1. 缩放规则与校验规则对齐(真 bug):
   旧滤镜仅按短边 1080 触发缩放(横屏看 ih、竖屏看 iw),对超宽屏
   (如 4000x1000)短边不超 1080 完全不缩放,产物长边 4000 超过
   validate_transcode_output 的 1920 上限,转码被判定失败而降级用
   原始 HEVC——浏览器仍无法播放。改为统一滤镜:
     scale=w=if(gte(iw\,ih)\,min(1920\,iw)\,-2):
           h=if(gt(ih\,iw)\,min(1920\,ih)\,-2),format=yuv420p
   横屏限宽、竖屏限高、短边 -2 自适应,min() 保证小视频不放大;
   build_transcode_vf 不再需要 is_portrait 参数(方向无关)。
   ffmpeg 实测:1920x1080→1920x1080、4K竖屏→1080x1920、
   4000x1000→1920x480、1080x2400→864x1920、640x360 不放大,均无 side data。

2. 合并 ffprobe(性能建议):新增 probe_video_info(path) 一次
   -show_streams -of json 同时解析 width/height 与 Display Matrix
   rotation(用全量 JSON 输出,规避 show_entries 嵌套 section 名在
   ffmpeg 4.x/7.x 不一致的问题);主流程两次探测合并为一次。
   probe_rotation/probe_dimensions 保留供测试与 validate 复用。

3. 临时文件清理:转码 try 块 finally 中 _tc_tmp.unlink() 在 #1449
   即已存在(develop 基线代码),本轮 review 该条为窗口外误判,
   已在 PR 评论说明。

测试:
- TestBuildTranscodeVF 重写为统一滤镜断言(逗号转义、无 transpose、
  横竖双分支 min(1920)、yuv420p 后缀)。
- 新增 TestProbeVideoInfo(真实 ffprobe 320x240 无 rotation;
  不存在文件返回三元 None)。
- 端到端新增 test_ultra_wide_long_edge_capped(4000x1000→1920x480
  且 validate 通过);_transcode_like_production 改用 probe_video_info
  + 无参 build_transcode_vf,与生产完全一致。
- 任务级竖屏用例滤镜特征断言更新为 min(1920)。
2026-08-30 21:09:43 +08:00

292 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ingest_asset 任务中 HEVC 转码主流程的任务级单元测试。
通过 mock subprocess / repository / OSS,验证:
- 转码成功 + 方向校验通过 → storage_key 改写为 *_h264
- 方向校验失败(竖屏转出横屏)→ 降级原文件,storage_key 不变,error 日志
- ffmpeg 非零退出 → 降级原文件
- 非 HEVC 编码 → 不触发转码
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
# 在 import worker_app 模块前 mock 掉数据库连接和 celery(同 test_ingest_validation.py
_mock_db_module = MagicMock()
_mock_db_module.SessionLocal = MagicMock()
sys.modules["worker_app.db"] = _mock_db_module
sys.modules["worker_app.core.config"] = MagicMock()
_mock_celery_module = MagicMock()
def _passthrough_decorator(*args, **kwargs):
if len(args) == 1 and callable(args[0]):
return args[0]
return lambda f: f
_mock_celery_module.celery_app.task = MagicMock(side_effect=_passthrough_decorator)
sys.modules["worker_app.celery_app"] = _mock_celery_module
# mock video_processing 子模块(主流程会 import 它们)
_oss_helpers_mock = MagicMock()
_thumbnail_mock = MagicMock()
sys.modules["video_processing.oss_helpers"] = _oss_helpers_mock
sys.modules["video_processing.thumbnail_generator"] = _thumbnail_mock
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
import pytest # noqa: E402
from worker_app.tasks import ingest as ingest_mod # noqa: E402
class _FakeJobRepo:
def __init__(self, db):
self.initial_job = SimpleNamespace(
id="job-1",
project_id="proj-1",
library_id="lib-1",
storage_key="uploads/proj/IMG_2281.MOV",
file_hash="hash-1",
status=None,
error_message=None,
result_asset_id=None,
updated_at=None,
)
self.updated_job = None
def get(self, job_id):
return self.initial_job
def update(self, job):
# 生产代码在同一 job 对象上原地修改属性后传入 update;
# 这里捕获引用,断言时读最终状态
self.updated_job = job
@property
def final_job(self):
return self.updated_job or self.initial_job
class _FakeAssetRepo:
def __init__(self, db):
self.created = None
def create(self, asset):
self.created = asset
def _video_metadata(codec="hevc"):
return {
"codec": codec,
"width": 1920,
"height": 1080,
"duration": 10.0,
"size_bytes": 5 * 1024 * 1024,
}
@pytest.fixture
def task_env(tmp_path):
"""统一构造 ingest_asset 主流程的 mock 环境。返回控制句柄。
测试中用 mocks = _start_patches(control) 启动,断言必须用
mocks["upload"] 等 start() 返回的 mock;不能在 stop() 后读模块
属性(stop 后属性恢复为原 auto-mock,调用记录为 0)。
"""
local_file = tmp_path / "local_hevc.MOV"
local_file.write_bytes(b"fake-hevc-source")
tc_out = tmp_path / "transcode_out_h264.mp4"
control = {
"rotation_source": 90, # 源文件 rotationNone=横屏无 side data
"transcode_rc": 0,
"transcode_produces_file": True,
"validate_ok": True,
"upload_url": "https://oss.example.com/x_h264.MOV",
"codec": "hevc",
"source_dims": (1920, 1080),
"tc_out": tc_out,
"local_file": local_file,
}
def fake_probe_rotation(path):
if Path(path).name == tc_out.name:
return None # 产物无 side data
return control["rotation_source"]
def fake_probe_dimensions(path):
if Path(path).name == tc_out.name:
return (1080, 1920) if control["validate_ok"] else (1920, 1080)
return control["source_dims"]
control["subprocess_calls"] = []
def fake_subprocess_run(cmd, **kwargs):
control["subprocess_calls"].append(list(cmd[:3]))
if cmd and cmd[0] == "ffmpeg" and "libx264" in cmd:
if control["transcode_rc"] != 0:
return SimpleNamespace(returncode=control["transcode_rc"], stderr="boom")
if control["transcode_produces_file"]:
Path(cmd[-1]).write_bytes(b"fake-h264-output")
return SimpleNamespace(returncode=0, stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
def fake_ntf(*args, **kwargs):
mock_file = MagicMock()
mock_file.name = str(tc_out) if kwargs.get("suffix") == "_h264.mp4" else str(local_file)
mock_file.close = MagicMock()
# with ... as tmp: 让 __enter__ 返回自身,tmp.name 才是上面设置的路径
mock_file.__enter__.return_value = mock_file
mock_file.__exit__.return_value = False
return mock_file
job_repo = _FakeJobRepo(db=None)
asset_repo = _FakeAssetRepo(db=None)
control["patchers"] = {
"session": patch.object(ingest_mod, "SessionLocal", return_value=MagicMock()),
"job_repo": patch.object(ingest_mod, "SQLAlchemyIngestJobRepository", return_value=job_repo),
"asset_repo": patch.object(ingest_mod, "SQLAlchemyAssetRepository", return_value=asset_repo),
"download": patch.object(ingest_mod, "download_asset", return_value=True),
"upload": patch.object(
sys.modules["video_processing.oss_helpers"],
"upload_to_oss",
return_value=control["upload_url"],
),
"metadata": patch.object(
ingest_mod,
"extract_media_metadata",
side_effect=lambda path, mt: (
(_video_metadata("h264"), True)
if Path(path).name == tc_out.name
else (_video_metadata(control["codec"]), True)
),
),
"rotation": patch.object(ingest_mod, "probe_rotation", side_effect=fake_probe_rotation),
"dimensions": patch.object(ingest_mod, "probe_dimensions", side_effect=fake_probe_dimensions),
"validate": patch.object(
ingest_mod,
"validate_transcode_output",
side_effect=lambda p, portrait: control["validate_ok"],
),
"subprocess": patch.object(ingest_mod.subprocess, "run", side_effect=fake_subprocess_run),
"ntf": patch.object(tempfile, "NamedTemporaryFile", side_effect=fake_ntf),
# 缩略图生成跳过
"thumb": patch(
"video_processing.thumbnail_generator.extract_first_frame",
side_effect=RuntimeError("skip thumb"),
),
}
control["job_repo"] = job_repo
control["asset_repo"] = asset_repo
return control
def _start_patches(control):
"""启动全部 patcher,返回具名 mock dict(调用记录都在这些 mock 上)。"""
return {name: p.start() for name, p in control["patchers"].items()}
def _stop_patches(control):
for p in control["patchers"].values():
p.stop()
class TestIngestHEVCTranscodeFlow:
def test_success_rewrites_storage_key(self, task_env):
"""竖屏 HEVC 转码+校验通过 → storage_key 改写为 *_h264.MOVasset READY 入库。"""
mocks = _start_patches(task_env)
try:
result = ingest_mod.ingest_asset("job-1")
finally:
_stop_patches(task_env)
assert result["status"] == "completed"
assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281_h264.MOV"
assert task_env["asset_repo"].created is not None
# 转码产物上传 OSS 恰好一次,且上传的是 *_h264.MOV 新 key
mocks["upload"].assert_called_once()
uploaded_path, uploaded_key = mocks["upload"].call_args.args
assert uploaded_key == "uploads/proj/IMG_2281_h264.MOV"
assert str(uploaded_path).endswith("_h264.mp4")
def test_validation_failure_keeps_original_file(self, task_env):
"""竖屏转出横屏(校验失败)→ 降级原文件,storage_key 不变,打 error 日志。"""
task_env["validate_ok"] = False
mocks = _start_patches(task_env)
error_mock = MagicMock()
try:
with patch.object(ingest_mod.logger, "error", error_mock):
result = ingest_mod.ingest_asset("job-1")
finally:
_stop_patches(task_env)
assert result["status"] == "completed"
assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV"
assert error_mock.called
assert "方向/维度校验失败" in error_mock.call_args[0][0]
# 校验失败:转码产物不得上传 OSS,杜绝横屏文件覆盖
mocks["upload"].assert_not_called()
def test_ffmpeg_nonzero_keeps_original(self, task_env):
"""ffmpeg 返回非零 → 降级原文件,storage_key 不变。"""
task_env["transcode_rc"] = 1
mocks = _start_patches(task_env)
try:
result = ingest_mod.ingest_asset("job-1")
finally:
_stop_patches(task_env)
assert result["status"] == "completed"
assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV"
mocks["upload"].assert_not_called()
def test_physical_portrait_no_rotation_still_transcodes(self, task_env):
"""物理竖屏(存储 1080x1920、rotation=NoneAndroid 风格)也必须判定竖屏
并转码改写 storage_key——回归旧逻辑只看 rotation 误判横屏的 bug。"""
task_env["source_dims"] = (1080, 1920)
task_env["rotation_source"] = None
mocks = _start_patches(task_env)
try:
result = ingest_mod.ingest_asset("job-1")
finally:
_stop_patches(task_env)
assert result["status"] == "completed"
assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281_h264.MOV"
mocks["upload"].assert_called_once()
# 统一滤镜按长边 1920 封顶(横/竖分支都在),不应再出现按短边 1080 的旧表达式
cmds = []
for call in mocks["subprocess"].call_args_list:
cmd = call.args[0] if call.args else call.kwargs.get("cmd", [])
cmds.append(cmd)
vfs = [str(c) for c in cmds if c and c[0] == "ffmpeg" and "libx264" in c]
assert vfs, "应执行 libx264 转码"
assert any("min(1920" in vf for vf in vfs), f"应使用长边1920封顶滤镜: {vfs[0]}"
assert all("gt(ih,1080)" not in vf for vf in vfs), "不应再用短边1080旧表达式"
def test_non_hevc_no_transcode(self, task_env):
"""非 HEVC 编码(h264)→ 不触发 ffmpeg 转码。"""
task_env["codec"] = "h264"
task_env["rotation_source"] = None
mocks = _start_patches(task_env)
try:
result = ingest_mod.ingest_asset("job-1")
finally:
_stop_patches(task_env)
assert result["status"] == "completed"
assert task_env["job_repo"].final_job.storage_key == "uploads/proj/IMG_2281.MOV"
mocks["upload"].assert_not_called()
# 所有 subprocess 调用都不应是 ffmpeg 转码
for call in mocks["subprocess"].call_args_list:
cmd = call.args[0] if call.args else call.kwargs.get("cmd", [])
assert "libx264" not in cmd