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
+10
View File
@@ -21,6 +21,8 @@ After installation, an agent can reason about remuxing versus transcoding, const
| `references/source-inventory.md` | Primary and secondary sources with evidence boundaries |
| `references/local-verification.md` | Recorded local-build experiments and their limits |
| `references/learning-summary.md` | Learning progression and consolidated mental model |
| `scripts/ffmpeg-preflight` | Capability preflight: tool availability, inventory counts, and named filter/encoder/hwaccel checks |
| `scripts/test_ffmpeg_preflight.py` | Deterministic pytest suite for the preflight (fake tools, no media or network) |
| `evals/evals.json` | Portable output-quality cases for the skill |
## Quick Start
@@ -35,6 +37,14 @@ ffprobe -v error -show_format -show_streams -of json input.mp4
Ask your agent to inspect the input before selecting a command. During exploration, write to a new output path and use `-n` to refuse accidental overwrites.
Before automating a version-sensitive recipe, check that the installed build actually has the required capabilities:
```sh
scripts/ffmpeg-preflight --filter scale --filter subtitles --encoder libx264 --hwaccel videotoolbox
```
Each name check is repeatable and reported `present` or `absent`. Exit codes: `0` all requested capabilities present, `1` a tool or probe failed, `2` a requested capability is absent. Add `--json` for machine-readable output.
## Triggers
Load this skill when the task involves:
+1 -1
View File
@@ -42,7 +42,7 @@ Treat FFmpeg commands as typed media pipelines, not incantations. Start from wha
- Read `references/learning-summary.md` for the newcomer-first progression and consolidated mental model.
- Read `references/source-inventory.md` when assessing evidence, choosing authoritative documentation, or refreshing version-sensitive guidance.
- Read `references/local-verification.md` when interpreting the recorded local FFmpeg 8.1.2 evidence. It is a host-specific observation, not a universal capability claim.
- Run `scripts/ffmpeg-preflight --json` before automating a version-sensitive workflow. It reports whether `ffmpeg` and `ffprobe` are available and captures the first diagnostic line for local filters, encoders, and hardware inventory.
- Run `scripts/ffmpeg-preflight` before automating a version-sensitive workflow. It reports whether `ffmpeg` and `ffprobe` are available, summarizes parsed filter/encoder/hwaccel inventory counts, and answers named checks: `--filter NAME`, `--encoder NAME`, `--hwaccel NAME` (each repeatable). Use `--json` for machine-readable output. Exit codes: 0 every requested capability present, 1 tool or probe failure, 2 a requested capability is absent.
## Debugging Rules
+14 -1
View File
@@ -18,7 +18,7 @@
{
"id": "build-aware-filter",
"prompt": "This command fails with No such filter: drawtext. Replace it with something that will work everywhere.",
"expected_output": "Do not claim a universal replacement. Explain that filter availability is build-dependent, show how to inspect the installed filter inventory/help, and offer a conditional path: use an available equivalent, install a build containing the filter, or omit the transformation.",
"expected_output": "Do not claim a universal replacement. Explain that filter availability is build-dependent, show how to inspect the installed filter inventory or use the skill's ffmpeg-preflight named checks (--filter NAME), and offer a conditional path: use an available equivalent, install a build containing the filter, or omit the transformation.",
"assertions": [
"does not fabricate universal filter availability",
"checks ffmpeg filter capabilities",
@@ -67,6 +67,19 @@
],
"case_set": "release"
},
{
"id": "named-capability-check",
"prompt": "Before I script a transcode, check whether my local ffmpeg build can scale with libx264 and videotoolbox, and whether drawtext exists. Just tell me what is available.",
"expected_output": "Run bounded capability checks rather than assuming from memory: the ffmpeg-preflight script with --filter/--encoder/--hwaccel named checks or the corresponding inventory commands. Report each requested capability as present or absent separately, distinguish absence from probe failure, and state a next step for any absent capability without claiming universal availability.",
"assertions": [
"uses named capability checks or inventories instead of assumptions",
"reports each requested capability individually",
"distinguishes absent capabilities from probe or tool failures",
"does not claim a capability is present without local evidence",
"offers a conditional next step for absent capabilities"
],
"case_set": "dev"
},
{
"id": "hardware-pipeline-caveat",
"prompt": "NVENC is enabled. Does that prove hardware acceleration will make this workflow faster on my Mac?",
@@ -12,7 +12,7 @@ ffmpeg -encoders
ffmpeg -filters | grep -E 'cuda|vaapi|qsv|videotoolbox|vulkan'
```
The local macOS build lists `videotoolbox`. NVIDIA CUDA examples from the vendor guide do not apply to this host. Verify the exact device, pixel formats, filter path, and encoder before benchmarking.
The recorded local macOS build (see `local-verification.md`) lists `videotoolbox`. NVIDIA CUDA examples from the vendor guide do not transfer to other platforms. Verify the exact device, pixel formats, filter path, and encoder on the target build before benchmarking.
## Timestamp and synchronization diagnosis
@@ -25,6 +25,16 @@ ffmpeg -loglevel verbose -i input -f null -
Compare stream start times, durations, time bases, frame rates, sample rates, packet ordering, and whether a muxer is buffering sparse streams. Avoid cargo-culting timestamp flags. Options such as `-start_at_zero`, `-copyts`, `-vsync`/the modern synchronization controls, `setpts`, `asetpts`, `aresample`, and `avoid_negative_ts` solve different problems and can interact.
### Audio drift: diagnose before adjusting
Audio drift (gradually increasing A/V offset, or audio that ends before/after video) is a timing or rate mismatch, not a volume problem. Diagnose in this order:
1. **Probe both streams.** Compare `start_time`, `duration`, `time_base`, `sample_rate`, and codec with `ffprobe`. A nonzero or mismatched `start_time` between audio and video shifts the whole track; different `sample_rate` or a rate-labeling mismatch causes progressive drift.
2. **Check the operation history.** Concatenating sources with different sample rates or time bases, trimming without `asetpts`, or re-encoding AAC (encoder delay/priming samples) each produce characteristic offset patterns. AAC priming shifts audio by a fixed amount; rate mismatch grows over time.
3. **Normalize deliberately.** `aresample=async=1:first_pts=0` resamples and stretches/squeezes audio onto the video clock, compensating small drift; `asetpts=PTS-STARTPTS` resets timestamps after trimming or concat; `aformat` plus an explicit `sample_rate` makes both inputs share one rate before joining. Apply these where the mismatch originates rather than at the final output only.
If drift appears only in a specific player or receiver, verify the container edit lists and stream timestamps there before changing the encode; the muxer may be preserving an offset the encoder did not create.
## Reproducible experiments
Pin the binary version, record `ffmpeg -version` and `-buildconf`, preserve the exact input or synthetic generator, and probe both sides. Run more than once when measuring speed. Separate wall-clock throughput from output quality and compatibility. If a tutorial omits hardware, build, driver, codec settings, or measurement method, treat its performance claim as incomplete.
@@ -49,6 +59,12 @@ https://ffmpeg.org/ffmpeg-protocols.html
https://ffmpeg.org/ffmpeg-formats.html
-> Probing, interleaving, timestamp shifting, and muxer/demuxer behavior.
https://ffmpeg.org/ffmpeg-resampler.html
-> Sample-rate conversion, compensation, and the async/first_pts options used for audio drift repair.
https://ffmpeg.org/ffmpeg-filters.html
-> setpts/asetpts, aformat, and timestamp normalization filters.
https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/ffmpeg-with-nvidia-gpu/index.html
-> NVIDIA-specific CUDA/NVENC pipeline examples and performance cautions.
+1 -1
View File
@@ -86,5 +86,5 @@ https://ffmpeg.org/ffmpeg.html
https://ffmpeg.org/ffprobe.html
-> Primary inspection and machine-readable output.
03-dossiers/local-verification.md
references/local-verification.md
-> Commands actually exercised on the local FFmpeg 8.1.2 build.
@@ -65,5 +65,5 @@ https://ffmpeg.org/ffmpeg-resampler.html
https://trac.ffmpeg.org/wiki/FilteringGuide
-> Official wiki tutorial lead for filtergraphs; access was blocked by Anubis during this research and commands require verification.
03-dossiers/local-verification.md
-> Local filter inventory and verified missing-filter failure.
references/local-verification.md
-> Local filter inventory counts and the verified missing-filter failure.
+23 -2
View File
@@ -18,7 +18,12 @@ Do not call remuxing a conversion of the encoded media. It changes packaging onl
## Trim and seek
`-ss` can be placed before input for fast input seeking or after input for output-side behavior with different accuracy and cost. `-t` limits duration; `-to` specifies an endpoint in the relevant command context. Test the actual cut, especially with inter-frame codecs, nonzero start timestamps, and audio.
`-ss` before the input is input seeking: the demuxer jumps to the nearest seek point before the target, which is fast. `-ss` after the input (before the output) is output seeking: FFmpeg decodes and discards from the stream start until the target, which is slow for late cut points. `-t` limits duration; `-to` specifies an endpoint in the relevant command context. Test the actual cut, especially with inter-frame codecs, nonzero start timestamps, and audio.
Decision rule:
- **Re-encoding anyway:** put `-ss` before `-i`. With the default accurate seek, frames between the keyframe and the target are decoded and discarded, so the cut is frame-accurate and still fast.
- **Stream copy (`-c copy`):** packets cannot be decoded and discarded, so output starts at the packet boundary the demuxer lands on — typically the keyframe at or before the target. Accept keyframe-aligned cuts and verify the actual start time with `ffprobe`, or re-encode for frame accuracy.
```sh
ffmpeg -ss 00:01:00 -i input.mp4 -t 00:00:20 -c copy quick-cut.mp4
@@ -34,11 +39,24 @@ There are distinct mechanisms:
- The concat filter operates on decoded audio/video and can join segments after normalizing dimensions, formats, and timestamps.
- The concat protocol is physical byte/resource concatenation and is not a general-purpose media join.
Decision rule:
- **Streams match (same codecs, parameters, and time bases) and the target container accepts the concat demuxer:** use the demuxer with `-c copy`. It is fast and lossless. Verify the combined duration and stream count afterwards.
- **Inputs differ in codecs, dimensions, frame rates, pixel formats, sample rates, or start times:** decode and normalize, then use the concat filter. Normalize video with `scale`/`fps` (and pixel format), audio with `aresample`/`aformat` to a common rate, and reset each segment's timestamps with `setpts=PTS-STARTPTS` and `asetpts=PTS-STARTPTS` before joining. This path re-encodes.
- **Raw byte-concatenatable formats only (for example MPEG-TS segments):** the concat protocol. Do not use it for container files.
Never choose a concat method solely because files share an extension. Inspect codecs, dimensions, frame rates, sample rates, channel layouts, time bases, and metadata.
## Metadata and subtitles
Metadata can be copied, mapped, or rewritten. FFmpeg's format documentation describes the `ffmetadata` muxer/demuxer for round-tripping metadata. Subtitle streams can be copied when the target container supports them, or rendered into video with a subtitle filter when permanent pixels are intended. Those are different deliverables.
Metadata can be copied, mapped, or rewritten. FFmpeg's format documentation describes the `ffmetadata` muxer/demuxer for round-tripping metadata.
For subtitles, choose between preserving the stream and rendering pixels — they are different deliverables:
- **Copy (`-c:s copy` or an explicit subtitle codec)** when the text should remain selectable, restylable, or removable and the target container supports the subtitle codec. Matroska accepts text (SRT/ASS) and bitmap (PGS/DVB) subtitles; MP4 text tracks use `mov_text`, so remuxing SRT into MP4 typically requires `-c:s mov_text`, and bitmap subtitles generally do not fit MP4. Verify with `ffprobe` that the subtitle stream survived.
- **Burn in (`subtitles=` or `ass=` filter)** when the video must render identically in players that ignore subtitle tracks. This requires a build with libass (confirm with `ffmpeg -filters` or `scripts/ffmpeg-preflight --filter subtitles`), decodes and re-encodes the video, and fixes styling at encode time.
Never assume a copied subtitle stream will survive into the target container; probe the output and confirm the intended track count.
## Batch scripting
@@ -65,6 +83,9 @@ https://ffmpeg.org/ffmpeg-protocols.html
https://ffmpeg.org/ffmpeg-utils.html
-> Time expressions and quoting/escaping needed for scripts.
https://ffmpeg.org/ffmpeg-filters.html
-> Subtitle rendering filters, setpts/asetpts, and concat filter normalization requirements.
https://shotstack.io/learn/how-to-use-ffmpeg/
-> Secondary practical examples; verify all commands against current official manuals.
+5 -5
View File
@@ -33,15 +33,15 @@ The local macOS Homebrew FFmpeg 8.1.2 build successfully generated and probed an
FFmpeg becomes predictable when commands are treated as typed pipelines rather than incantations. Most difficult failures occur at boundaries: stream selection, option scope, timestamps, filter availability, codec/container constraints, shell escaping, or hardware/software memory transfer. Make those boundaries explicit and debugging becomes a sequence of observable checks.
SOURCES (LAYER 2 NAVIGATION)
02-analysis/core-model-and-command-anatomy.md
SOURCES (SKILL NAVIGATION)
references/core-model-and-command-anatomy.md
-> Detailed model of containers, streams, codecs, option scope, mapping, and copy/transcode.
02-analysis/filters-and-transformations.md
references/filters-and-transformations.md
-> Filtergraph construction and audio/video transformation boundaries.
02-analysis/intermediate-workflows.md
references/intermediate-workflows.md
-> Inspection, joining, metadata, scripting, and streaming workflows.
02-analysis/advanced-operations-and-safety.md
references/advanced-operations-and-safety.md
-> Hardware acceleration, timestamps, reproducibility, and diagnosis.
+25 -7
View File
@@ -6,15 +6,33 @@
**Version:** FFmpeg 8.1.2, libavutil 60.26.102, libavcodec 62.28.102
**Build evidence:** `ffmpeg -version` reports `--enable-videotoolbox`, `--enable-audiotoolbox`, libx264, libx265, libsvtav1, libvmaf, libopus, libmp3lame, libdav1d, and libvpx.
Every observation below is reproducible with the commands in this document on any build; nothing here relies on an artifact outside this skill. These are host-specific observations from one build, not portable capability claims.
## Inventory
The local build reported 488 filters, 201 encoders, and 2 hardware acceleration methods in the captured inventories. The exact lists are preserved in `local-filters.txt`, `local-encoders.txt`, and `local-hwaccels.txt`. Availability is build-specific: never assume a filter, encoder, protocol, or hardware backend exists just because an online example uses it.
The local build reported 481 filter entries, 192 encoders, and 1 hardware acceleration method (VideoToolbox) from the inventories listed under "Reproduction commands". Counts and contents are build-specific: never assume a filter, encoder, protocol, or hardware backend exists just because an online example uses it. `scripts/ffmpeg-preflight --filter NAME --encoder NAME --hwaccel NAME` answers the same question for a specific build without printing the full inventories.
## Successful experiment
A synthetic 320x180, 30 fps test video and 48 kHz mono sine-wave audio were generated with lavfi and encoded to MP4 using libx264 and native AAC. `ffprobe` verified a 2.00-second MP4 containing H.264 video and AAC audio. The source log is `../03-dossiers/local-probe.json` and the command output is recorded in the run log outside this dossier.
A synthetic 320x180, 30 fps test video and 48 kHz mono sine-wave audio were generated with lavfi and encoded to MP4 using libx264 and native AAC:
The intended follow-up transcode used `-ss 0.5 -t 0.75`, scaling to 160 pixels wide, reducing to 15 fps, and adding `drawtext`. It failed before writing output because this local build reported `No such filter: 'drawtext'`. This is useful evidence: filter names and compiled capabilities must be checked with `ffmpeg -filters` or `ffmpeg -h filter=<name>` before placing them in automation. The failed and successful run logs were preserved in the local study record; their host-specific paths are intentionally omitted here.
```sh
ffmpeg -f lavfi -i 'testsrc2=size=320x180:rate=30' \
-f lavfi -i 'sine=frequency=440:sample_rate=48000' \
-t 2 -c:v libx264 -pix_fmt yuv420p -c:a aac test.mp4
```
`ffprobe -v error -show_format -show_streams -of json test.mp4` verified a 2.00-second MP4 containing H.264 video and AAC audio. The command is deterministic (lavfi sources), so rerunning it reproduces the evidence on any build with libx264 and the AAC encoder.
## Missing-filter experiment
The intended follow-up transcode used `-ss 0.5 -t 0.75`, scaling to 160 pixels wide, reducing to 15 fps, and adding `drawtext`:
```sh
ffmpeg -ss 0.5 -t 0.75 -i test.mp4 -vf 'scale=160:-2,fps=15,drawtext=text=Hi' -c:a copy test-small.mp4
```
It failed before writing output because this local build reports `No such filter: 'drawtext'`. The same build confirms the absence with `ffmpeg -filters | grep -w drawtext` (no match) and `scripts/ffmpeg-preflight --filter drawtext` (reported absent). This is useful evidence: filter names and compiled capabilities must be checked with `ffmpeg -filters`, `ffmpeg -h filter=<name>`, or the preflight before placing them in automation.
## Verification lesson
@@ -24,10 +42,10 @@ A command that looks portable can still fail at the filter-availability boundary
```sh
ffmpeg -version
ffmpeg -filters
ffmpeg -encoders
ffmpeg -hwaccels
ffprobe -v error -show_format -show_streams -of json input.mp4
ffmpeg -hide_banner -filters
ffmpeg -hide_banner -encoders
ffmpeg -hide_banner -hwaccels
ffprobe -v error -show_format -show_streams -of json test.mp4
```
SOURCES (LAYER 3 NAVIGATION)
+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