fix(ingest): 转码滤镜改按长边 1920 封顶(修复超宽屏误降级)+ 合并 ffprobe
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 2m6s
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 1m46s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 4m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m55s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m18s
AI Code Review / AI Code Review (pull_request) Successful in 9m20s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 13m37s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 15m54s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 21m45s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 29m4s
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) Successful in 20m45s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1m7s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m14s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 4m46s

Code Review 阻塞问题修复 + 建议采纳:

1. 缩放规则与校验规则对齐(真 bug):
   旧滤镜仅按短边 1080 触发缩放(横屏看 ih、竖屏看 iw),对超宽屏
   (如 4000x1000)短边不超 1080 完全不缩放,产物长边 4000 超过
   validate_transcode_output 的 1920 上限,转码被判定失败而降级用
   原始 HEVC——浏览器仍无法播放。改为统一滤镜:
     scale=w=if(gte(iw\,ih)\,min(1920\,iw)\,-2):
           h=if(gt(ih\,iw)\,min(1920\,ih)\,-2),format=yuv420p
   横屏限宽、竖屏限高、短边 -2 自适应,min() 保证小视频不放大;
   build_transcode_vf 不再需要 is_portrait 参数(方向无关)。
   ffmpeg 实测:1920x1080→1920x1080、4K竖屏→1080x1920、
   4000x1000→1920x480、1080x2400→864x1920、640x360 不放大,均无 side data。

2. 合并 ffprobe(性能建议):新增 probe_video_info(path) 一次
   -show_streams -of json 同时解析 width/height 与 Display Matrix
   rotation(用全量 JSON 输出,规避 show_entries 嵌套 section 名在
   ffmpeg 4.x/7.x 不一致的问题);主流程两次探测合并为一次。
   probe_rotation/probe_dimensions 保留供测试与 validate 复用。

3. 临时文件清理:转码 try 块 finally 中 _tc_tmp.unlink() 在 #1449
   即已存在(develop 基线代码),本轮 review 该条为窗口外误判,
   已在 PR 评论说明。

测试:
- TestBuildTranscodeVF 重写为统一滤镜断言(逗号转义、无 transpose、
  横竖双分支 min(1920)、yuv420p 后缀)。
- 新增 TestProbeVideoInfo(真实 ffprobe 320x240 无 rotation;
  不存在文件返回三元 None)。
- 端到端新增 test_ultra_wide_long_edge_capped(4000x1000→1920x480
  且 validate 通过);_transcode_like_production 改用 probe_video_info
  + 无参 build_transcode_vf,与生产完全一致。
- 任务级竖屏用例滤镜特征断言更新为 min(1920)。
This commit is contained in:
xiaoxia
2026-08-30 21:09:43 +08:00
parent 0eba41332c
commit e68801eb59
3 changed files with 167 additions and 39 deletions
+69 -16
View File
@@ -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<h):宽超过 1080 则缩到 10804K 竖屏 → 1080x1920
- 横屏:高超过 1080 则缩到 10804K 横屏 → 1920x1080
输出自动剥离 rotation side data滤镜只做等比缩放,方向无关
横屏限宽、竖屏限高,短边 -2 自适应偶数,min() 保证小视频不放大。
旧实现显式 transpose=1 与 autorotate 叠加导致竖屏被二次旋转成横屏,
且竖屏沿用按高缩放的表达式,1080x1920 会被错误缩小为 608x1080。
旧实现的问题:
- 显式 transpose=1 与 autorotate 叠加,竖屏被二次旋转成横屏;
- 竖屏沿用按高缩放表达式,1080x1920 被错误缩成 608x1080
- 仅按短边 1080 触发缩放,超宽屏(如 4000x1000)长边超 1920 会被
validate 拦截误降级,用户拿到浏览器无法播放的 HEVC 原文件。
"""
return _PORTRAIT_VF if is_portrait else _LANDSCAPE_VF
return _TRANSCODE_VF
def probe_dimensions(path: str) -> 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.xshow_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",
+95 -21
View File
@@ -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):
"""不能显式 transposeffmpeg 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<h):宽超 1080 缩到 1080,高自适应 → 4K 竖屏 1080x1920"""
vf = build_transcode_vf(True)
assert vf.startswith("scale=if(gt(iw\\,1080)\\,1080\\,iw):-2")
def test_landscape_scales_by_height(self):
"""横屏:高超 1080 缩到 1080,宽自适应 → 4K 横屏 1920x1080。"""
vf = build_transcode_vf(False)
assert vf.startswith("scale=-2:if(gt(ih\\,1080)\\,1080\\,ih)")
def test_long_edge_capped_1920_orientation_agnostic(self):
"""横屏限宽、竖屏限高,均 min(1920,...),短边 -2 自适应"""
vf = build_transcode_vf()
assert "gte(iw" in vf and "gt(ih" in vf, "横/竖分支都要在"
assert vf.count("min(1920") == 2, "宽高分支都按长边 1920 封顶"
def test_format_yuv420p_suffix(self):
for vf in (build_transcode_vf(True), build_transcode_vf(False)):
assert vf.endswith(",format=yuv420p")
assert build_transcode_vf().endswith(",format=yuv420p")
class TestProbeVideoInfo:
"""probe_video_info 合并探测:维度 + rotation 一次 ffprobe。"""
def test_merges_dimensions_and_rotation(self, tmp_path):
"""真实 ffprober90 素材 → (1920, 1080, 90)r270 → rotation=-90;横屏 None。"""
# 端到端素材由 hevc_sources fixture 构造,这里用独立小素材验证合并函数
base = tmp_path / "v.mp4"
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=white:s=320x240:d=1:r=15",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(base),
],
check=True,
)
w, h, rot = probe_video_info(str(base))
assert (w, h) == (320, 240)
assert rot is None
def test_probe_failure_returns_triple_none(self):
w, h, rot = probe_video_info("/nonexistent/path/fake.mp4")
assert (w, h, rot) == (None, None, None)
class TestValidateTranscodeOutput:
@@ -319,10 +352,37 @@ def hevc_sources(tmp_path):
check=True,
)
# 超宽屏 HEVC4000x1000,无 rotation):旧滤镜短边 1000<1080 不缩放,
# 长边 4000 超 validate 的 1920 上限被误降级;新滤镜长边封顶应缩到 1920x480。
ultra_wide = tmp_path / "ultra_wide_hevc.mp4"
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=red:s=4000x1000:d=1:r=30",
"-c:v",
"libx265",
"-tag:v",
"hvc1",
"-pix_fmt",
"yuv420p",
"-an",
str(ultra_wide),
],
check=True,
)
yield {
"portrait_r90": r90,
"portrait_r270": r270,
"portrait_physical": physical,
"ultra_wide": ultra_wide,
"landscape": base_hevc,
"tmp_path": tmp_path,
}
@@ -330,10 +390,10 @@ 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))
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"]
@@ -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 转码。"""