ffmpeg-skill 2.16.0 → 2.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/docs/contract.md +4 -3
- package/package.json +1 -1
- package/references/scripts.md +4 -1
- package/scripts/_common/probe.py +36 -0
- package/scripts/_contract.py +3 -0
- package/scripts/check.py +13 -3
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ No cloud · No API keys · Python standard library</p>
|
|
|
29
29
|
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
|
|
30
30
|
<a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-5.1%20%7C%206.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 5.1, 6.1, 7.1, 8 and 9 tested in CI"></a>
|
|
31
31
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
32
|
+
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
32
33
|
</p>
|
|
33
34
|
|
|
34
35
|
```bash
|
|
@@ -71,6 +72,7 @@ If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you wo
|
|
|
71
72
|
| Cut / join / measure / export a file right now | **this repo** (`ffmpeg-skill`), standalone |
|
|
72
73
|
| Decide cut points, approve a deliverable, plan a whole edit | [`video-production-agent`](https://github.com/kajisho5/video-production-agent) / [`AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS) |
|
|
73
74
|
| Build a typed editing graph across a workspace, without writing raw `ffmpeg` | [`video-editing-skill`](https://github.com/kajisho5/video-editing-skill) / [`audio-production-skill`](https://github.com/kajisho5/audio-production-skill) |
|
|
75
|
+
| Put Remotion motion graphics (animated captions, silence jump cuts, audio-peak motion) on a real recording | [`remotion-ffmpeg-skill`](https://github.com/kajisho5/remotion-ffmpeg-skill): its compositions take their length, fps, frame, silences, scene cuts, audio peaks and beats from this repo's `probe.py`, `silence.py` and `scenes.py`. It verifies its render with `check.py` and `look.py`, composites a graphics layer with `overlay.py` (`--place` for its `--sparse` mode), and delivers through `render.py --template` |
|
|
74
76
|
|
|
75
77
|
Other repos in the ecosystem — [`media-analysis-skill`](https://github.com/kajisho5/media-analysis-skill), [`transcription-skill`](https://github.com/kajisho5/transcription-skill), [`subtitle-skill`](https://github.com/kajisho5/subtitle-skill), [`thumbnail-skill`](https://github.com/kajisho5/thumbnail-skill), [`color-grading-skill`](https://github.com/kajisho5/color-grading-skill), [`motion-graphics-skill`](https://github.com/kajisho5/motion-graphics-skill), [`qc-skill`](https://github.com/kajisho5/qc-skill) — read this repo's `contract --json`, its tools' `--json` output and `doctor`, the same way any agent framework would; this repo does not call into any of them. The dependency runs one way.
|
|
76
78
|
|
|
@@ -257,7 +259,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
257
259
|
|---|---|
|
|
258
260
|
| `export.py` | Presets `youtube`, `youtube4k`, `reels`, `tiktok`, `shorts`, `linkedin`, `facebook`, `x`, `youtube-hdr` (HEVC Main10, source HDR tags kept), `youtube-av1`, `prores`, `h265`, `gif`, `copy`, all tagged BT.709 unless they carry HDR (a BT.601-matrix or RGB source is converted to BT.709, not relabelled); `--normalize` meets the platform's loudness in the same call (`render.py` turns it on by default for platform presets) |
|
|
259
261
|
| `proxy.py` | Small, low-bitrate proxy for downstream AI analysis/preview/editing decisions — resize by `--width`/`--scale`, proxy-grade `--quality` (default 30), `--fps`, `--no-audio`; not a delivery preset |
|
|
260
|
-
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row |
|
|
262
|
+
| `check.py` | PASS / WARN / FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, Facebook, broadcast and podcast specs, from the same delivery table the export presets and templates read (podcast also reports chapter markers and channel count), with the fix for each failure and a `format` / `judgement` kind per row; `--content` adds black-frame, frozen-frame, silence and short audio-dropout rows |
|
|
261
263
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
|
262
264
|
|
|
263
265
|
**Orchestration**
|
|
@@ -400,7 +402,7 @@ Each operation is a script whose flags are its argparse parser, and that parser
|
|
|
400
402
|
|
|
401
403
|
### How does ffmpeg-skill compare with Remotion?
|
|
402
404
|
|
|
403
|
-
ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
|
|
405
|
+
ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). [`remotion-ffmpeg-skill`](https://github.com/kajisho5/remotion-ffmpeg-skill) joins the two. It measures the footage with this repo, draws the graphics in Remotion from those measurements, and checks and delivers the result with this repo again. Install both (`npx ffmpeg-skill`, then `npx remotion-ffmpeg-skill`). As with the other skills, the dependency runs one way: it calls these tools, and nothing here calls it. HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
|
|
404
406
|
|
|
405
407
|
### Does it validate the filter graph before running?
|
|
406
408
|
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.17.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.17.0", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
|
@@ -205,7 +205,7 @@ rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
|
|
|
205
205
|
The `audio` row FAILs (WARN when no loudness target applies) when the track is present but silent (peak at or below -50 dBFS, from
|
|
206
206
|
the loudness pass or, when loudness is skipped, one volumedetect pass). `check --content`
|
|
207
207
|
(opt-in; render.py `"check": {"content": true}`) adds `black`, `frozen` and `silence` rows
|
|
208
|
-
from one decode pass; without it the row set is unchanged. Thresholds:
|
|
208
|
+
from one decode pass, and `dropouts` (2.17) from one audio-only pass; without it the row set is unchanged. Thresholds:
|
|
209
209
|
`docs/design-decisions.md` ("check.py --content").
|
|
210
210
|
|
|
211
211
|
### Dry run
|
|
@@ -643,6 +643,7 @@ Per-tool keys added in 1.17, all additive:
|
|
|
643
643
|
| `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
|
|
644
644
|
| `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
|
|
645
645
|
| `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
|
|
646
|
+
| `intermediate_preset` | `render.py` | The x264/x265 preset the stages before an encoding export ran at: `--intermediate-preset`, default `superfast` since 2.16 (`veryfast` in 2.15). `null` when no stage received it: no encoding export was reached, `--fast`, `--stop-after` before the export, a `copy` export, or nothing but the export encoded. Present on every render result, early stops included |
|
|
646
647
|
|
|
647
648
|
Per-tool keys added in 1.17.1, all additive:
|
|
648
649
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -1042,7 +1042,10 @@ pass when `--no-loudness` (or a spec with no loudness target) skips it.
|
|
|
1042
1042
|
`--content` (opt-in, one decode pass) adds three rows: `black` (share of the duration that is
|
|
1043
1043
|
black: WARN > 10%, FAIL >= 95%), `frozen` (longest frozen span: WARN > max(3 s, 30% of the
|
|
1044
1044
|
duration), FAIL when it covers >= 95%, i.e. the whole video) and `silence` (share below
|
|
1045
|
-
-50 dBFS: WARN > 50%, FAIL >= 95%).
|
|
1045
|
+
-50 dBFS: WARN > 50%, FAIL >= 95%); since 2.17 one more audio-only pass adds `dropouts`
|
|
1046
|
+
(gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides -- lost audio
|
|
1047
|
+
at a join, a glitched decode; WARN when there is any, never FAIL, because a noise gate makes
|
|
1048
|
+
them on purpose). A render project enables it with `"check": {"platform":
|
|
1046
1049
|
"reels", "content": true}`.
|
|
1047
1050
|
|
|
1048
1051
|
### batch.py — same recipe over a folder, cached
|
package/scripts/_common/probe.py
CHANGED
|
@@ -569,6 +569,14 @@ CONTENT_MIN_SPAN = 0.1 # blackdetect minimum run, seconds
|
|
|
569
569
|
CONTENT_FREEZE_NOISE = "-60dB" # freezedetect noise tolerance
|
|
570
570
|
CONTENT_FREEZE_MIN = 0.5 # freezedetect minimum run, seconds
|
|
571
571
|
CONTENT_SILENCE_MIN = 0.5 # silencedetect minimum run, seconds
|
|
572
|
+
# 2.17: a dropout is a short run of (near) digital silence with sound on both sides -- a join
|
|
573
|
+
# that lost its audio, a glitched decode. Far quieter than the silence row's -50 dB, so a
|
|
574
|
+
# recording's own pauses (room tone) do not count; a noise gate does produce them, which is
|
|
575
|
+
# why the row only ever WARNs.
|
|
576
|
+
CONTENT_DROPOUT_DB = -80.0
|
|
577
|
+
CONTENT_DROPOUT_MIN, CONTENT_DROPOUT_MAX = 0.02, 1.0 # seconds
|
|
578
|
+
CONTENT_DROPOUT_EDGE = 0.05 # a gap this close to the start or end is a lead-in/out, not a dropout
|
|
579
|
+
CONTENT_DROPOUT_PAD = 2.0 # zeros appended before the dropout pass; > MAX, so a tail that runs into it never counts
|
|
572
580
|
|
|
573
581
|
_BLACK_RE = re.compile(r"black_start:\s*([0-9.]+)\s+black_end:\s*([0-9.]+)")
|
|
574
582
|
_FREEZE_RE = re.compile(r"lavfi\.freezedetect\.freeze_(start|end):\s*([0-9.]+)")
|
|
@@ -618,9 +626,37 @@ def detect_content(path: str, duration: float, *, video: bool = True, audio: boo
|
|
|
618
626
|
if start is not None:
|
|
619
627
|
sil.append((start, end))
|
|
620
628
|
out["silence"] = sil
|
|
629
|
+
out["dropouts"] = _detect_dropouts(path, end)
|
|
621
630
|
return out
|
|
622
631
|
|
|
623
632
|
|
|
633
|
+
def _detect_dropouts(path: str, end: float) -> "List[Tuple[float, float]]":
|
|
634
|
+
"""Short (near) digital-silence gaps inside the first audio stream: one audio-only pass
|
|
635
|
+
(silencedetect at CONTENT_DROPOUT_DB). A second silencedetect in the main pass would log
|
|
636
|
+
under the same name as the silence row's, so its lines could not be told apart. The audio
|
|
637
|
+
is padded with CONTENT_DROPOUT_PAD s of zeros first: a silent tail of audio that ends before
|
|
638
|
+
the container does then runs into the pad and is too long to count, instead of closing at
|
|
639
|
+
the audio's EOF and passing the container-duration edge test."""
|
|
640
|
+
proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-i", path, "-map", "0:a:0", "-af",
|
|
641
|
+
f"apad=pad_dur={CONTENT_DROPOUT_PAD:g},"
|
|
642
|
+
f"silencedetect=noise={CONTENT_DROPOUT_DB:g}dB:d={CONTENT_DROPOUT_MIN}", "-f", "null", "-"],
|
|
643
|
+
check=False, record=True)
|
|
644
|
+
if proc.returncode != 0:
|
|
645
|
+
die(f"dropout detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
|
|
646
|
+
gaps: "List[Tuple[float, float]]" = []
|
|
647
|
+
start = None
|
|
648
|
+
for kind, val in SIL_RE.findall(proc.stderr):
|
|
649
|
+
if kind == "start":
|
|
650
|
+
start = float(val)
|
|
651
|
+
elif start is not None:
|
|
652
|
+
s, e = start, float(val)
|
|
653
|
+
start = None
|
|
654
|
+
inside = s > CONTENT_DROPOUT_EDGE and (not end or e < end - CONTENT_DROPOUT_EDGE)
|
|
655
|
+
if inside and CONTENT_DROPOUT_MIN <= e - s <= CONTENT_DROPOUT_MAX:
|
|
656
|
+
gaps.append((s, e))
|
|
657
|
+
return gaps # a gap still open at the end is a tail, never a dropout
|
|
658
|
+
|
|
659
|
+
|
|
624
660
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
625
661
|
|
|
626
662
|
|
package/scripts/_contract.py
CHANGED
|
@@ -576,6 +576,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
576
576
|
elif name == "render":
|
|
577
577
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
|
|
578
578
|
"cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
579
|
+
"caption": {"type": ["object", "null"], "description": "the caption stage's own block, forwarded verbatim from caption.py (cue-layout counts and fit-size keys); null without a caption stage"},
|
|
580
|
+
"snap": {"type": ["object", "null"], "description": "a project's \"snap\": cut.py's snap block for the first snapped clip, with every clip's in clips; null when nothing was snapped"},
|
|
581
|
+
"intermediate_preset": {"type": ["string", "null"], "description": "2.15: the x264/x265 preset the stages before an encoding export ran at (--intermediate-preset, default superfast since 2.16); null when no stage received it (no encoding export reached, --fast, --stop-after before the export, a copy export, or nothing but the export encoded)"},
|
|
579
582
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "each stage's own notes, prefixed with its tool (\"export: loudness ... is outside x's ...\")"},
|
|
580
583
|
"plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
|
|
581
584
|
"plan_sha256": {"type": ["string", "null"], "description": "plan execution: sha256 of the plan file as executed"},
|
package/scripts/check.py
CHANGED
|
@@ -20,10 +20,12 @@ Examples:
|
|
|
20
20
|
python3 check.py clip.mp4 --platform custom --max-duration 30 --aspect 1:1 --lufs -16
|
|
21
21
|
python3 check.py final.mp4 --platform reels --content
|
|
22
22
|
|
|
23
|
-
--content (opt-in) adds
|
|
23
|
+
--content (opt-in) adds content rows from one decode pass: black (share of the
|
|
24
24
|
duration that is black: WARN > 10%, FAIL >= 95%), frozen (longest frozen span: WARN >
|
|
25
25
|
max(3 s, 30% of the duration), FAIL when the whole video is frozen) and silence (share
|
|
26
|
-
below -50 dBFS: WARN > 50%, FAIL >= 95%)
|
|
26
|
+
below -50 dBFS: WARN > 50%, FAIL >= 95%); and, from one more audio-only pass (2.17),
|
|
27
|
+
dropouts (gaps of digital silence, <= -80 dBFS for 20 ms to 1 s with sound on both sides:
|
|
28
|
+
WARN when there is any). Without it the row set is unchanged.
|
|
27
29
|
"""
|
|
28
30
|
import argparse
|
|
29
31
|
import json
|
|
@@ -89,7 +91,7 @@ def main() -> int:
|
|
|
89
91
|
ap.add_argument("--max-mb", type=float, help="override max file size in MB")
|
|
90
92
|
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
91
93
|
ap.add_argument("--content", action="store_true",
|
|
92
|
-
help="also check the content (one decode pass): black, frozen and
|
|
94
|
+
help="also check the content (one decode pass, plus an audio-only one): black, frozen, silence and dropouts rows")
|
|
93
95
|
add_common(ap)
|
|
94
96
|
args = ap.parse_args()
|
|
95
97
|
apply_common(args)
|
|
@@ -288,6 +290,14 @@ def main() -> int:
|
|
|
288
290
|
f"{share * 100:.0f}% silent", f"<= {SILENCE_WARN * 100:.0f}% below {SILENT_PEAK_DBFS:g} dBFS",
|
|
289
291
|
"silence.py to cut the gaps, or audio.py --replace when the track is empty",
|
|
290
292
|
reason="most of the running time has no audible sound")
|
|
293
|
+
if "dropouts" in cd:
|
|
294
|
+
gaps = cd["dropouts"]
|
|
295
|
+
at = ", ".join(f"{s:.2f}s ({(e - s) * 1000:.0f} ms)" for s, e in gaps[:5]) + (" ..." if len(gaps) > 5 else "")
|
|
296
|
+
row("dropouts", "WARN" if gaps else "PASS", f"{len(gaps)}" + (f": {at}" if gaps else ""),
|
|
297
|
+
"no digital-silence gap under 1 s with sound on both sides",
|
|
298
|
+
"listen at those times: a gap at a cut or join is lost audio (re-cut with cut.py --accurate, or join.py "
|
|
299
|
+
"again); a noise gate makes these on purpose" if gaps else "",
|
|
300
|
+
reason="a short stretch of digital silence in the middle of the sound is usually lost audio at a join or a glitched decode" if gaps else "")
|
|
291
301
|
|
|
292
302
|
failed = [r for r in rows if r["status"] == "FAIL"]
|
|
293
303
|
warned = [r for r in rows if r["status"] == "WARN"]
|