diff --git a/apps/worker/worker_app/tasks/ingest.py b/apps/worker/worker_app/tasks/ingest.py index e39334f0b..c9ac3f79e 100755 --- a/apps/worker/worker_app/tasks/ingest.py +++ b/apps/worker/worker_app/tasks/ingest.py @@ -1,3 +1,4 @@ +import json import shutil import subprocess import tempfile @@ -152,15 +153,20 @@ def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]: # ── HEVC 自动转码辅助函数(模块级,便于单元测试)───────────────────────── HEVC_CODECS = ("hevc", "h265", "hvh1") -# 转码目标:短边/长边封顶 1080p(只缩不放),产物长边不得超过 1920 -TRANSCODE_TARGET_EDGE = 1080 +# 转码目标:长边封顶 1920(只缩不放,与 validate 的 max_long_edge 一致), +# 竖屏/横屏/超宽屏统一按长边等比缩放,短边自动按比例(-2 保证偶数)。 TRANSCODE_MAX_LONG_EDGE = 1920 TRANSCODE_TIMEOUT_SECONDS = 900 # ffmpeg scale 滤镜中 if(...) 表达式内的逗号必须用 \, 转义, -# 否则逗号被当作 filter 分隔符解析,报 "No such filter: '1080)' / Invalid size"。 +# 否则逗号被当作 filter 分隔符解析,报 "No such filter" / Invalid size。 # subprocess list 传参不经 shell,\ 在 Python 字符串里直接写一个字面反斜杠即可。 -_PORTRAIT_VF = rf"scale=if(gt(iw\,{TRANSCODE_TARGET_EDGE})\,{TRANSCODE_TARGET_EDGE}\,iw):-2,format=yuv420p" -_LANDSCAPE_VF = rf"scale=-2:if(gt(ih\,{TRANSCODE_TARGET_EDGE})\,{TRANSCODE_TARGET_EDGE}\,ih),format=yuv420p" +# 横屏(iw>=ih)限宽 min(1920,iw)、高 -2 自适应;竖屏(ih>iw)限高、宽自适应; +# min() 保证小视频不放大。与 validate_transcode_output 的"长边 <= 1920"规则对齐, +# 超宽屏(如 4000x1000)短边不触发旧的短边缩放、长边超限被误降级的问题由此消除。 +_TRANSCODE_VF = ( + rf"scale=w=if(gte(iw\,ih)\,min({TRANSCODE_MAX_LONG_EDGE}\,iw)\,-2):" + rf"h=if(gt(ih\,iw)\,min({TRANSCODE_MAX_LONG_EDGE}\,ih)\,-2),format=yuv420p" +) def is_hevc_codec(codec: str | None) -> bool: @@ -235,18 +241,20 @@ def is_portrait_video( return stored_height > stored_width -def build_transcode_vf(is_portrait: bool) -> str: - """构建转码视频滤镜。 +def build_transcode_vf() -> str: + """构建转码视频滤镜(竖屏/横屏统一,按显示长边封顶 1920、只缩不放)。 依赖 ffmpeg 内置 autorotate(默认开启)按 display matrix 物理旋转画面, - 输出自动剥离 rotation side data;这里只做"只缩不放"的 1080p 等比缩放: - - 竖屏(旋转后 w tuple[int | None, int | None]: @@ -277,6 +285,52 @@ def probe_dimensions(path: str) -> tuple[int | None, int | None]: return None, None +def probe_video_info(path: str) -> tuple[int | None, int | None, int | None]: + """一次 ffprobe 同时读取视频宽高与旋转角度(display matrix side data)。 + + 返回 (width, height, rotation);探测失败对应位置为 None。 + 合并维度/角度两次探测,减少大文件、高并发下的 ffprobe 进程开销。 + rotation 仅取 stream side_data_list 的 Display Matrix(不读 tags.rotate, + 避免 iOS 文件 tag 值与 side data 双来源取错)。用 -show_streams 全量 JSON + 输出解析,兼容 ffmpeg 4.x/7.x(show_entries 嵌套 section 名跨版本不一致)。 + """ + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_streams", + "-of", + "json", + str(path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=60, + ) + data = json.loads(result.stdout or "{}") + streams = data.get("streams") or [] + if not streams: + return None, None, None + stream = streams[0] + width = int(stream["width"]) if stream.get("width") else None + height = int(stream["height"]) if stream.get("height") else None + rotation = None + for side in stream.get("side_data_list") or []: + if side.get("side_data_type") == "Display Matrix" and side.get("rotation") is not None: + deg = int(round(float(side["rotation"]))) % 360 + # ffprobe:顺时针 90 拍摄输出 90,逆时针 90 输出 -90(归一为 270) + rotation = {0: 0, 90: 90, 180: 180, 270: -90}.get(deg, deg if deg in (90, 180) else None) + break + return width, height, rotation + except (subprocess.TimeoutExpired, ValueError, OSError, json.JSONDecodeError, KeyError, TypeError): + return None, None, None + + def validate_transcode_output( output_path: str, expected_portrait: bool, @@ -422,8 +476,7 @@ def ingest_asset(job_id: str) -> dict: # 存储维度已是 h>w 且 rotation=0/None,只看 rotation 会误判横屏、 # 套用横屏滤镜把 1080x1920 压成 608x1080,转码产物校验失败降级, # 用户拿到 HEVC 原文件浏览器仍黑帧。 - _rotation = probe_rotation(str(local_file)) - _src_w, _src_h = probe_dimensions(str(local_file)) + _src_w, _src_h, _rotation = probe_video_info(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", @@ -444,7 +497,7 @@ def ingest_asset(job_id: str) -> dict: # 输出自动剥离 side data);滤镜只做 1080p 等比"只缩不放"。 # 注意不能再加 transpose:旧逻辑 autorotate + transpose 双重旋转, # 竖屏被转成横屏;scale 表达式内逗号必须 \, 转义(见 build_transcode_vf)。 - _vf = build_transcode_vf(_is_portrait) + _vf = build_transcode_vf() _cmd = [ "ffmpeg", diff --git a/tests/unit/test_ingest_hevc_transcode.py b/tests/unit/test_ingest_hevc_transcode.py index 07a8ddd57..11b975923 100644 --- a/tests/unit/test_ingest_hevc_transcode.py +++ b/tests/unit/test_ingest_hevc_transcode.py @@ -30,6 +30,7 @@ from worker_app.tasks.ingest import ( # noqa: E402 is_portrait_video, probe_dimensions, probe_rotation, + probe_video_info, validate_transcode_output, ) @@ -90,33 +91,65 @@ class TestIsPortraitVideo: class TestBuildTranscodeVF: + """统一转码滤镜:长边封顶 1920、只缩不放、方向无关。""" + def test_comma_escaped_with_backslash(self): r"""scale 表达式内的逗号必须 \, 转义(否则报 Invalid size / No such filter)。""" - for vf in (build_transcode_vf(True), build_transcode_vf(False)): - assert "\\," in vf - # 不应存在未转义的裸逗号(filter 分隔)出现在 if 表达式内 - assert "gt(ih,1080)" not in vf - assert "gt(iw,1080)" not in vf + vf = build_transcode_vf() + assert "\\," in vf + # 不应存在未转义的裸逗号(filter 分隔)出现在 if 表达式内 + assert "gte(iw,ih)" not in vf + assert "gt(ih,iw)" not in vf + assert "min(1920,iw)" not in vf def test_no_transpose_filter(self): """不能显式 transpose:ffmpeg autorotate 已按 side data 物理旋转, 再加 transpose 会双重旋转把竖屏转成横屏。""" - assert "transpose" not in build_transcode_vf(True) - assert "transpose" not in build_transcode_vf(False) + assert "transpose" not in build_transcode_vf() - def test_portrait_scales_by_width(self): - """竖屏(旋转后 w tuple[bool, int | None]: """按生产代码相同方式执行转码,返回 (is_portrait, rotation)。""" - rotation = probe_rotation(str(src)) - width, height = probe_dimensions(str(src)) + # 与生产一致:合并探测 + 统一滤镜 + width, height, rotation = probe_video_info(str(src)) is_portrait = is_portrait_video(width, height, rotation) - vf = build_transcode_vf(is_portrait) + vf = build_transcode_vf() subprocess.run( [ FFMPEG, @@ -445,6 +505,20 @@ class TestTranscodeEndToEnd: assert probe_rotation(str(dst)) is None assert validate_transcode_output(str(dst), False) is True + def test_ultra_wide_long_edge_capped(self, hevc_sources): + """超宽屏 4000x1000:长边必须封顶 1920(→1920x480),validate 通过。 + 回归旧滤镜只按短边触发缩放、长边超 1920 被误降级的 bug。""" + src = hevc_sources["ultra_wide"] + dst = hevc_sources["tmp_path"] / "out_ultra.mp4" + is_portrait, rotation = _transcode_like_production(src, dst) + assert rotation is None + assert is_portrait is False + + width, height = probe_dimensions(str(dst)) + assert max(width, height) <= 1920, f"长边应封顶 1920,实际 {width}x{height}" + assert width == 1920 and height == 480 + assert validate_transcode_output(str(dst), False) is True + def test_thumbnail_portrait_direction(self, hevc_sources): """缩略图(thumbnail_generator 同款 ffmpeg 抽帧,依赖 autorotate)竖屏源→竖版图。""" src = hevc_sources["portrait_r90"] diff --git a/tests/unit/test_ingest_hevc_transcode_task.py b/tests/unit/test_ingest_hevc_transcode_task.py index f6726f917..a02a86bc1 100644 --- a/tests/unit/test_ingest_hevc_transcode_task.py +++ b/tests/unit/test_ingest_hevc_transcode_task.py @@ -262,14 +262,15 @@ class TestIngestHEVCTranscodeFlow: 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 判断),不应是横屏的按高缩放 + # 统一滤镜按长边 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("gt(iw" in vf for vf in vfs), f"竖屏应使用按宽缩放滤镜: {vfs[0]}" + 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 转码。"""