OpenTranscode/tests/test_chunk_method_retry.py

564 lines
24 KiB
Python

"""
v7 behavior tests — chunk-method retry on y4m pipe break.
Verifies the v4.0.0 fix for the "works up until near the end, never saves
chunks into a full file" production bug. When av1an's Hybrid chunk method
fails on a phone-recorded MP4 with sparse keyframes, every chunk's
encoder dies with:
[h264 @ 0x...] error while decoding MB 35 25
Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1
The fix retries the file with ``--chunk-method select`` (VapourSynth's
select() filter) before falling back to pure ffmpeg. This is faster than
the ffmpeg fallback (chunk-parallel still works) and produces identical-
quality output (same encoder, same params).
The production log that revealed this bug is at:
logs/av1an.log.2026-07-13
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
def _load_opentranscode_module():
if not OPENTRANSCODE_PATH.exists():
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _install_pyside6_stubs():
if any(name in sys.modules for name in
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
# Check if it's a real PySide6 or our stub. If real, leave it alone.
if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
return
# Real PySide6 — don't install stubs over it
try:
importlib.util.find_spec("PySide6.QtCore")
return
except (ImportError, ValueError):
pass
import types
pyside6 = types.ModuleType("PySide6")
pyside6._otc_stub = True
qt_widgets = types.ModuleType("PySide6.QtWidgets")
qt_core = types.ModuleType("PySide6.QtCore")
qt_gui = types.ModuleType("PySide6.QtGui")
class _QThread:
def __init__(self, *a, **kw): pass
def start(self): pass
def isRunning(self): return False
def wait(self, ms=None): pass
class _Signal:
def __init__(self, *a, **kw): pass
def connect(self, *a, **kw): pass
def emit(self, *a, **kw): pass
def _Slot(*a, **kw):
def deco(fn): return fn
return deco
qt_core.QThread = _QThread
qt_core.Signal = _Signal
qt_core.Slot = _Slot
qt_core.Qt = MagicMock()
qt_core.QPointF = MagicMock()
qt_core.QRectF = MagicMock()
qt_core.QTimer = MagicMock()
class _QWidget:
def __init__(self, *a, **kw): pass
class _QMainWindow(_QWidget): pass
qt_widgets.QWidget = _QWidget
qt_widgets.QMainWindow = _QMainWindow
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
"QMessageBox", "QStyleFactory"):
setattr(qt_widgets, name, MagicMock())
for n in ("QFont", "QPalette", "QColor", "QPainter", "QPen", "QBrush",
"QRadialGradient", "QFontMetrics"):
setattr(qt_gui, n, MagicMock())
pyside6.QtWidgets = qt_widgets
pyside6.QtCore = qt_core
pyside6.QtGui = qt_gui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtWidgets"] = qt_widgets
sys.modules["PySide6.QtCore"] = qt_core
sys.modules["PySide6.QtGui"] = qt_gui
@pytest.fixture(scope="module")
def opentranscode_module():
return _load_opentranscode_module()
# ─────────────────────────────────────────────────────────────────────────────
# Realistic stderr from a y4m-pipe-break failure (extracted from the
# production log at logs/av1an.log.2026-07-13). The key markers are:
# - "Failed to read y4m frame delimiter" (the broken y4m pipe)
# - "[h264 @ ...] error while decoding MB" (decoder failing mid-GOP)
# - "SUMMARY" + "Average Speed" (SVT-AV1's per-chunk summary block,
# printed because the encoder ran briefly on partial data before the
# pipe broke — this previously triggered the v6-03 "concat failure"
# misdiagnosis)
# ─────────────────────────────────────────────────────────────────────────────
_Y4M_BREAK_STDERR = (
"INFO encode_file: av1an_core::context: Input: 1920x1080 @ 29.763 fps, YUVJ420P, SDR\n"
"INFO encode_file: av1an_core::scenes: scenecut: found 8 scene(s) "
"[with extra_splits (298 frames): 16 scene(s)]\n"
"DEBUG encode_file: av1an_core::context: Segmenting video\n"
"DEBUG encode_file: av1an_core::context: Segment done\n"
"INFO encode_file: av1an_core::context: \n"
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
"WARN encode_chunk{worker_id=5 total_chunks=16 chunk_index=\"00011\"}: "
"av1an_core::broker: Encoder failed (on chunk 11):\n"
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
" SUMMARY -----------------------------------------------------------------\n"
" Average Speed:\t\t2.501 fps\n"
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
"ERROR av1an_core::broker: [chunk 4] [chunk 4] encoder failed 3 times, "
"shutting down worker: encoder crashed: exit status: 0\n"
)
class TestY4mBreakPatternInErrorTable:
"""v4.0.0: 'Failed to read y4m frame delimiter' is in the error_patterns table."""
def test_y4m_break_pattern_in_source(self, opentranscode_module):
"""The error_patterns table should include the y4m break pattern."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "Failed to read y4m frame delimiter" in src, \
"error_patterns table should include 'Failed to read y4m frame delimiter'"
def test_v7_01_retry_logic_in_source(self, opentranscode_module):
"""The v4.0.0 retry-with-select logic should be present in _encode_one."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert 'chunk_method="select"' in src, \
"_encode_one should have a retry path that passes chunk_method='select'"
assert 'chunk_method_override' in src, \
"_encode_one should cache the working chunk_method in chunk_method_override"
def test_v6_03_diagnostic_guarded_by_y4m_check(self, opentranscode_module):
"""v4.0.0: the v6-03 SUMMARY-block concat-failure diagnostic must NOT
fire when the y4m break marker is present (otherwise it misdiagnoses
chunk-extraction failures as concat failures)."""
src = Path(OPENTRANSCODE_PATH).read_text()
# Find the v6-03 SUMMARY block check and verify it's guarded by
# the y4m break exclusion.
assert '"Failed to read y4m frame delimiter" not in stderr_full' in src, \
"v6-03 SUMMARY block diagnostic must be guarded by y4m break exclusion"
def test_vs_plugin_probe_in_source(self, opentranscode_module):
"""v4.0.0: env_probe should include the VapourSynth source plugin probe."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "_probe_vs_source_plugins" in src, \
"env_probe should include _probe_vs_source_plugins helper"
assert "_VS_PLUGIN_PROBE_PATHS" in src, \
"env_probe should include the VS plugin path table"
class TestChunkMethodParameter:
"""v4.0.0: _encode_one accepts a chunk_method parameter for retries."""
def test_encode_one_accepts_chunk_method_kwarg(self, opentranscode_module, tmp_path):
"""_encode_one should accept chunk_method as a keyword argument."""
import inspect
sig = inspect.signature(opentranscode_module.EncoderWorker._encode_one)
params = list(sig.parameters.keys())
assert "chunk_method" in params, \
f"_encode_one should accept chunk_method parameter; got params: {params}"
# Default should be None (no override)
assert sig.parameters["chunk_method"].default is None, \
"chunk_method default should be None"
class TestY4mBreakRetry:
"""v4.0.0: When av1an fails with the y4m break pattern, retry with select."""
def test_y4m_break_triggers_select_retry(self, opentranscode_module, tmp_path):
"""When av1an fails with the y4m break pattern (and we're not already
using select), the code should retry with --chunk-method select."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
# Create a real test video
test_video = tmp_path / "input.mp4"
subprocess.run(
[ffmpeg_bin, "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=24",
"-c:v", "libx264", "-y", str(test_video)],
capture_output=True, timeout=15,
)
if not test_video.exists():
pytest.skip("Could not generate test video")
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Start with NO chunk_method_override — av1an auto-selects Hybrid
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
# NOTE: chunk_method_override intentionally absent — simulates
# the production scenario where env_probe didn't pre-set it
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
# Track which chunk_method each call used
call_chunk_methods: list[str | None] = []
def mock_run(cmd, **kw):
# Extract the chunk method from the command
chunk_m = None
if "--chunk-method" in cmd:
idx = cmd.index("--chunk-method")
chunk_m = cmd[idx + 1] if idx + 1 < len(cmd) else None
call_chunk_methods.append(chunk_m)
if chunk_m is None or chunk_m == "hybrid":
# First attempt (auto/Hybrid) — fail with y4m break
return ("ok", 1, "", _Y4M_BREAK_STDERR)
else:
# Retry with select — succeed by producing a fake output
# (the actual encode is mocked; we just create the file)
# Find the output path (last arg of -o)
if "-o" in cmd:
out_idx = cmd.index("-o")
out_path = Path(cmd[out_idx + 1])
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(b"\x00" * 8192) # 8KB fake AV1 output
return ("ok", 0, "", "encoding finished")
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode — should NOT be called (select retry succeeds first)
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Setup required attributes
worker._current_temps = []
worker._file_res_map = {}
worker._stop = False
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have succeeded via the select retry
assert result is True, f"Expected retry to succeed. Logs: {logs}"
# Should have been called twice: once with no chunk_method (auto),
# once with chunk_method="select"
assert len(call_chunk_methods) == 2, \
f"Expected 2 calls (Hybrid fail + select retry), got {len(call_chunk_methods)}: {call_chunk_methods}"
assert call_chunk_methods[0] is None, \
f"First call should have no chunk_method (auto/Hybrid), got: {call_chunk_methods[0]}"
assert call_chunk_methods[1] == "select", \
f"Second call should use --chunk-method select, got: {call_chunk_methods[1]}"
# Should have logged the RETRY message
assert any("RETRY" in l and "select" in l for l in logs), \
f"Expected RETRY message with 'select' in logs: {logs}"
# Should NOT have called ffmpeg fallback (select retry succeeded)
worker._ffmpeg_fallback_encode.assert_not_called()
# fail_count should NOT be incremented (retry succeeded)
assert worker.fail_count == 0, \
f"fail_count should be 0 after successful select retry, got {worker.fail_count}"
# v4.0.0: the working chunk_method should be cached for subsequent files
assert env.av1an_flags.get("chunk_method_override") == "select", \
f"chunk_method_override should be cached as 'select' for subsequent files"
def test_y4m_break_no_retry_when_already_select(self, opentranscode_module, tmp_path):
"""When av1an fails with y4m break AND we're already using select,
don't retry with select again (would infinite-loop). Fall through to
the v6-01 ffmpeg fallback instead."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Already forcing select — simulates the case where the user
# passed --chunk-method select but it still failed (rare, but
# possible if VapourSynth itself is broken)
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
"chunk_method_override": "select",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
call_count = [0]
def mock_run(cmd, **kw):
call_count[0] += 1
# Always fail with y4m break (even on select retry)
return ("ok", 1, "", _Y4M_BREAK_STDERR)
worker._run_with_stop_check = mock_run
# Mock ffmpeg fallback to succeed
def mock_ffmpeg_fallback(file_path, encode_input, output_f):
output_f.parent.mkdir(parents=True, exist_ok=True)
output_f.write_bytes(b"\x00" * 8192)
return True
worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have succeeded via ffmpeg fallback (not select retry)
assert result is True, f"Expected ffmpeg fallback to succeed. Logs: {logs}"
# Should have been called only ONCE (no select retry since already select)
assert call_count[0] == 1, \
f"Expected 1 av1an call (no retry since already select), got {call_count[0]}"
# Should NOT have logged the select RETRY message
assert not any("RETRY" in l and "select" in l for l in logs), \
f"Should not log select RETRY when already using select: {logs}"
# Should have logged the ffmpeg fallback RETRY
assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
f"Expected ffmpeg fallback RETRY in logs: {logs}"
def test_y4m_break_diagnosis_not_misdiagnosed_as_concat(self, opentranscode_module, tmp_path):
"""v4.0.0: the v6-03 'concat failure' diagnostic must NOT fire when the
y4m break marker is present. The y4m break pattern's own diagnosis
should be emitted instead."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Force select so the retry doesn't happen (we just want to test
# the diagnostic message)
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
"chunk_method_override": "select",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
def mock_run(cmd, **kw):
# Fail with y4m break (which includes SUMMARY + Average Speed)
return ("ok", 1, "", _Y4M_BREAK_STDERR)
worker._run_with_stop_check = mock_run
# Mock ffmpeg fallback to succeed
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False
output_f = tmp_path / "output" / "input_archived.mkv"
worker._encode_one(test_video, test_video, output_f, 1)
# Should have emitted the y4m break diagnosis
y4m_diagnosis = any(
"y4m" in l.lower() and "DIAGNOSIS" in l for l in logs
)
assert y4m_diagnosis, \
f"Should emit y4m break DIAGNOSIS. Logs: {logs}"
# Should NOT have emitted the v6-03 concat failure diagnosis.
# The v6-03 message starts with "SVT-AV1 encoder completed successfully
# (SUMMARY block found in stderr)" — check for that unique prefix
# to avoid matching the y4m break diagnosis which itself says "this
# is NOT a concat failure".
concat_diagnosis = any(
"SVT-AV1 encoder completed successfully" in l
or "post-encode merge step crashed" in l
for l in logs
)
assert not concat_diagnosis, \
f"Should NOT emit v6-03 concat failure diagnosis for y4m break. Logs: {logs}"
class TestVSPluginProbe:
"""v4.0.0: _probe_vs_source_plugins detects installed VS plugins."""
def test_probe_returns_empty_when_no_plugins(self, opentranscode_module, tmp_path, monkeypatch):
"""When no VS plugin .so files exist in any search dir, the probe
should return an empty list."""
# Point HOME at an empty tmp dir so the home-dir search paths
# don't accidentally find real plugins
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert result == [], \
f"Expected empty list when no plugins installed, got: {result}"
def test_probe_detects_lsmash(self, opentranscode_module, tmp_path, monkeypatch):
"""When libvslsmashsource.so is in a search dir, the probe should
return ['lsmash']."""
# Create a fake VS plugin dir with a fake lsmash .so.
# The probe searches ~/.local/lib/vapoursynth/ so we create it there.
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
vs_dir.mkdir(parents=True)
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert "lsmash" in result, \
f"Expected 'lsmash' in probe result, got: {result}"
def test_probe_detects_multiple_plugins(self, opentranscode_module, tmp_path, monkeypatch):
"""When multiple plugins are installed, the probe should find them all."""
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
vs_dir.mkdir(parents=True)
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
(vs_dir / "libffms2.so").write_bytes(b"\x00" * 16)
(vs_dir / "libbestsource.so").write_bytes(b"\x00" * 16)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert "lsmash" in result
assert "ffms2" in result
assert "bestsource" in result
assert len(result) == 3, f"Expected 3 plugins, got: {result}"
class TestCLIChunkMethodFlag:
"""v4.0.0: --chunk-method CLI flag is parsed and validated."""
def test_chunk_method_flag_accepted(self, opentranscode_module):
"""--chunk-method should be accepted by the CLI parser."""
parser = opentranscode_module.build_parser() if hasattr(opentranscode_module, "build_parser") else None
if parser is None:
# The v3 single file doesn't have build_parser; test the package's
# cli module instead
from opentranscode.cli import build_parser
parser = build_parser()
# Valid values
for method in ("auto", "select", "hybrid", "segment",
"ffms2", "lsmash", "bestsource", "dgdecnv"):
args = parser.parse_args(["--chunk-method", method])
assert args.chunk_method == method, \
f"--chunk-method {method} should parse to {method}"
def test_chunk_method_rejects_invalid_value(self, opentranscode_module):
"""Invalid --chunk-method values should be rejected by argparse."""
try:
from opentranscode.cli import build_parser
except ImportError:
pytest.skip("opentranscode.cli not importable (PySide6 missing)")
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--chunk-method", "invalid-method"])