fix(ingest): 方向判定改为按显示方向(物理竖屏 rotation=0 不再误判横屏)
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 2m30s
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 1m18s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m6s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m19s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 31s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m26s
AI Code Review / AI Code Review (pull_request) Failing after 7m44s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 8m21s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (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

Code Review 阻塞问题修复:

旧逻辑仅以 rotation side data 判定竖屏(is_portrait_rotation),对
"已物理旋转、存储即 h>w、rotation=0/None"的竖屏视频(Android 拍摄常见)
会误判为横屏:套用横屏按高缩放滤镜把 1080x1920 压成 608x1080,产物被
validate_transcode_output 拦截后降级使用原始 HEVC 文件——此类视频在
浏览器仍无法播放(HEVC 黑帧),转码链路对这类素材完全失效。

修复:
- 新增 is_portrait_video(stored_w, stored_h, rotation):rotation 为
  90/270/-90 时显示宽高相对存储维度互换,否则显示方向即存储维度,按
  显示 h>w 判定竖屏;维度探测失败时退回仅看 rotation。
- 主流程 Step 2 同时 probe 源文件维度与 rotation,按显示方向选滤镜。
- is_portrait_rotation 保留并补充注释(仅覆盖 iOS 风格 side data)。

测试补充:
- TestIsPortraitVideo:iOS 风格(1920x1080+rotation90)、物理竖屏
  (1080x1920 无 rotation)、横屏、rotation=180 不互换、探测失败回退。
- 端到端新增 test_physical_portrait_hevc_no_rotation:真实 ffmpeg 造
  1080x1920 无 side data 的 HEVC,断言转码后仍为 1080x1920 h264。
- 任务级新增 test_physical_portrait_no_rotation_still_transcodes:
  断言物理竖屏也走转码、改写 storage_key、使用按宽缩放滤镜。
- FONT 改为多路径候选查找,缺失时 drawtext 降级(仅红条标记),
  测试不再硬依赖 Debian 字体路径。

注:转码临时文件的 finally 清理逻辑在 #1449 中已存在(ingest.py
转码 try 块 finally 段 unlink _tc_tmp),本次审查该条为窗口外误判。
This commit is contained in:
xiaoxia
2026-08-30 20:41:33 +08:00
parent 5e3ee08d26
commit b03d19cf32
3 changed files with 162 additions and 18 deletions
+43 -10
View File
@@ -200,10 +200,37 @@ def probe_rotation(path: str) -> int | None:
def is_portrait_rotation(rotation: int | None) -> bool:
"""rotation side data 为 ±90/270 时表示竖屏拍摄。"""
"""rotation side data 为 ±90/270 时表示竖屏拍摄。
注意:这只覆盖"存储横屏 + display matrix 旋转"的 iOS 风格视频;
物理竖屏视频(Android 常见,存储即 h>w、rotation=None/0)不会命中,
方向判定请用 is_portrait_video()。
"""
return rotation in (90, 270, -90)
def is_portrait_video(
stored_width: int | None,
stored_height: int | None,
rotation: int | None,
) -> bool:
"""按显示方向判断是否竖屏(显示高度 > 显示宽度)。
- rotation 为 90/270/-90 时,显示方向的宽高相对存储维度互换;
- rotation 为 0/180/None 时,显示方向即存储维度。
这样两类竖屏都能正确识别:
- iOS:存储 1920x1080 + rotation=90 → 显示 1080x1920 竖屏
- Android/物理竖屏:存储 1080x1920、无 rotation → 显示 1080x1920 竖屏
探测失败(维度为 None)时退回仅看 rotation,保证调用链不中断。
"""
if not stored_width or not stored_height:
return is_portrait_rotation(rotation)
if is_portrait_rotation(rotation):
return stored_width > stored_height
return stored_height > stored_width
def build_transcode_vf(is_portrait: bool) -> str:
"""构建转码视频滤镜。
@@ -386,16 +413,22 @@ def ingest_asset(job_id: str) -> dict:
except Exception as _disk_err:
logger.warning("磁盘检查失败,仍尝试转码: job_id=%s err=%s", job_id, _disk_err)
# ── Step 2: ffprobe 旋转检测(失败按横屏处理,后续方向校验兜底)──
# ── Step 2: 方向检测(按显示方向判定竖/横屏)──────────────
# 不能只看 rotation side dataAndroid 等设备的物理竖屏视频
# 存储维度已是 h>w 且 rotation=0/None,只看 rotation 会误判横屏、
# 套用横屏滤镜把 1080x1920 压成 608x1080,转码产物校验失败降级,
# 用户拿到 HEVC 原文件浏览器仍黑帧。
_rotation = probe_rotation(str(local_file))
_is_portrait = is_portrait_rotation(_rotation)
if _rotation is not None:
logger.info(
"检测到视频 rotation=%s (portrait=%s): job_id=%s",
_rotation,
_is_portrait,
job_id,
)
_src_w, _src_h = probe_dimensions(str(local_file))
_is_portrait = is_portrait_video(_src_w, _src_h, _rotation)
logger.info(
"视频方向检测: stored=%sx%s rotation=%s portrait=%s: job_id=%s",
_src_w,
_src_h,
_rotation,
_is_portrait,
job_id,
)
# ── Step 3: ffmpeg 转码(独立 try/except)──
try:
+94 -7
View File
@@ -27,6 +27,7 @@ from worker_app.tasks.ingest import ( # noqa: E402
build_transcode_vf,
is_hevc_codec,
is_portrait_rotation,
is_portrait_video,
probe_dimensions,
probe_rotation,
validate_transcode_output,
@@ -58,6 +59,36 @@ class TestPortraitRotation:
assert not is_portrait_rotation(rotation), f"rotation={rotation} 不应判定为竖屏"
class TestIsPortraitVideo:
"""按显示方向判定竖屏(存储维度 + rotation 互换)。"""
def test_ios_style_stored_landscape_with_rotation90(self):
# iOS:存储 1920x1080 + rotation=90 → 显示 1080x1920 竖屏
assert is_portrait_video(1920, 1080, 90) is True
assert is_portrait_video(1920, 1080, -90) is True
assert is_portrait_video(1920, 1080, 270) is True
def test_physical_portrait_no_rotation(self):
# Android/物理竖屏:存储 1080x1920、无 rotation → 竖屏(旧逻辑误判横屏)
assert is_portrait_video(1080, 1920, None) is True
assert is_portrait_video(1080, 1920, 0) is True
def test_landscape_normal(self):
assert is_portrait_video(1920, 1080, None) is False
assert is_portrait_video(1920, 1080, 0) is False
def test_rotation180_no_swap(self):
# 180 度不互换宽高
assert is_portrait_video(1920, 1080, 180) is False
assert is_portrait_video(1080, 1920, 180) is True
def test_dimensions_unknown_falls_back_to_rotation(self):
# 探测失败(None)退回仅看 rotation,不抛异常
assert is_portrait_video(None, None, 90) is True
assert is_portrait_video(None, None, None) is False
assert is_portrait_video(0, 0, 270) is True
class TestBuildTranscodeVF:
def test_comma_escaped_with_backslash(self):
r"""scale 表达式内的逗号必须 \, 转义(否则报 Invalid size / No such filter)。"""
@@ -143,18 +174,25 @@ pytestmark = pytest.mark.skipif(
reason="ffmpeg/ffprobe/libx265 不可用,跳过端到端转码测试",
)
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# 字体候选路径(Debian/Ubuntu/Alpine/macOS),找不到则省略 drawtext
# 仅靠顶部红条表达方向,测试断言不依赖文字。
_FONT_CANDIDATES = (
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
)
FONT = next((f for f in _FONT_CANDIDATES if Path(f).exists()), None)
@pytest.fixture
def hevc_sources(tmp_path):
"""构造带方向标记的 HEVC 测试素材(横屏存储 + rotation side data,模拟 iPhone)。"""
base = tmp_path / "base_landscape_h264.mp4"
draw = (
"drawbox=x=0:y=0:w=1920:h=200:color=red:t=fill,"
f"drawtext=fontfile={FONT}:text='TOP':fontsize=160:fontcolor=black:"
"x=(w-tw)/2:y=30"
)
# 顶部红条标记画面方向;有字体时叠加 TOP 文字(仅人眼校验用,断言不依赖)
draw = "drawbox=x=0:y=0:w=1920:h=200:color=red:t=fill"
if FONT:
draw += f",drawtext=fontfile={FONT}:text='TOP':fontsize=160:fontcolor=black:" "x=(w-tw)/2:y=30"
subprocess.run(
[
FFMPEG,
@@ -250,9 +288,41 @@ def hevc_sources(tmp_path):
r90 = tag_rotate(base_hevc, 90)
r270 = tag_rotate(base_hevc, 270)
# 物理竖屏素材(Android 风格):直接生成 1080x1920 HEVC,无 rotation side data。
# 红条画在存储帧的顶部(短边 1080 一侧),方向断言只看维度。
physical = tmp_path / "portrait_physical_hevc.mp4"
phys_draw = "drawbox=x=0:y=0:w=1080:h=120:color=red:t=fill"
if FONT:
phys_draw += f",drawtext=fontfile={FONT}:text='TOP':fontsize=120:fontcolor=black:" "x=(w-tw)/2:y=20"
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=white:s=1080x1920:d=2:r=30",
"-vf",
phys_draw,
"-c:v",
"libx265",
"-tag:v",
"hvc1",
"-pix_fmt",
"yuv420p",
"-an",
str(physical),
],
check=True,
)
yield {
"portrait_r90": r90,
"portrait_r270": r270,
"portrait_physical": physical,
"landscape": base_hevc,
"tmp_path": tmp_path,
}
@@ -261,7 +331,8 @@ def hevc_sources(tmp_path):
def _transcode_like_production(src: Path, dst: Path) -> tuple[bool, int | None]:
"""按生产代码相同方式执行转码,返回 (is_portrait, rotation)。"""
rotation = probe_rotation(str(src))
is_portrait = is_portrait_rotation(rotation)
width, height = probe_dimensions(str(src))
is_portrait = is_portrait_video(width, height, rotation)
vf = build_transcode_vf(is_portrait)
subprocess.run(
[
@@ -344,6 +415,22 @@ class TestTranscodeEndToEnd:
assert probe_rotation(str(dst)) is None
assert validate_transcode_output(str(dst), True) is True
def test_physical_portrait_hevc_no_rotation(self, hevc_sources):
"""物理竖屏 HEVC(存储 1080x1920、无 rotation side dataAndroid 风格)
→ 必须识别为竖屏,转出 1080x1920 h264;旧逻辑只看 rotation 会误判横屏、
套横屏滤镜压成 608x1080 并被 validate 拦截降级,用户拿到不能播放的 HEVC。"""
src = hevc_sources["portrait_physical"]
dst = hevc_sources["tmp_path"] / "out_physical.mp4"
is_portrait, rotation = _transcode_like_production(src, dst)
assert rotation is None
assert is_portrait is True
width, height = probe_dimensions(str(dst))
assert (width, height) == (1080, 1920)
assert height > width
assert probe_rotation(str(dst)) is None
assert validate_transcode_output(str(dst), True) is True
def test_landscape_hevc(self, hevc_sources):
"""横屏 HEVC 转码后:h264、1920x1080w>h)、无 rotation side data。"""
src = hevc_sources["landscape"]
+25 -1
View File
@@ -110,6 +110,7 @@ def task_env(tmp_path):
"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,
}
@@ -122,7 +123,7 @@ def task_env(tmp_path):
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)
return control["source_dims"]
control["subprocess_calls"] = []
@@ -247,6 +248,29 @@ class TestIngestHEVCTranscodeFlow:
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()
# 竖屏滤镜按宽缩放(表达式引用 iw 判断),不应是横屏的按高缩放
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("gt(iw" in vf for vf in vfs), f"竖屏应使用按宽缩放滤镜: {vfs[0]}"
def test_non_hevc_no_transcode(self, task_env):
"""非 HEVC 编码(h264)→ 不触发 ffmpeg 转码。"""
task_env["codec"] = "h264"