mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user