346 lines
15 KiB
Python
346 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 conftest import load_program_module
|
|
|
|
_m = load_program_module()
|
|
AUDIO_PROFILES = _m.AUDIO_PROFILES
|
|
CONTAINER_PROFILES = _m.CONTAINER_PROFILES
|
|
RESOLUTION_PRESETS = _m.RESOLUTION_PRESETS
|
|
VIDEO_CODECS = _m.VIDEO_CODECS
|
|
EncoderWorker = _m.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(
|
|
"open_transcode.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(
|
|
"open_transcode.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("open_transcode.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
|
|
ew = load_program_module()
|
|
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
|