Files
xiaoxia-saas/tests/unit/test_ingest_hevc_transcode.py
xiaoxia 15bc55f0ff
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (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 / Validate - Migration (alembic) (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4m45s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 6m40s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 6m58s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
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 Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 40s
AI Code Review / AI Code Review (pull_request) Failing after 10m10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m25s
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 / 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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 42s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 0s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 2m9s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m10s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 4m35s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 13m26s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 15m35s
CI/CD Pipeline / Unit Tests (push) Failing after 22m35s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 22m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 22m59s
CI/CD Pipeline / CI Gate (push) Has been skipped
fix(ingest): 修复竖屏 HEVC 转码方向错误(双重旋转/ffprobe 取错行/逗号转义) (#1559)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-08-30 21:56:02 +08:00

561 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HEVC 自动转码逻辑单元测试 (ingest.py)
测试覆盖(全部调用生产代码真实函数):
- HEVC 编码检测(is_hevc_codec
- ffprobe 旋转探测(probe_rotation):单行 side_data 读取,不再取错 stream_tags 行
- 竖屏判定(is_portrait_rotation
- 转码滤镜构建(build_transcode_vf):逗号 \\, 转义、无 transpose(避免与
ffmpeg 内置 autorotate 双重旋转)、竖屏按宽/横屏按高的 1080p 只缩不放
- 转码产物方向/维度校验(validate_transcode_output
- ffmpeg 端到端(有 ffmpeg 时):竖屏 r90/r270 → 1080x1920 h264 无 side data
横屏 → 1920x1080;缩略图方向为竖版
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker"))
from worker_app.tasks.ingest import ( # noqa: E402
HEVC_CODECS,
build_transcode_vf,
is_hevc_codec,
is_portrait_rotation,
is_portrait_video,
probe_dimensions,
probe_rotation,
probe_video_info,
validate_transcode_output,
)
# ── 纯逻辑测试(不依赖 ffmpeg)──────────────────────────────────────────────
class TestHEVCCodecDetection:
def test_hevc_keywords_detected(self):
for codec in ("hevc", "h265", "hvh1", "HEVC", "H265", "Hevc", "HVH1"):
assert is_hevc_codec(codec), f"{codec} 应该被识别为 HEVC"
def test_non_hevc_codecs_not_detected(self):
for codec in ("h264", "avc1", "vp9", "av1", "mpeg4", "", None):
assert not is_hevc_codec(codec), f"{codec} 不应被识别为 HEVC"
def test_hevc_keywords_constant(self):
assert HEVC_CODECS == ("hevc", "h265", "hvh1")
class TestPortraitRotation:
def test_portrait_rotations(self):
for rotation in (90, 270, -90):
assert is_portrait_rotation(rotation), f"rotation={rotation} 应为竖屏"
def test_non_portrait_rotations(self):
for rotation in (0, 180, -180, None):
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:
"""统一转码滤镜:长边封顶 1920、只缩不放、方向无关。"""
def test_comma_escaped_with_backslash(self):
r"""scale 表达式内的逗号必须 \, 转义(否则报 Invalid size / No such filter)。"""
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()
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):
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:
def _probe_dimensions_called_with(self, monkeypatch, w, h, rotation=None):
monkeypatch.setattr("worker_app.tasks.ingest.probe_dimensions", lambda p: (w, h))
monkeypatch.setattr("worker_app.tasks.ingest.probe_rotation", lambda p: rotation)
def test_portrait_output_ok(self, monkeypatch):
self._probe_dimensions_called_with(monkeypatch, 1080, 1920, None)
assert validate_transcode_output("/tmp/fake.mp4", expected_portrait=True)
def test_landscape_output_ok(self, monkeypatch):
self._probe_dimensions_called_with(monkeypatch, 1920, 1080, None)
assert validate_transcode_output("/tmp/fake.mp4", expected_portrait=False)
def test_portrait_source_but_landscape_output_rejected(self, monkeypatch):
"""竖屏源转出横屏(双重旋转 bug 产物)必须判失败降级。"""
self._probe_dimensions_called_with(monkeypatch, 1920, 1080, None)
assert not validate_transcode_output("/tmp/fake.mp4", expected_portrait=True)
def test_landscape_source_but_portrait_output_rejected(self, monkeypatch):
self._probe_dimensions_called_with(monkeypatch, 1080, 1920, None)
assert not validate_transcode_output("/tmp/fake.mp4", expected_portrait=False)
def test_residual_rotation_side_data_rejected(self, monkeypatch):
"""产物仍带 rotation side data 会被播放器二次旋转,必须判失败。"""
self._probe_dimensions_called_with(monkeypatch, 1080, 1920, rotation=90)
assert not validate_transcode_output("/tmp/fake.mp4", expected_portrait=True)
def test_long_edge_beyond_1920_rejected(self, monkeypatch):
"""只缩不放:长边不得超过 1920。"""
self._probe_dimensions_called_with(monkeypatch, 3840, 2160, None)
assert not validate_transcode_output("/tmp/fake.mp4", expected_portrait=False)
def test_missing_dimensions_rejected(self, monkeypatch):
self._probe_dimensions_called_with(monkeypatch, None, None, None)
assert not validate_transcode_output("/tmp/fake.mp4", expected_portrait=False)
# ── ffmpeg 端到端测试(无 ffmpeg/libx265 环境自动跳过)──────────────────────
FFMPEG = shutil.which("ffmpeg")
FFPROBE = shutil.which("ffprobe")
def _has_x265() -> bool:
if not FFMPEG:
return False
out = subprocess.run([FFMPEG, "-hide_banner", "-encoders"], capture_output=True, text=True).stdout
return "libx265" in out
pytestmark = pytest.mark.skipif(
not (FFMPEG and FFPROBE and _has_x265()),
reason="ffmpeg/ffprobe/libx265 不可用,跳过端到端转码测试",
)
# 字体候选路径(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"
# 顶部红条标记画面方向;有字体时叠加 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,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-f",
"lavfi",
"-i",
"color=c=white:s=1920x1080:d=2:r=30",
"-vf",
draw,
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(base),
],
check=True,
)
base_hevc = tmp_path / "base_landscape_hevc.mp4"
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(base),
"-c:v",
"libx265",
"-tag:v",
"hvc1",
"-pix_fmt",
"yuv420p",
"-an",
str(base_hevc),
],
check=True,
)
def tag_rotate(src: Path, rotation: int) -> Path:
"""给横屏 HEVC 素材打上旋转 display matrix,模拟 iPhone 竖屏。
ffmpeg 版本差异:
- 5.1+(含 CI 的 7.x):mp4 muxer 支持 -display_rotation 输出选项,
老式 -metadata rotate 在 stream copy 时不再写入 display matrix
- 4.x:不识别 -display_rotation,仍用 -metadata:s:v:0 rotate=。
两条路径都试,造完用 probe_rotation 自检;都失败则返回 None,
消费方跳过依赖旋转信息的断言(skip)。
"""
out = tmp_path / f"portrait_r{rotation}_hevc.mp4"
attempts = [
# ffmpeg 5.1+: display_rotation 输出选项
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-c",
"copy",
"-display_rotation",
str(rotation),
str(out),
],
# ffmpeg 4.x: 老式 rotate metadata
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-c",
"copy",
"-metadata:s:v:0",
f"rotate={rotation}",
str(out),
],
]
for cmd in attempts:
proc = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
if proc.returncode == 0 and out.exists() and probe_rotation(str(out)) is not None:
return out
return None # 当前 ffmpeg 无法造出带 display matrix 的素材
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,
)
# 超宽屏 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,
}
def _transcode_like_production(src: Path, dst: Path) -> tuple[bool, int | None]:
"""按生产代码相同方式执行转码,返回 (is_portrait, rotation)。"""
# 与生产一致:合并探测 + 统一滤镜
width, height, rotation = probe_video_info(str(src))
is_portrait = is_portrait_video(width, height, rotation)
vf = build_transcode_vf()
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"18",
"-vf",
vf,
"-pix_fmt",
"yuv420p",
"-an",
"-movflags",
"+faststart",
str(dst),
],
check=True,
)
return is_portrait, rotation
class TestTranscodeEndToEnd:
def test_portrait_r90_hevc(self, hevc_sources):
"""竖屏 HEVC(rotation=90) 转码后:h264、1080x1920h>w)、无 rotation side data。"""
src = hevc_sources["portrait_r90"]
if src is None:
pytest.skip("当前 ffmpeg 无法生成带 rotation display matrix 的测试素材")
dst = hevc_sources["tmp_path"] / "out_r90.mp4"
is_portrait, rotation = _transcode_like_production(src, dst)
assert rotation == 90
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
codec = subprocess.run(
[
FFPROBE,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(dst),
],
capture_output=True,
text=True,
check=True,
).stdout.strip()
assert codec == "h264"
def test_portrait_r270_hevc(self, hevc_sources):
"""竖屏 HEVC(rotation=270→side_data 显示 -90) 同样转为 1080x1920。"""
src = hevc_sources["portrait_r270"]
if src is None:
pytest.skip("当前 ffmpeg 无法生成带 rotation display matrix 的测试素材")
dst = hevc_sources["tmp_path"] / "out_r270.mp4"
is_portrait, rotation = _transcode_like_production(src, dst)
assert rotation == -90 # ffprobe side_data 规范化为 -90
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_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"]
dst = hevc_sources["tmp_path"] / "out_land.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 (width, height) == (1920, 1080)
assert width > height
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"]
if src is None:
pytest.skip("当前 ffmpeg 无法生成带 rotation display matrix 的测试素材")
thumb = hevc_sources["tmp_path"] / "thumb.jpg"
subprocess.run(
[
FFMPEG,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-ss",
"0.1",
"-i",
str(src),
"-vframes",
"1",
"-vf",
"scale=640:-1:force_original_aspect_ratio=decrease,format=yuvj420p",
"-q:v",
"2",
str(thumb),
],
check=True,
)
width, height = probe_dimensions(str(thumb))
assert height > width, f"竖屏缩略图应为竖版,实际 {width}x{height}"
def test_probe_rotation_reads_side_data_not_tag_line(self, hevc_sources):
"""回归:旧实现同时请求 side_data+stream_tags 输出两行且取第一行,
r90 文件会取到 "270";新实现只读 side_datar90→90、r270→-90。"""
r90, r270 = hevc_sources["portrait_r90"], hevc_sources["portrait_r270"]
if r90 is None or r270 is None:
pytest.skip("当前 ffmpeg 无法生成带 rotation display matrix 的测试素材")
assert probe_rotation(str(r90)) == 90
assert probe_rotation(str(r270)) == -90
assert probe_rotation(str(hevc_sources["landscape"])) is None