fix: harden FFmpeg capability preflight and references

Repairs public FFmpeg evidence references, adds named capability checks, and expands deterministic failure-path coverage. Closes #428.
This commit is contained in:
Magnus Hedemark
2026-09-01 13:50:33 -04:00
committed by GitHub
parent 02d6189135
commit f92cf52b25
11 changed files with 577 additions and 55 deletions
+188 -19
View File
@@ -1,54 +1,223 @@
#!/usr/bin/env python3
"""Report local FFmpeg/ffprobe capability facts as JSON or readable text."""
"""Check local FFmpeg/ffprobe availability and named build capabilities.
Non-mutating, dependency-free, and offline: it runs read-only inventory
probes, never touches media files, and makes no network requests.
Checks reported:
ffmpeg / ffprobe availability, resolved path, return code, and the first
diagnostic line of the version probe.
filters / encoders / hwaccels
whether each inventory probe ran, plus a conservative
count of parseable entries. A count of zero means the
inventory was empty or used an unexpected format; it is
a warning, not a proof of absence.
Named capability queries:
--filter NAME, --encoder NAME, --hwaccel NAME (each repeatable)
Report whether each exact name appears in the corresponding inventory.
Matching is case-sensitive and exact; a query against an unavailable or
empty inventory is reported absent.
Output:
Default: concise human-readable lines. --json: one JSON document with the
shape {"ffmpeg", "ffprobe", "filters", "encoders", "hwaccels", "queries"}.
Raw inventory text is never printed; only counts and named results are.
Exit codes:
0 tools available, probes ran, and every requested capability is present
1 probe or environment failure (missing binary, failed inventory probe)
2 probes ran but at least one requested capability is absent
Human output is evidence for people; the parsed counts and query results in
--json output are the stable interface. Inventory text itself varies across
FFmpeg versions and builds and is intentionally not treated as an API.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
from typing import Any
FIRST_LINE_LIMIT = 200
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
ENCODER_FLAG_RE = re.compile(r"[VAS][A-Za-z.]{5}")
def run(binary: str, *args: str) -> dict[str, Any]:
def first_line(text: str) -> str:
for line in text.splitlines():
if line.strip():
return line.strip()[:FIRST_LINE_LIMIT]
return ""
def probe(binary: str, *args: str) -> tuple[dict[str, Any], str]:
"""Run one read-only probe. Returns (public report, stdout for parsing)."""
path = shutil.which(binary)
if not path:
return {"available": False, "error": f"{binary} was not found on PATH"}
result = subprocess.run(
[path, *args], capture_output=True, text=True, check=False
)
output = result.stdout + result.stderr
first_line = next((line for line in output.splitlines() if line.strip()), "")
return {
return {"available": False, "error": f"{binary} was not found on PATH"}, ""
result = subprocess.run([path, *args], capture_output=True, text=True, check=False)
report = {
"available": result.returncode == 0,
"path": path,
"returncode": result.returncode,
"first_line": first_line,
"first_line": first_line(result.stdout + result.stderr),
}
return report, result.stdout
def parse_filters(stdout: str) -> set[str]:
"""Parse filter names from `ffmpeg -filters` output.
Entry lines look like ` .. scale V->V Scale the input video size.`
(flags column, name, media spec containing '->'). Legend and separator
lines do not have a media spec and are skipped conservatively.
"""
names: set[str] = set()
for line in stdout.splitlines():
fields = line.split()
if len(fields) < 3 or "->" not in fields[2]:
continue
if NAME_RE.fullmatch(fields[1]):
names.add(fields[1])
return names
def parse_encoders(stdout: str) -> set[str]:
"""Parse encoder names from `ffmpeg -encoders` output.
Entry lines look like ` V....D libx264 libx264 H.264 ...` (type flag,
name, description). Legend lines have '=' as the second field and are
skipped because '=' is not a valid name character.
"""
names: set[str] = set()
for line in stdout.splitlines():
fields = line.split()
if len(fields) < 3 or not ENCODER_FLAG_RE.fullmatch(fields[0]):
continue
if NAME_RE.fullmatch(fields[1]):
names.add(fields[1])
return names
def parse_hwaccels(stdout: str) -> set[str]:
"""Parse hardware acceleration methods from `ffmpeg -hwaccels` output.
Each method is listed as a bare word on its own line; the header line
and blank lines are skipped.
"""
names: set[str] = set()
for line in stdout.splitlines():
token = line.strip()
if NAME_RE.fullmatch(token):
names.add(token)
return names
PARSERS = {
"filters": parse_filters,
"encoders": parse_encoders,
"hwaccels": parse_hwaccels,
}
def summarize_inventory(report: dict[str, Any], stdout: str, kind: str) -> tuple[dict[str, Any], set[str]]:
summary: dict[str, Any] = {
"available": report.get("available", False),
"returncode": report.get("returncode"),
}
entries = PARSERS[kind](stdout) if summary["available"] and stdout else set()
summary["entry_count"] = len(entries)
if summary["available"] and not entries:
summary["warning"] = "no parseable entries; inventory empty or unexpected format"
for key in ("error", "first_line"):
if key in report:
summary[key] = report[key]
return summary, entries
def main() -> int:
parser = argparse.ArgumentParser(
description="Check local FFmpeg tools before using version-sensitive recipes."
description="Check local FFmpeg tools and named build capabilities before automating.",
)
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
parser.add_argument(
"--filter", action="append", default=[], metavar="NAME",
help="check whether filter NAME is available (repeatable)",
)
parser.add_argument(
"--encoder", action="append", default=[], metavar="NAME",
help="check whether encoder NAME is available (repeatable)",
)
parser.add_argument(
"--hwaccel", action="append", default=[], metavar="NAME",
help="check whether hardware acceleration method NAME is available (repeatable)",
)
args = parser.parse_args()
report = {
"ffmpeg": run("ffmpeg", "-version"),
"ffprobe": run("ffprobe", "-version"),
"filters": run("ffmpeg", "-filters"),
"encoders": run("ffmpeg", "-encoders"),
"hardware_acceleration": run("ffmpeg", "-hwaccels"),
ffmpeg_report, _ = probe("ffmpeg", "-version")
ffprobe_report, _ = probe("ffprobe", "-version")
inventories: dict[str, dict[str, Any]] = {}
entries: dict[str, set[str]] = {}
for kind in ("filters", "encoders", "hwaccels"):
if ffmpeg_report.get("available"):
report, stdout = probe("ffmpeg", "-hide_banner", f"-{kind}")
else:
report, stdout = {"available": False, "error": ffmpeg_report.get("error", "")}, ""
inventories[kind], entries[kind] = summarize_inventory(report, stdout, kind)
queries = {
"filter": {name: name in entries["filters"] for name in dict.fromkeys(args.filter)},
"encoder": {name: name in entries["encoders"] for name in dict.fromkeys(args.encoder)},
"hwaccel": {name: name in entries["hwaccels"] for name in dict.fromkeys(args.hwaccel)},
}
queries = {kind: names for kind, names in queries.items() if names}
report = {
"ffmpeg": ffmpeg_report,
"ffprobe": ffprobe_report,
"filters": inventories["filters"],
"encoders": inventories["encoders"],
"hwaccels": inventories["hwaccels"],
"queries": queries,
}
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
for name, result in report.items():
for name in ("ffmpeg", "ffprobe"):
result = report[name]
state = "available" if result["available"] else "unavailable"
detail = result.get("first_line") or result.get("error", "")
print(f"{name}: {state} - {detail}")
return 0 if report["ffmpeg"]["available"] and report["ffprobe"]["available"] else 1
for kind in ("filters", "encoders", "hwaccels"):
summary = report[kind]
if not summary["available"]:
detail = summary.get("error") or f"probe exited {summary.get('returncode')}"
print(f"{kind}: failed - {detail}")
elif summary["entry_count"] == 0:
print(f"{kind}: 0 entries parsed ({summary.get('warning', 'empty inventory')})")
else:
print(f"{kind}: {summary['entry_count']} entries parsed")
for kind, names in queries.items():
for name, present in names.items():
print(f"{kind} '{name}': {'present' if present else 'absent'}")
probe_failures = (
not ffmpeg_report["available"]
or not ffprobe_report["available"]
or any(not inventories[kind]["available"] for kind in inventories)
)
if probe_failures:
return 1
if any(not present for names in queries.values() for present in names.values()):
return 2
return 0
if __name__ == "__main__":
+291 -16
View File
@@ -1,36 +1,311 @@
#!/usr/bin/env python3
"""Smoke-test the FFmpeg preflight script without media or network access."""
"""Deterministic pytest suite for ffmpeg-preflight.
Fake ffmpeg/ffprobe binaries cover the declared failure matrix without real
media, network access, GPU hardware, or dependence on a particular CI image.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def test_preflight_with_fake_tools():
script = Path(__file__).resolve().parent / "ffmpeg-preflight"
import pytest
with tempfile.TemporaryDirectory() as directory:
fake_bin = Path(directory)
for name in ("ffmpeg", "ffprobe"):
tool = fake_bin / name
tool.write_text("#!/bin/sh\nprintf \"%s\\n\" \"$0 $*\"\n")
tool.chmod(0o755)
SCRIPT = Path(__file__).resolve().parent / "ffmpeg-preflight"
FAKE_FFMPEG_VERSION = "ffmpeg version 8.1.2 fake"
FAKE_FFPROBE_VERSION = "ffprobe version 8.1.2 fake"
FIXTURE_FILTERS = """Filters:
T.. = Timeline support
A = Audio input/output
V = Video input/output
| = Source or sink filter
------
.. scale V->V Scale the input video size.
TS aap AA->A Apply Affine Projection algorithm.
.. anullsrc |->A Null audio source, return empty audio frames.
.. abuffersink A->| Buffer audio frames,
"""
FIXTURE_ENCODERS = """Encoders:
V..... = Video
A..... = Audio
S..... = Subtitle
------
V....D libx264 libx264 H.264 (codec h264)
A....D aac AAC (Advanced Audio Coding)
V....D libvpx-vp9 libvpx VP9 (codec vp9)
"""
FIXTURE_HWACCELS = """Hardware acceleration methods:
videotoolbox
"""
HEADER_ONLY_FILTERS = "Filters:\n"
MALFORMED_FILTERS = """some preamble noise
scale V->V without leading fields
T.. = Timeline support
.. V->V name token is missing
not->an-entry
Filters:
"""
def write_fixtures(directory: Path, filters: str, encoders: str, hwaccels: str) -> None:
(directory / "filters.txt").write_text(filters)
(directory / "encoders.txt").write_text(encoders)
(directory / "hwaccels.txt").write_text(hwaccels)
def install_tools(
bin_dir: Path,
fixtures_dir: Path,
*,
with_ffprobe: bool = True,
version_rc: int = 0,
filters_rc: int = 0,
encoders_rc: int = 0,
hwaccels_rc: int = 0,
) -> None:
emit = (
"emit() {\n"
' while IFS= read -r line || [ -n "$line" ]; do\n'
" printf '%s\\n' \"$line\"\n"
" done < \"$1\"\n"
"}\n"
)
ffmpeg = (
"#!/bin/sh\n"
+ emit
+ 'case "$*" in\n'
f' *-version*) echo "{FAKE_FFMPEG_VERSION}"; exit {version_rc} ;;\n'
f' *-filters*) emit "$FIXTURES/filters.txt"; exit {filters_rc} ;;\n'
f' *-encoders*) emit "$FIXTURES/encoders.txt"; exit {encoders_rc} ;;\n'
f' *-hwaccels*) emit "$FIXTURES/hwaccels.txt"; exit {hwaccels_rc} ;;\n'
"esac\n"
'echo "unexpected arguments: $*" >&2\n'
"exit 99\n"
)
ffmpeg_path = bin_dir / "ffmpeg"
ffmpeg_path.write_text(ffmpeg)
ffmpeg_path.chmod(0o755)
if with_ffprobe:
ffprobe = bin_dir / "ffprobe"
ffprobe.write_text(f'#!/bin/sh\necho "{FAKE_FFPROBE_VERSION}"\n')
ffprobe.chmod(0o755)
@pytest.fixture
def environment(tmp_path: Path):
"""Provide (setup, run) with healthy fake tools pre-installed.
``setup(**overrides)`` rebuilds the fake tools (use for missing ffprobe,
failing probes, or alternate fixtures). ``run(*args, bin_dir=None)``
invokes the preflight script with PATH restricted to the fake bin dir.
"""
bin_dir = tmp_path / "bin"
fixtures_dir = tmp_path / "fixtures"
def setup(
*,
with_ffprobe: bool = True,
filters: str = FIXTURE_FILTERS,
encoders: str = FIXTURE_ENCODERS,
hwaccels: str = FIXTURE_HWACCELS,
**tool_kwargs,
) -> Path:
shutil.rmtree(bin_dir, ignore_errors=True)
bin_dir.mkdir()
fixtures_dir.mkdir(exist_ok=True)
write_fixtures(fixtures_dir, filters, encoders, hwaccels)
install_tools(bin_dir, fixtures_dir, with_ffprobe=with_ffprobe, **tool_kwargs)
return bin_dir
def run(*arguments: str, bin_dir: Path | None = None) -> subprocess.CompletedProcess:
env = os.environ.copy()
env["PATH"] = str(fake_bin)
result = subprocess.run(
[sys.executable, str(script), "--json"],
env["PATH"] = str(bin_dir if bin_dir is not None else tmp_path / "bin")
env["FIXTURES"] = str(fixtures_dir)
return subprocess.run(
[sys.executable, str(SCRIPT), *arguments],
capture_output=True,
text=True,
env=env,
)
setup()
return setup, run
def test_success_json_reports_availability_counts(environment):
setup, run = environment
result = run("--json")
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["ffmpeg"]["available"] is True
assert report["ffmpeg"]["first_line"] == FAKE_FFMPEG_VERSION
assert report["ffprobe"]["available"] is True
assert report["filters"]["available"] is True
assert report["filters"]["entry_count"] == 4
assert report["encoders"]["entry_count"] == 3
assert report["hwaccels"]["entry_count"] == 1
assert "warning" not in report["filters"]
assert report["queries"] == {}
def test_named_queries_present_exit_zero(environment):
setup, run = environment
result = run("--json", "--filter", "scale", "--filter", "anullsrc",
"--encoder", "libx264", "--encoder", "libvpx-vp9",
"--hwaccel", "videotoolbox")
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["queries"] == {
"filter": {"scale": True, "anullsrc": True},
"encoder": {"libx264": True, "libvpx-vp9": True},
"hwaccel": {"videotoolbox": True},
}
def test_named_queries_absent_exit_two(environment):
setup, run = environment
result = run("--json", "--filter", "drawtext", "--encoder", "nosuchenc",
"--hwaccel", "cuda")
assert result.returncode == 2
report = json.loads(result.stdout)
assert report["queries"]["filter"]["drawtext"] is False
assert report["queries"]["encoder"]["nosuchenc"] is False
assert report["queries"]["hwaccel"]["cuda"] is False
def test_mixed_queries_exit_two(environment):
setup, run = environment
result = run("--filter", "scale", "--filter", "drawtext")
assert result.returncode == 2
def test_missing_tools_exit_one(environment, tmp_path):
setup, run = environment
empty_bin = tmp_path / "empty-bin"
empty_bin.mkdir()
result = run("--json", "--filter", "scale", bin_dir=empty_bin)
assert result.returncode == 1
report = json.loads(result.stdout)
assert report["ffmpeg"]["available"] is False
assert report["ffprobe"]["available"] is False
assert report["filters"]["available"] is False
assert report["queries"] == {"filter": {"scale": False}}
def test_ffprobe_missing_exit_one(environment):
setup, run = environment
setup(with_ffprobe=False)
result = run("--json")
assert result.returncode == 1
report = json.loads(result.stdout)
assert report["ffmpeg"]["available"] is True
assert report["ffprobe"]["available"] is False
def test_inventory_command_failure_exit_one(environment):
setup, run = environment
setup(filters_rc=3)
result = run("--json")
assert result.returncode == 1
report = json.loads(result.stdout)
assert report["filters"]["available"] is False
assert report["filters"]["returncode"] == 3
assert report["encoders"]["available"] is True
assert report["hardware_acceleration"]["available"] is True
print("ffmpeg preflight smoke test passed")
def test_inventory_failure_takes_precedence_over_absent_query(environment):
setup, run = environment
setup(filters_rc=1)
result = run("--filter", "scale")
assert result.returncode == 1
def test_empty_inventory_warns_and_exits_zero_without_queries(environment):
setup, run = environment
setup(filters=HEADER_ONLY_FILTERS, encoders="", hwaccels="")
result = run("--json")
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["filters"]["entry_count"] == 0
assert "warning" in report["filters"]
assert report["encoders"]["entry_count"] == 0
assert report["hwaccels"]["entry_count"] == 0
def test_query_against_empty_inventory_is_absent_exit_two(environment):
setup, run = environment
setup(filters=HEADER_ONLY_FILTERS)
result = run("--filter", "scale")
assert result.returncode == 2
def test_malformed_output_yields_no_entries(environment):
setup, run = environment
setup(filters=MALFORMED_FILTERS, encoders="garbage line\nsecond line\n")
result = run("--json", "--filter", "scale", "--encoder", "libx264")
assert result.returncode == 2
report = json.loads(result.stdout)
assert report["filters"]["entry_count"] == 0
assert report["encoders"]["entry_count"] == 0
assert report["queries"]["filter"]["scale"] is False
def test_repeated_flags_are_deduplicated(environment):
setup, run = environment
result = run("--json", "--filter", "scale", "--filter", "scale", "--filter", "scale")
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["queries"]["filter"] == {"scale": True}
def test_stderr_noise_is_not_parsed_as_inventory(environment, tmp_path):
setup, run = environment
noisy = tmp_path / "bin" / "ffmpeg"
body = noisy.read_text()
noisy.write_text(body.replace(
'#!/bin/sh\n',
'#!/bin/sh\necho "static banner noise" >&2\necho "banner on stdout too" \n',
))
result = run("--json")
assert result.returncode == 0, result.stderr
report = json.loads(result.stdout)
assert report["filters"]["entry_count"] == 4
def test_text_mode_is_concise_and_names_results(environment):
setup, run = environment
result = run("--filter", "scale", "--encoder", "nosuchenc")
assert result.returncode == 2
lines = result.stdout.splitlines()
assert FAKE_FFMPEG_VERSION in result.stdout
assert any(l.startswith("filters: 4 entries parsed") for l in lines)
assert any(l.startswith("filter 'scale': present") for l in lines)
assert any(l.startswith("encoder 'nosuchenc': absent") for l in lines)
assert "Scale the input video size" not in result.stdout
def test_json_mode_stdout_is_a_single_json_document(environment):
setup, run = environment
result = run("--json")
assert result.returncode == 0, result.stderr
assert result.stdout.lstrip().startswith("{")
json.loads(result.stdout)
def test_human_mode_failure_line_for_missing_binary(environment, tmp_path):
setup, run = environment
empty_bin = tmp_path / "nobin"
empty_bin.mkdir()
result = run(bin_dir=empty_bin)
assert result.returncode == 1
assert "ffmpeg: unavailable" in result.stdout