Files
xiaoxia-saas/tests/unit/test_ingest_hevc_transcode_task.py
T
xiaoxia 18aed0c34b
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 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 / 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 3m1s
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 Web Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m35s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m50s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 7m51s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 8m43s
AI Code Review / AI Code Review (pull_request) Successful in 9m14s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 17m57s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 16m37s
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) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
fix(ingest): 修复竖屏 HEVC 转码方向错误(双重旋转/ffprobe 取错行/逗号转义)
竖屏 iPhone HEVC 素材转码后方向错乱(竖屏变横屏),实测定位三个根因:

1. 双重旋转:ffmpeg 默认 autorotate 已按 display matrix 物理旋转画面,
   代码又显式追加 transpose=1,导致 r90 竖屏被转成 1920x1080 横屏。
   旋转全部交给 autorotate,滤镜只做 scale。
2. ffprobe 取错行:探测命令同时输出 side_data=rotation 和
   stream_tags=rotate 两行(r90 文件输出 270/90 两行),split("\n")[0]
   取到 270,方向判定反了。改为只读 side_data 单行并 int 化。
3. scale 表达式逗号未转义:if(gt(ih,1080),...) 中的裸逗号被当作
   filter 分隔,报 Invalid size。统一用 \, 转义(raw string 字面量)。

附带修复:
- 竖屏按宽、横屏按高缩放(旋转后竖屏 w<h,旧代码竖屏沿用按高缩放
  会把 1080x1920 错缩为 608x1080),长边封顶 1080 只缩不放。
- 移除无效的 -metadata:s:v:0 rotate=0(实测清不掉 display matrix
  side data,autorotate 方案产物本身已无 side data)。
- 转码后强制方向/维度校验 validate_transcode_output:竖屏产物必须
  h>w、横屏 w>=h、长边 <=1920、无残留 rotation side data;校验失败
  打 error 日志并降级使用原始文件、不上传 OSS,杜绝横屏文件覆盖。

测试:
- test_ingest_hevc_transcode.py 重写(22 用例):纯逻辑断言 +
  真实 ffmpeg 端到端(r90/r270/横屏/4K 素材造 display matrix,
  断言 1080x1920 / 1920x1080 / h264 / 无 side data / 缩略图竖版)。
- test_ingest_hevc_transcode_task.py 新增(4 用例):任务级 mock
  验证成功改写 storage_key、校验失败降级+error 日志、ffmpeg 非零
  降级、非 HEVC 不转码。
2026-08-30 19:57:46 +08:00

267 lines
9.8 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",
"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 (1920, 1080)
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_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