OpenTranscode/tests/test_large_file_fixes.py

345 lines
15 KiB
Python

"""v4.6.0 large-file + av1an chunking fix tests.
Root causes these lock in:
1. The pure-ffmpeg path pre-scaled to a CRF-16 intermediate — a flat
1800s timeout and a 0.5-0.8x source size temp file killed large
files. Now the ffmpeg path scales inline and only the av1an path
pre-scales (with the per-file timeout and STOP support).
2. av1an's FRAME MISMATCH / "encoder crashed: exit status: 0" chunk
drift fell through to "Unknown av1an failure"; the ffmpeg ≥ 7
"-vsync" removal broke segment/hybrid chunking silently. Both are
now diagnosed and trigger the select retry.
3. Severe disk-space warnings were verbose-only, so quiet mode gave
zero notice before "No space left on device".
4. Loudnorm analysis decoded the whole VIDEO stream (-af only), pushing
large files past its 120s timeout; -vn makes it audio-only.
"""
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from conftest import capture_signal
from opentranscode.codec_profiles import (
AUDIO_PROFILES,
CONTAINER_PROFILES,
RESOLUTION_PRESETS,
VIDEO_CODECS,
)
from opentranscode.encoder_worker import EncoderWorker
def _worker(mock_env, use_ffmpeg_fallback, codec=None):
return EncoderWorker(
in_dir=Path("/tmp"),
out_dir=Path("/tmp"),
video_codec=codec or VIDEO_CODECS[0],
audio_profile=AUDIO_PROFILES[0],
container=CONTAINER_PROFILES[0],
crf=32,
preset_label="Medium (6)",
delete_source=False,
env=mock_env,
extensions={".mkv"},
resolution=RESOLUTION_PRESETS[0],
use_ffmpeg_fallback=use_ffmpeg_fallback,
)
# ─────────────────────────────────────────────────────────────────────────────
# _prepare_input: pre-scale only on the av1an path
# ─────────────────────────────────────────────────────────────────────────────
class TestPreScaleGating:
def test_ffmpeg_path_skips_prescale(self, mock_env, tmp_path):
"""With the ffmpeg path (incl. GPU mode), the source file is used
directly — no CRF-16 intermediate, no 30-minute pre-scale cap."""
w = _worker(mock_env, use_ffmpeg_fallback=True)
w._temp_dir = tmp_path
w._current_temps = []
w.resolution = RESOLUTION_PRESETS[2] # 1080p — scaling requested
w.verbose = False
src = tmp_path / "source.mp4"
src.write_bytes(b"\x00" * 1024)
mock_env.ffmpeg_path = "/usr/bin/ffmpeg"
result = w._prepare_input(
src, 1920, 1080, needs_scale=True,
scale_filter="scale=1920:1080",
)
assert result is not None
encode_input, output_f = result
assert encode_input == src # NO intermediate
# No pre-scale intermediate tracked (the .av1an work-dir path is
# still tracked by the symlink section — harmless, never created
# on this path).
assert not any(".scaled_tmp" in str(t) for t in w._current_temps)
def test_av1an_path_still_prescales(self, mock_env, tmp_path, monkeypatch):
"""The av1an path keeps the CRF-16 intermediate (VS plugins need
it) but now runs it via _run_with_stop_check with the per-file
timeout instead of a flat 1800s subprocess.run cap."""
w = _worker(mock_env, use_ffmpeg_fallback=False)
w._temp_dir = tmp_path
w._current_temps = []
w.resolution = RESOLUTION_PRESETS[2]
w.verbose = False
w.encode_timeout = 86400
logs: list[str] = []
w.log_msg = capture_signal(logs)
src = tmp_path / "source.mp4"
src.write_bytes(b"\x00" * 1024)
seen = {}
def fake_stop_check(cmd, env=None, timeout=None, log_prefix=" "):
seen["timeout"] = timeout
seen["cmd"] = cmd
# Fake a successful pre-scale: create the intermediate.
out_idx = cmd.index("-y")
Path(cmd[out_idx + 1]).write_bytes(b"\x00" * 4096)
return ("ok", 0, "", "")
w._run_with_stop_check = fake_stop_check
result = w._prepare_input(
src, 1920, 1080, needs_scale=True,
scale_filter="scale=1920:1080",
)
assert result is not None
encode_input, output_f = result
assert encode_input != src # intermediate produced
assert str(encode_input).endswith(".scaled_tmp.mkv")
assert seen["timeout"] == 86400 # per-file timeout, NOT 1800
assert any("libx265" in c for c in seen["cmd"]) # CRF-16 intermediate
def test_prescale_timeout_fails_cleanly(self, mock_env, tmp_path):
"""A pre-scale timeout now reports a dedicated message (the old
code surfaced a raw TimeoutExpired as 'pre-scale error')."""
w = _worker(mock_env, use_ffmpeg_fallback=False)
w._temp_dir = tmp_path
w._current_temps = []
w.resolution = RESOLUTION_PRESETS[2]
w.verbose = False
w.encode_timeout = 3600
w.fail_count = 0
logs: list[str] = []
w.log_msg = capture_signal(logs)
src = tmp_path / "source.mp4"
src.write_bytes(b"\x00" * 1024)
w._run_with_stop_check = MagicMock(return_value=("timeout", -1, "", ""))
result = w._prepare_input(
src, 1920, 1080, needs_scale=True,
scale_filter="scale=1920:1080",
)
assert result is None
assert w.fail_count == 1
assert any("pre-scale timeout" in l for l in logs)
def test_prescale_stop_aborts_without_fail(self, mock_env, tmp_path):
"""User STOP during pre-scale is not a transcode failure."""
w = _worker(mock_env, use_ffmpeg_fallback=False)
w._temp_dir = tmp_path
w._current_temps = []
w.resolution = RESOLUTION_PRESETS[2]
w.verbose = False
w.fail_count = 0
logs: list[str] = []
w.log_msg = capture_signal(logs)
src = tmp_path / "source.mp4"
src.write_bytes(b"\x00" * 1024)
w._run_with_stop_check = MagicMock(return_value=("stop", -1, "", ""))
result = w._prepare_input(
src, 1920, 1080, needs_scale=True,
scale_filter="scale=1920:1080",
)
assert result is None
assert w.fail_count == 0
# ─────────────────────────────────────────────────────────────────────────────
# Disk-space severe warnings are user-facing
# ─────────────────────────────────────────────────────────────────────────────
class TestDiskSpaceWarnings:
def _setup(self, mock_env, tmp_path, verbose):
w = _worker(mock_env, use_ffmpeg_fallback=True)
w._temp_dir = tmp_path
w.verbose = verbose
logs: list[str] = []
w.log_msg = capture_signal(logs)
src = tmp_path / "big.mkv"
src.write_bytes(b"\x00" * (2 * 1024 ** 3)) # 2 GB (sparse-ish; st_size counts)
return w, logs, src
def test_severe_output_warning_in_quiet_mode(self, mock_env, tmp_path, monkeypatch):
w, logs, src = self._setup(mock_env, tmp_path, verbose=False)
monkeypatch.setattr(
"opentranscode.encoder_worker.shutil.disk_usage",
lambda _p: MagicMock(free=512 * 1024 ** 2), # 0.5 GB free < 2 GB
)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=False)
assert any("low disk space on output" in l for l in logs), logs
def test_no_warning_when_plenty_free_quiet(self, mock_env, tmp_path, monkeypatch):
w, logs, src = self._setup(mock_env, tmp_path, verbose=False)
monkeypatch.setattr(
"opentranscode.encoder_worker.shutil.disk_usage",
lambda _p: MagicMock(free=100 * 1024 ** 3),
)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=False)
assert logs == []
def test_severe_temp_warning_in_quiet_mode_av1an_scale(
self, mock_env, tmp_path, monkeypatch,
):
w, logs, src = self._setup(mock_env, tmp_path, verbose=False)
w.use_ffmpeg_fallback = False # av1an path — intermediate on temp
calls = {"n": 0}
def fake_disk_usage(_p):
calls["n"] += 1
# First call: output partition (plenty); second: temp (low).
free = 100 * 1024 ** 3 if calls["n"] == 1 else 512 * 1024 ** 2
return MagicMock(free=free)
monkeypatch.setattr("opentranscode.encoder_worker.shutil.disk_usage",
fake_disk_usage)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=True)
assert any("low disk space on temp" in l for l in logs), logs
# ─────────────────────────────────────────────────────────────────────────────
# Loudnorm analysis: audio-only decode
# ─────────────────────────────────────────────────────────────────────────────
class TestLoudnormAudioOnly:
def test_analysis_cmd_has_vn(self, mock_env, tmp_path):
w = _worker(mock_env, use_ffmpeg_fallback=True)
w.audio_level_db = -16.0 # enable normalization path
mock_env.ffmpeg_path = "/usr/bin/ffmpeg"
seen = {}
def fake_run(cmd, **kw):
seen["cmd"] = cmd
res = MagicMock(returncode=1)
res.stdout = ""
res.stderr = "no json" # no JSON → returns None (fine)
return res
# _analyze_audio_loudness uses subprocess.run directly
import opentranscode.encoder_worker as ew
monkeypatch = pytest.MonkeyPatch()
try:
monkeypatch.setattr(ew.subprocess, "run", fake_run)
gain = w._analyze_audio_loudness(tmp_path / "video.mp4")
finally:
monkeypatch.undo()
assert gain is None # stderr had no JSON — graceful fallback
assert "-vn" in seen["cmd"], (
"loudnorm analysis must skip video decoding (-vn) or large "
"files blow the 120s timeout"
)
# ─────────────────────────────────────────────────────────────────────────────
# av1an chunking: new failure patterns trigger the select retry
# ─────────────────────────────────────────────────────────────────────────────
_FRAME_MISMATCH_STDERR = (
"ERROR av1an_core::broker: [chunk 2] encoder failed 3 times, shutting "
"down worker: encoder crashed: exit status: 0\n"
)
_FRAME_MISMATCH_STDOUT = (
" FRAME MISMATCH: chunk 2: 53/52 (actual/expected frames)\n"
)
_VSYNC_STDERR = (
"Error: FFmpeg failed to segment:\n"
' stderr: "Unrecognized option \'vsync\'.\\nError splitting the '
'argument list: Option not found\\n"\n'
)
class TestChunkFailurePatterns:
def _encode_one_with_mocks(self, mock_env, tmp_path, stdout, stderr):
w = _worker(mock_env, use_ffmpeg_fallback=False)
w._temp_dir = tmp_path
w._current_temps = []
w._file_res_map = {}
w._stop = False
w._consecutive_fail_count = 0
w._last_fail_pattern = None
w.verbose = True
w.audio_level_db = 0.0
w.encode_timeout = 60
w._current_idx = 1
w._current_total = 1
w._current_filename = "test.mkv"
w._current_scale_filter = ""
mock_env.av1an_flags["svt_name"] = "svt-av1"
# conftest's mock_env pre-sets the env_probe "select" override;
# remove it so the first attempt runs with auto (the retry path
# under test).
mock_env.av1an_flags.pop("chunk_method_override", None)
logs: list[str] = []
w.log_msg = capture_signal(logs)
calls: list[str | None] = []
def fake_stop_check(cmd, env=None, timeout=None, log_prefix=" "):
cm = None
if "--chunk-method" in cmd:
cm = cmd[cmd.index("--chunk-method") + 1]
calls.append(cm)
if cm in (None, "hybrid"):
return ("ok", 1, stdout, stderr)
# select retry: fake a valid output
out = Path(cmd[cmd.index("-o") + 1])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(b"\x00" * 8192)
return ("ok", 0, "", "encoding finished")
w._run_with_stop_check = fake_stop_check
w._ffmpeg_fallback_encode = MagicMock(return_value=True)
src = tmp_path / "input.mkv"
src.write_bytes(b"\x00" * 4096)
out = tmp_path / "out" / "input_archived.mkv"
result = w._encode_one(src, src, out, 1)
return w, logs, calls, result
def test_frame_mismatch_triggers_select_retry(self, mock_env, tmp_path):
"""FRAME MISMATCH (frames land in av1an's STDOUT) previously fell
to 'Unknown av1an failure'; now it retries with select."""
w, logs, calls, result = self._encode_one_with_mocks(
mock_env, tmp_path, _FRAME_MISMATCH_STDOUT, _FRAME_MISMATCH_STDERR,
)
assert result is True
assert calls == [None, "select"], calls
assert w.env.av1an_flags.get("chunk_method_override") == "select"
def test_vsync_removal_diagnosed_and_retried(self, mock_env, tmp_path):
"""ffmpeg ≥ 7 removed -vsync; av1an segment chunking dies with
'Unrecognized option' — must be diagnosed, not 'Unknown'."""
w, logs, calls, result = self._encode_one_with_mocks(
mock_env, tmp_path, "", _VSYNC_STDERR,
)
assert result is True
assert calls == [None, "select"]
assert any("vsync" in l for l in logs), logs
def test_frame_mismatch_not_misdiagnosed_as_concat(self, mock_env, tmp_path):
w, logs, calls, result = self._encode_one_with_mocks(
mock_env, tmp_path, _FRAME_MISMATCH_STDOUT, _FRAME_MISMATCH_STDERR,
)
assert not any("concat failure" in l for l in logs), logs