From b03d19cf3278f73ab4420426477a1cef73f5257b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 30 Aug 2026 20:41:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(ingest):=20=E6=96=B9=E5=90=91=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E6=94=B9=E4=B8=BA=E6=8C=89=E6=98=BE=E7=A4=BA=E6=96=B9?= =?UTF-8?q?=E5=90=91=EF=BC=88=E7=89=A9=E7=90=86=E7=AB=96=E5=B1=8F=20rotati?= =?UTF-8?q?on=3D0=20=E4=B8=8D=E5=86=8D=E8=AF=AF=E5=88=A4=E6=A8=AA=E5=B1=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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),本次审查该条为窗口外误判。 --- apps/worker/worker_app/tasks/ingest.py | 53 +++++++-- tests/unit/test_ingest_hevc_transcode.py | 101 ++++++++++++++++-- tests/unit/test_ingest_hevc_transcode_task.py | 26 ++++- 3 files changed, 162 insertions(+), 18 deletions(-) diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index 48938ba15..65b2b8db7 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -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: diff --git a/tests/unit/test_ingest_hevc_transcode.py b/tests/unit/test_ingest_hevc_transcode.py index 3839081fe..07a8ddd57 100644 --- a/tests/unit/test_ingest_hevc_transcode.py +++ b/tests/unit/test_ingest_hevc_transcode.py @@ -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"] diff --git a/tests/unit/test_ingest_hevc_transcode_task.py b/tests/unit/test_ingest_hevc_transcode_task.py index cfb82b7e8..f6726f917 100644 --- a/tests/unit/test_ingest_hevc_transcode_task.py +++ b/tests/unit/test_ingest_hevc_transcode_task.py @@ -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"