OpenTranscode/opentranscode/env_probe.py

726 lines
30 KiB
Python

"""Environment probe — distro-aware binary + library + av1an detection.
Combines the distro probe (``detect_distro``) and the CPU topology
probe (``detect_cpu_topology``) with binary path search, ffmpeg
library availability probing, av1an version/flag probing, runtime
dependency probing, and the av1an VSScript smoke test.
The smoke-test helpers (``_av1an_env``, ``_av1an_vsscript_smoke_test``,
``_detect_av1an_svt_encoder``) live here rather than in
``ffprobe_utils`` because they exercise av1an (not ffprobe) and are
called from both the GUI (``ui_window``) and the CLI dry-run
(``cli.run_dry_run``).
Extracted from ``open-transcode.v3.py`` (QA item v4-03 — package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
import ctypes
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from .cpu_topology import CpuTopology, detect_cpu_topology
from .distro_probe import DistroProfile, detect_distro
# ──────────────────────────────────────────────
# ENVIRONMENT PROBE (distro-aware, extended)
# ──────────────────────────────────────────────
@dataclass
class EnvProbe:
distro: DistroProfile = field(default_factory=lambda: DistroProfile(
family="unknown", name="Unknown", version_id="?",
pkg_manager="unknown", install_cmd_template="",
binary_extra_paths=[], av1an_known_encoder_names=[],
ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes=""
))
av1an_path: str | None = None
ffmpeg_path: str | None = None
ffprobe_path: str | None = None
# v3 (OTC-011, PEP 868): parameterized dict/list type hints.
# av1an_flags values are sometimes str (flag name), sometimes bool
# (has_chunk_method), sometimes int — keep as dict[str, object] for honesty.
av1an_flags: dict[str, object] = field(default_factory=dict)
av1an_version: str | None = None
ffmpeg_version: str | None = None
ffmpeg_libs: dict[str, bool] = field(default_factory=dict) # lib name -> available
runtime_deps: dict[str, bool] = field(default_factory=dict) # dep name -> present
missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install
vs_version: str | None = None # VapourSynth version string (for diagnostics)
vs_script_lib: str | None = None # path to libvapoursynth-script.so that passed
cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown"))
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def ready(self) -> bool:
return (self.av1an_path is not None and self.ffmpeg_path is not None
and not self.errors and not self.missing_dep_pkgs)
@property
def dep_install_hint(self) -> str:
"""Generate a distro-specific install command for missing runtime deps."""
if not self.missing_dep_pkgs or self.distro.family == "unknown":
return ""
return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs))
@property
def install_hint(self) -> str:
"""Generate a distro-specific install command for missing packages."""
missing = []
if self.av1an_path is None:
missing.append(self.distro.av1an_pkg)
if self.ffmpeg_path is None:
missing.append(self.distro.ffmpeg_pkg)
if not missing:
return ""
return self.distro.install_cmd_template.format(packages=" ".join(missing))
def _find_binary(name: str, distro: DistroProfile) -> str | None:
"""
Search for a binary in: (1) standard PATH via shutil.which, then
(2) distro-specific extra paths (expanded ~). Returns first match.
"""
# Standard PATH search
found = shutil.which(name)
if found:
return found
# Distro-specific extra paths
for raw_path in distro.binary_extra_paths:
expanded = Path(raw_path).expanduser()
candidate = expanded / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]:
"""Check which encoder/decoder libraries ffmpeg was compiled with.
Runs ffmpeg -encoders ONCE and greps for all known encoder names.
Each entry: (key, [search_strings]) — any match = available.
v4 STABILITY FIX: the v3 search strings for libsvtav1 and libaom were
wrong. ffmpeg's `-encoders` output lists them as `libsvtav1` and
`libaom-av1` (no underscore between svt/av1, hyphen between aom/av1) —
NOT `libsvt_av1` / `libaom_av1`. This caused _probe_ffmpeg_libs to
report False for both even when they were installed, which then caused
_handle_vs_incompat to incorrectly tell the user "ffmpeg also lacks
libsvtav1" and abort — even though ffmpeg actually had it. The e2e
test test_probe_detects_ffmpeg_libs caught this.
"""
try:
res = subprocess.run(
[ffmpeg_bin, "-encoders"],
capture_output=True, text=True, timeout=10,
)
output = res.stdout
except (OSError, subprocess.SubprocessError):
output = ""
# v4: search strings match the EXACT names ffmpeg -encoders prints.
# Verified against ffmpeg 7.x output:
# V..... libsvtav1 SVT-AV1(...) encoder (codec av1)
# V....D libaom-av1 libaom AV1 (codec av1)
# V....D libvpx-vp9 libvpx VP9 (codec vp9)
# The trailing space in each search string anchors the match to the
# encoder name boundary, preventing false positives like "libvpx_vp9"
# matching "libvpx_vp9_decoder" (which doesn't exist, but defensive).
# We also include the underscore variant as a fallback for older
# ffmpeg builds that may have used that spelling.
checks = [
("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]),
("libaom", ["libaom-av1 ", "libaom_av1", "aom_av1 "]),
("libvpx", ["libvpx-vp9 ", "libvpx_vp9", "vpx_vp9 "]),
("libx265", ["libx265 "]),
("libopus", ["libopus "]),
("libvorbis", ["libvorbis "]),
("flac", ["flac "]),
]
libs = {}
for lib_name, search_strings in checks:
libs[lib_name] = any(s in output for s in search_strings)
return libs
def _probe_av1an_version(av1an_bin: str) -> str | None:
"""Extract av1an version string."""
try:
# Try --version first, fall back to parsing --help header
for args in (["--version"], ["--help"]):
res = subprocess.run(
[av1an_bin] + args,
capture_output=True, text=True, timeout=10,
)
output = res.stdout or res.stderr
match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE)
if match:
return match.group(1)
if res.stdout.strip(): # If --version produced output but no version match
return res.stdout.strip().splitlines()[0][:60]
except (OSError, subprocess.SubprocessError):
pass
return None
def _probe_ffmpeg_version(ffmpeg_bin: str) -> str | None:
"""Extract ffmpeg version string."""
try:
res = subprocess.run(
[ffmpeg_bin, "-version"],
capture_output=True, text=True, timeout=10,
)
first_line = res.stdout.splitlines()[0] if res.stdout else ""
match = re.search(r"ffmpeg version (\S+)", first_line)
return match.group(1) if match else first_line[:60]
except (OSError, subprocess.SubprocessError):
return None
def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]:
"""Check runtime dependencies that av1an needs to function.
Returns (deps_dict, missing_pkg_names).
Checks:
- VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen
to get the VSScript API — without this it panics with
'Failed to get VSScript API')
- Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc)
"""
deps: dict[str, bool] = {}
missing_pkgs: list[str] = []
# --- VapourSynth + VSScript (critical: av1an will panic without it) ---
# av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls
# vsscript_init() / vsscript_createScript() / etc. It does NOT use the
# Python vapoursynth module. The shared library and the VSScript API
# library can be packaged separately on some distros (e.g. Debian has
# libvapoursynth-script-dev). We must check what av1an actually loads.
#
# IMPORTANT: We do NOT call vsscript_init() in our probe. VSScript's init
# internally calls Py_Initialize(), which crashes/fails when Python is
# already running (our probe runs inside a Python subprocess). Instead,
# we verify the shared library exists AND can be dlopen'd (CDLL constructor
# resolves all .so dependencies). If it loads, it will work for av1an.
vs_ok = False
vs_detail = ""
vs_ver_str = ""
vs_lib_path = None
# --- Step 1: Direct filesystem check (most reliable) ---
# Check well-known install paths. Works even if ldconfig cache is stale.
_vs_script_search = [
"/usr/lib/libvapoursynth-script.so",
"/usr/lib/libvapoursynth_script.so",
"/usr/lib64/libvapoursynth-script.so",
"/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so",
"/usr/local/lib/libvapoursynth-script.so",
]
for p in _vs_script_search:
if Path(p).is_file():
vs_lib_path = p
break
# --- Step 2: Glob search on known lib dirs ---
if not vs_lib_path:
for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib",
"/usr/lib/x86_64-linux-gnu"):
d = Path(lib_dir)
if d.is_dir():
matches = list(d.glob("libvapoursynth-script.so*"))
# Prefer unversioned .so over .so.0 (dev symlink)
for m in sorted(matches, key=lambda p: p.name):
vs_lib_path = str(m)
break
if vs_lib_path:
break
# --- Step 3: ldconfig -p ---
if not vs_lib_path:
try:
res = subprocess.run(
["ldconfig", "-p"], capture_output=True, text=True, timeout=5,
)
for line in res.stdout.splitlines():
if "libvapoursynth-script" in line or "libvapoursynth_script" in line:
parts = line.split("=>")
if len(parts) >= 2:
vs_lib_path = parts[1].strip().split()[0]
break
except (OSError, subprocess.SubprocessError):
pass
# --- Step 4: ctypes.util.find_library ---
if not vs_lib_path:
try:
for name in ("vapoursynth-script", "vapoursynth_script"):
found = ctypes.util.find_library(name)
if found:
vs_lib_path = found
break
except (OSError, subprocess.SubprocessError):
pass
# --- Step 5: Distro-specific package file listing ---
if not vs_lib_path:
pkg_query = {
"arch": ["pacman", "-Ql", "vapoursynth"],
"debian": ["dpkg", "-L", "vapoursynth"],
"redhat": ["rpm", "-ql", "vapoursynth"],
"suse": ["rpm", "-ql", "vapoursynth"],
}
query_cmd = pkg_query.get(distro.family)
if query_cmd:
try:
res = subprocess.run(
query_cmd, capture_output=True, text=True, timeout=10,
)
for line in res.stdout.splitlines():
line = line.strip()
# Skip directory entries and grab .so files
if "libvapoursynth-script" in line and line.endswith(".so"):
vs_lib_path = line
break
if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path:
vs_lib_path = line # versioned .so as fallback
except (OSError, subprocess.SubprocessError):
pass
# --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) ---
# We do NOT gate on dlopen success. The library's constructor may call
# Py_Initialize() which conflicts with our Python subprocess, causing a
# silent segfault. av1an loads this library in its own fresh Rust process
# where no Python is running — so it works there even if our probe crashes.
# We only use dlopen to produce an optional warning.
vs_dlopen_warning = ""
if vs_lib_path:
try:
_escaped = vs_lib_path.replace("'", "\\'")
probe_code = (
"import ctypes; "
f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') "
f"except OSError as e: print(f'LOAD_FAIL|{{e}}') "
f"except Exception as e: print(f'LOAD_OTHER|{{e}}') "
)
res = subprocess.run(
[sys.executable, "-c", probe_code],
capture_output=True, text=True, timeout=10,
)
out = res.stdout.strip()
if out == "LOAD_OK":
vs_ok = True
elif out:
vs_dlopen_warning = f"dlopen test failed: {out}"
vs_ok = True # file exists — let av1an try in its own process
else:
# subprocess produced no output — likely segfault in library
# constructor (Py_Initialize conflict). File still exists.
vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)"
vs_ok = True
except subprocess.TimeoutExpired:
vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)"
vs_ok = True
except (OSError, subprocess.SubprocessError) as e:
vs_dlopen_warning = f"dlopen probe error: {e}"
vs_ok = True
# Final gate: library file was found on disk
if vs_lib_path and not vs_ok:
vs_ok = True # file found on disk is sufficient
if vs_ok:
vs_detail = vs_lib_path or "found"
# Try to get VapourSynth version from the core lib for diagnostics
try:
ver_probe = (
"import ctypes, ctypes.util; "
"_lib = ctypes.util.find_library('vapoursynth'); "
"if not _lib: "
" import subprocess as _sp; "
" _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); "
" _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() "
" if 'libvapoursynth.so.' in l and 'script' not in l]; "
" _lib = _m[0] if _m else None; "
"if _lib: "
" try: "
" _h = ctypes.CDLL(_lib); "
" _fn = _h.vapoursynth_version; "
" _fn.restype = ctypes.c_int; "
" print(_fn()) "
" except: pass "
)
res = subprocess.run(
[sys.executable, "-c", ver_probe],
capture_output=True, text=True, timeout=10,
)
ver_out = res.stdout.strip()
if ver_out and ver_out.isdigit() and int(ver_out) > 0:
vs_ver_str = f"R{ver_out}"
except (OSError, subprocess.SubprocessError):
pass
else:
if not vs_detail:
vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)"
deps["vapoursynth"] = vs_ok
if not vs_ok:
# Determine which package(s) to suggest.
# Most distros bundle VSScript into the main 'vapoursynth' package,
# but some split it (Debian/Ubuntu: libvapoursynth-script-dev).
# Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs.
if distro.vsscript_pkg:
missing_pkgs.append(distro.vsscript_pkg)
elif "vapoursynth" in distro.dep_pkgs:
missing_pkgs.append(distro.dep_pkgs["vapoursynth"])
deps["vs_detail"] = False # extra key for the diagnostic message
else:
deps["vs_detail"] = True
# --- Encoder binaries (av1an invokes these directly, not via ffmpeg) ---
for enc_key, binary_names in distro.encoder_binaries.items():
found = False
for bin_name in binary_names:
if _find_binary(bin_name, distro) is not None:
found = True
break
deps[enc_key] = found
if not found:
# Map encoder key to dep_pkgs key
dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"}
dep_key = dep_key_map.get(enc_key, enc_key)
if dep_key in distro.dep_pkgs:
pkg_name = distro.dep_pkgs[dep_key]
if pkg_name not in missing_pkgs:
missing_pkgs.append(pkg_name)
# --- ffprobe (needed for input file validation) ---
# Already checked in probe_environment() for the main binary, but let's
# make sure the dep dict reflects it for consistency.
# (ffprobe_path is set separately in probe_environment)
return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning
def probe_environment() -> EnvProbe:
"""
Distro-aware binary detection + av1an flag compatibility probe +
ffmpeg library availability check.
"""
distro = detect_distro()
result = EnvProbe(distro=distro)
result.cpu = detect_cpu_topology()
cpu = result.cpu
result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})")
result.warnings.append(
f"CPU: {cpu.model_name}{cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical"
)
# --- Binary detection (distro-aware path search) ---
for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]:
path = _find_binary(name, distro)
if path is None:
result.errors.append(f"Missing binary: {name}")
else:
setattr(result, attr, path)
# --- Install hint for missing binaries ---
if result.install_hint:
result.warnings.append(f"Install command: {result.install_hint}")
# --- FFmpeg version + library probe ---
if result.ffmpeg_path:
result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path)
if result.ffmpeg_version:
result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}")
result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path)
# Warn about missing AUDIO libs (video codecs are handled by av1an's own
# encoder binaries — ffmpeg's video encoder list is irrelevant)
audio_lib_warnings = {
"Opus": "libopus",
"Vorbis": "libvorbis",
"FLAC": "flac",
}
for codec_label, lib_name in audio_lib_warnings.items():
if not result.ffmpeg_libs.get(lib_name, False):
result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)")
# --- Av1an version ---
if result.av1an_path:
result.av1an_version = _probe_av1an_version(result.av1an_path)
if result.av1an_version:
result.warnings.append(f"av1an version: {result.av1an_version}")
# --- Av1an flag compatibility probe ---
if result.av1an_path:
try:
help_out = subprocess.run(
[result.av1an_path, "--help"],
capture_output=True, text=True, timeout=15,
).stdout
result.av1an_flags = {
"worker": "--workers" if "--workers" in help_out else "-w",
"video_params": "--video-params" if "--video-params" in help_out else "-v",
"audio_params": "--audio-params" if "--audio-params" in help_out else "-a",
}
# Detect which encoder names this av1an build actually accepts.
# Substring matching on --help is unreliable (e.g. "svt" appears in
# descriptions but the real name may be "svtav1" or "svt_av1").
# Instead, pass a bogus encoder name and parse the clap error which
# lists all valid values.
svt_name = _detect_av1an_svt_encoder(result.av1an_path)
if svt_name:
result.av1an_flags["svt_name"] = svt_name
result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'")
else:
# Absolute fallback — should rarely be needed
result.av1an_flags["svt_name"] = "svt_av1"
result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)")
# Check for chunk-method availability (differs by av1an version/distro)
if "--chunk-method" in help_out:
result.av1an_flags["has_chunk_method"] = True
# Check for --temp flag (lets us relocate av1an work dir out of user folders)
if "--temp" in help_out:
result.av1an_flags["has_temp"] = True
result.av1an_flags["temp_flag"] = "--temp"
elif "-T" in help_out:
result.av1an_flags["has_temp"] = True
result.av1an_flags["temp_flag"] = "-T"
# Check for -s/segments flag (newer av1an)
if "-s" in help_out or "--scenes" in help_out:
result.av1an_flags["has_scenes"] = True
# Detect concat method: prefer mkvmerge, fall back to ffmpeg
if shutil.which("mkvmerge"):
result.av1an_flags["concat_method"] = "mkvmerge"
else:
result.av1an_flags["concat_method"] = "ffmpeg"
except (OSError, subprocess.SubprocessError) as e:
result.errors.append(f"av1an probe failed: {e}")
# --- Distro-specific notes ---
if distro.notes:
result.warnings.append(f"Distro note: {distro.notes}")
# --- Runtime dependency probe (vapoursynth, encoder binaries) ---
if result.av1an_path:
deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro)
result.runtime_deps = deps
result.missing_dep_pkgs = missing_pkgs
if deps.get("vapoursynth"):
result.vs_version = vs_ver
result.vs_script_lib = vs_detail
# Log VapourSynth/VSScript with extra detail
vs_status = "OK" if deps.get("vapoursynth") else "MISSING"
result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}")
if deps.get("vapoursynth"):
# vs_detail is the library path on success
result.warnings.append(f" VSScript lib: {vs_detail}")
if vs_dlopen_warn:
result.warnings.append(f" dlopen note: {vs_dlopen_warn}")
else:
# vs_detail is the failure reason
result.warnings.append(f" Reason: {vs_detail}")
# Log encoder binary deps (skip vs_detail key)
for dep_name, present in deps.items():
if dep_name in ("vapoursynth", "vs_detail"):
continue
status = "OK" if present else "MISSING"
result.warnings.append(f"Dependency: {dep_name} = {status}")
if missing_pkgs:
hint = result.dep_install_hint
result.errors.append(
f"Missing runtime dependencies: {', '.join(missing_pkgs)}"
)
if hint:
result.errors.append(f" FIX: {hint}")
return result
def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None:
"""Determine the exact encoder name av1an accepts for SVT-AV1.
Strategy (in order):
1. Run ``av1an --encoder __PROBE__`` and parse clap's error for
``[possible values: ...]``.
2. Parse ``--help`` for ``[default: <name>]`` next to ``--encoder``.
3. Regex fallback on the error output.
"""
try:
# --- Method 1: clap error with possible values ---
res = subprocess.run(
[av1an_bin, "--encoder", "__PROBE_TEST__"],
capture_output=True, text=True, timeout=10,
)
stderr = res.stderr or ""
stdout = res.stdout or ""
combined = stderr + stdout
m = re.search(r"\[possible values:\s*([^\]]+)\]", combined)
if m:
values = [v.strip().rstrip(',') for v in m.group(1).split()]
for v in values:
if "svt" in v.lower():
return v
# --- Method 2: parse --help for encoder default value ---
help_res = subprocess.run(
[av1an_bin, "--help"],
capture_output=True, text=True, timeout=10,
)
help_text = (help_res.stdout or "") + (help_res.stderr or "")
# Look for pattern: --encoder <ENCODER> ... [default: svt-av1]
m2 = re.search(
r"--encoder\s+<ENCODER>.*?\[default:\s*(\S+?)\]",
help_text, re.DOTALL,
)
if m2:
return m2.group(1)
# --- Method 3: regex fallback on the error output ---
for line in combined.splitlines():
for token in re.findall(r"\bsvt[a-z_-]*av1[a-z_-]*\b", line, re.IGNORECASE):
return token
for token in re.findall(r"\bsvtav1\b", line, re.IGNORECASE):
return token
return None
except (OSError, subprocess.SubprocessError):
return None
def _av1an_env() -> dict[str, str]:
"""Build an env dict for subprocess that includes ~/.local/lib in LD_LIBRARY_PATH.
When VapourSynth is built from git and installed to ~/.local/, the linker
won't find libvapoursynth-script.so unless LD_LIBRARY_PATH points there.
This function ensures every av1an invocation inherits that path.
"""
env = os.environ.copy()
local_lib = str(Path.home() / ".local" / "lib")
existing = env.get("LD_LIBRARY_PATH", "")
if local_lib not in existing:
env["LD_LIBRARY_PATH"] = f"{local_lib}:{existing}".rstrip(":")
return env
def _av1an_vsscript_smoke_test(
av1an_bin: str,
ffmpeg_bin: str,
av1an_flags: dict,
svt_name: str = "svt_av1",
timeout: int = 30,
) -> tuple[bool, str]:
"""Pre-flight test: create a tiny video and try to run av1an on it.
This catches 'Failed to get VSScript API' panics BEFORE the real queue
starts. File-existence checks for libvapoursynth-script.so pass even
when the ABI is incompatible (av1an's Rust vapoursynth crate built
against a different VS version). Only actually invoking av1an reveals
the mismatch.
Returns (ok, detail_message).
ok=True -> av1an initialized VSScript successfully.
ok=False -> av1an panicked or failed; detail_message explains why.
"""
with tempfile.TemporaryDirectory(prefix="av1an_smoke_") as tmpdir:
test_in = Path(tmpdir) / "test_smoke.mkv"
test_out = Path(tmpdir) / "test_smoke_out.mkv"
# Create a 1-second 64x64 black video (video-only is enough to
# trigger VSScript init in av1an — no audio needed).
gen_cmd = [
ffmpeg_bin,
"-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24",
"-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(test_in),
]
try:
res = subprocess.run(gen_cmd, capture_output=True, text=True, timeout=15)
if res.returncode != 0:
return False, f"ffmpeg test-video failed (rc={res.returncode}): {(res.stderr or '')[-200:]}"
except (OSError, subprocess.SubprocessError) as e:
return False, f"Could not generate smoke test video: {e}"
if not test_in.exists():
return False, "Smoke test video was not created by ffmpeg"
# Build minimal av1an command
worker_flag = av1an_flags.get("worker", "--workers")
vparams_flag = av1an_flags.get("video_params", "--video-params")
aparams_flag = av1an_flags.get("audio_params", "--audio-params")
cmd = [
av1an_bin,
"-i", str(test_in),
worker_flag, "1",
"--encoder", svt_name,
vparams_flag, "--preset 8 --crf 40 --keyint 240",
"-o", str(test_out),
]
# Use chunk-method select if available (triggers VSScript init)
if av1an_flags.get("has_chunk_method"):
cmd.extend(["--chunk-method", "select"])
# SEI CERT ERR01-C: catch only the specific exception types we
# expect from subprocess.run; never swallow unrelated failures.
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
env=_av1an_env())
except subprocess.TimeoutExpired:
# Timeout is a real failure — av1an is hanging. Do NOT mask it.
return False, f"SMOKE_TIMEOUT: av1an smoke test exceeded {timeout}s — likely hung in VSScript init or encoder spawn"
except FileNotFoundError as e:
return False, f"SMOKE_BIN_MISSING: {e}"
except OSError as e:
return False, f"SMOKE_OS_ERROR: {e}"
stderr = res.stderr or ""
stdout = res.stdout or ""
# Success requires BOTH rc==0 AND the output file actually exists.
# The previous code returned True on any non-VSScript failure, which
# masked real bugs (missing encoder binary, concat failure, etc.)
# and led to "chunks but never saves a file" symptoms in production.
if res.returncode == 0 and test_out.exists():
test_out.unlink(missing_ok=True)
return True, "av1an VSScript init OK"
# Classify the known failure modes by inspecting stderr.
if "Failed to get VSScript API" in stderr:
return False, "VSScript_API_INCOMPAT"
if "invalid value" in stderr and "--encoder" in stderr:
return False, f"INVALID_ENCODER: {stderr[-200:]}"
if "No usable encoder found" in stderr:
return False, f"ENCODER_BIN_MISSING: {stderr[-300:]}"
# Unknown failure — return False so the caller can offer ffmpeg
# fallback or rebuild. Include the FULL stderr (not just the tail)
# so the user can see the actual error and the diagnostic patterns
# below can match on it.
combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:]
return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}"