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
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:
@@ -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 data:Android 等设备的物理竖屏视频
|
||||
# 存储维度已是 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:
|
||||
|
||||
@@ -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 data,Android 风格)
|
||||
→ 必须识别为竖屏,转出 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、1920x1080(w>h)、无 rotation side data。"""
|
||||
src = hevc_sources["landscape"]
|
||||
|
||||
@@ -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=None,Android 风格)也必须判定竖屏
|
||||
并转码改写 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"
|
||||
|
||||
Reference in New Issue
Block a user