diff --git a/ffmpeg/README.md b/ffmpeg/README.md index 8959e5a..b59bb1a 100644 --- a/ffmpeg/README.md +++ b/ffmpeg/README.md @@ -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: diff --git a/ffmpeg/SKILL.md b/ffmpeg/SKILL.md index 6c3113d..19f4eaf 100644 --- a/ffmpeg/SKILL.md +++ b/ffmpeg/SKILL.md @@ -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 diff --git a/ffmpeg/evals/evals.json b/ffmpeg/evals/evals.json index df9013b..77fc1d3 100644 --- a/ffmpeg/evals/evals.json +++ b/ffmpeg/evals/evals.json @@ -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?", diff --git a/ffmpeg/references/advanced-operations-and-safety.md b/ffmpeg/references/advanced-operations-and-safety.md index 1fd4e8d..6c8d6f7 100644 --- a/ffmpeg/references/advanced-operations-and-safety.md +++ b/ffmpeg/references/advanced-operations-and-safety.md @@ -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. diff --git a/ffmpeg/references/command-cookbook.md b/ffmpeg/references/command-cookbook.md index fac616d..7443ddf 100644 --- a/ffmpeg/references/command-cookbook.md +++ b/ffmpeg/references/command-cookbook.md @@ -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. diff --git a/ffmpeg/references/filters-and-transformations.md b/ffmpeg/references/filters-and-transformations.md index 808469d..f9e2744 100644 --- a/ffmpeg/references/filters-and-transformations.md +++ b/ffmpeg/references/filters-and-transformations.md @@ -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. diff --git a/ffmpeg/references/intermediate-workflows.md b/ffmpeg/references/intermediate-workflows.md index 42753d5..520de20 100644 --- a/ffmpeg/references/intermediate-workflows.md +++ b/ffmpeg/references/intermediate-workflows.md @@ -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. diff --git a/ffmpeg/references/learning-summary.md b/ffmpeg/references/learning-summary.md index b3ffcb4..9e77007 100644 --- a/ffmpeg/references/learning-summary.md +++ b/ffmpeg/references/learning-summary.md @@ -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. diff --git a/ffmpeg/references/local-verification.md b/ffmpeg/references/local-verification.md index 2224cf7..2b2bd8b 100644 --- a/ffmpeg/references/local-verification.md +++ b/ffmpeg/references/local-verification.md @@ -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=` 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=`, 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) diff --git a/ffmpeg/scripts/ffmpeg-preflight b/ffmpeg/scripts/ffmpeg-preflight index f72bea5..e6bc388 100755 --- a/ffmpeg/scripts/ffmpeg-preflight +++ b/ffmpeg/scripts/ffmpeg-preflight @@ -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__": diff --git a/ffmpeg/scripts/test_ffmpeg_preflight.py b/ffmpeg/scripts/test_ffmpeg_preflight.py index dee91f4..97c1002 100644 --- a/ffmpeg/scripts/test_ffmpeg_preflight.py +++ b/ffmpeg/scripts/test_ffmpeg_preflight.py @@ -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