OpenTranscode/tests/test_v6_behavior.py

370 lines
14 KiB
Python

"""
v6 behavior tests — per-file av1an→ffmpeg fallback.
Verifies that when av1an fails for a specific file (concat failure,
scene-detection panic, or other per-file issue), the encoder automatically
retries with the ffmpeg fallback path.
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
V3_PATH = Path(__file__).resolve().parent.parent / "open-transcode.v3.py"
def _load_v3_module():
if not V3_PATH.exists():
pytest.skip(f"v3 source not found at {V3_PATH}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode_v3", str(V3_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")):
return
import types
pyside6 = types.ModuleType("PySide6")
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 v3_module():
return _load_v3_module()
class TestCanFfmpegFallback:
"""v6-01: _can_ffmpeg_fallback checks if ffmpeg has the encoder."""
def test_returns_true_when_ffmpeg_has_encoder(self, v3_module):
"""When ffmpeg_libs has the encoder, _can_ffmpeg_fallback returns True."""
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
worker.video_codec = MagicMock()
worker.video_codec.ffmpeg_encoder = "libsvtav1"
worker.env = MagicMock()
worker.env.ffmpeg_libs = {"libsvtav1": True, "libx265": True}
assert worker._can_ffmpeg_fallback() is True
def test_returns_false_when_ffmpeg_lacks_encoder(self, v3_module):
"""When ffmpeg_libs does NOT have the encoder, returns False."""
worker = v3_module.EncoderWorker.__new__(v3_module.EncoderWorker)
worker.video_codec = MagicMock()
worker.video_codec.ffmpeg_encoder = "libsvtav1"
worker.env = MagicMock()
worker.env.ffmpeg_libs = {"libsvtav1": False, "libx265": True}
assert worker._can_ffmpeg_fallback() is False
class TestErrorPatternsIncludeSplitScores:
"""v6-02: 'split scores is not empty' panic is in the error_patterns table."""
def test_split_scores_pattern_in_source(self, v3_module):
"""The error_patterns table should include the 'split scores' pattern."""
src = Path(V3_PATH).read_text()
assert "split scores is not empty" in src, \
"error_patterns table should include 'split scores is not empty'"
def test_summary_detection_in_source(self, v3_module):
"""v6-03: SUMMARY + Average Speed detection for concat failures."""
src = Path(V3_PATH).read_text()
assert "SUMMARY" in src and "Average Speed" in src, \
"Should detect encoder SUMMARY block for concat failure diagnosis"
class TestPerFileFallbackRetry:
"""v6-01: When av1an fails, retry with ffmpeg fallback."""
def test_av1an_failure_triggers_ffmpeg_retry(self, v3_module, tmp_path):
"""When av1an fails (non-systematic) and ffmpeg has the encoder,
the code should retry with _ffmpeg_fallback_encode."""
# Create a real test video
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
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")
# Build a worker in av1an mode (not ffmpeg fallback)
av1_codec = next(c for c in v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
env = v3_module.probe_environment()
# Set fake av1an path + flags so the command builder doesn't crash
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
worker = v3_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, # av1an mode — will fail and retry
)
# Mock _run_with_stop_check to simulate av1an failure
# (return a SUMMARY block in stderr + non-zero exit code = concat failure)
def mock_run(cmd, **kw):
# Simulate av1an encoding all frames then failing at concat
fake_stderr = (
"Encoding: 24/24 Frames @ 3.52 fps\n"
"SUMMARY -----------------------------------------\n"
"Total Frames\t\tFrame Rate\t\tByte Count\n"
" 24\t\t23.98 fps\t\t 12345\n\n"
"Average Speed:\t\t3.598 fps\n"
"SvtMalloc[info]: you have no memory leak\n\n"
"source pipe stderr:\n\n"
"ffmpeg pipe stderr:\n\n"
)
return ("ok", 1, "", fake_stderr)
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode to simulate success
def mock_ffmpeg_fallback(file_path, encode_input, output_f):
# Create the output file so the verify step passes
output_f.parent.mkdir(parents=True, exist_ok=True)
output_f.write_bytes(b"\x00" * 1024) # 1KB fake output
return True
worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Set up _current_temps and _file_res_map (needed by _process_one_file)
worker._current_temps = []
worker._file_res_map = {}
worker._stop = False
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
# Call _encode_one directly
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have retried with ffmpeg and succeeded
assert result is True, f"Expected retry to succeed. Logs: {logs}"
# Should have logged the RETRY message
assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
f"Expected RETRY message in logs: {logs}"
# Should have logged RETRY OK
assert any("RETRY OK" in l for l in logs), \
f"Expected RETRY OK in logs: {logs}"
# fail_count should NOT be incremented (retry succeeded)
assert worker.fail_count == 0, \
f"fail_count should be 0 after successful retry, got {worker.fail_count}"
def test_av1an_failure_no_retry_when_ffmpeg_lacks_encoder(self, v3_module, tmp_path):
"""When av1an fails and ffmpeg does NOT have the encoder,
no retry should happen — just increment fail_count and return False."""
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) # fake video
av1_codec = next(c for c in v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
# Build env with libsvtav1=False — can't fallback
env = v3_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
env.ffmpeg_libs["libsvtav1"] = False
worker = v3_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,
)
# Mock av1an failure
def mock_run(cmd, **kw):
return ("ok", 1, "", "some av1an error")
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode — should NOT be called
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs = []
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 fail (no retry possible)
assert result is False
assert worker.fail_count == 1
# _ffmpeg_fallback_encode should NOT have been called
worker._ffmpeg_fallback_encode.assert_not_called()
# Should NOT have logged RETRY
assert not any("RETRY" in l for l in logs)
def test_systematic_failure_does_not_retry(self, v3_module, tmp_path):
"""When av1an fails with a systematic issue (VSScript API),
self._stop is set and no retry should happen."""
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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
env = v3_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
worker = v3_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,
)
# Mock av1an VSScript failure — this sets self._stop = True
def mock_run(cmd, **kw):
return ("ok", 1, "", "Failed to get VSScript API")
worker._run_with_stop_check = mock_run
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False # will be set by the pattern matcher
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should fail — systematic issue, no retry
assert result is False
assert worker._stop is True, "VSScript failure should set _stop"
# _ffmpeg_fallback_encode should NOT have been called (self._stop is True)
worker._ffmpeg_fallback_encode.assert_not_called()
# Should NOT have logged RETRY
assert not any("RETRY" in l for l in logs)