ffmpeg-skill 2.16.1 → 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 +2 -1
- package/docs/contract.md +3 -3
- package/package.json +1 -1
- package/references/scripts.md +4 -1
- package/scripts/_common/probe.py +36 -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
|
|
@@ -258,7 +259,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
258
259
|
|---|---|
|
|
259
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) |
|
|
260
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 |
|
|
261
|
-
| `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 |
|
|
262
263
|
| `report.py` | Single-file HTML delivery report: before/after sheets, media facts, loudness, compliance, the commands run; `--pack` renders a social pack table |
|
|
263
264
|
|
|
264
265
|
**Orchestration**
|
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
|
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/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"]
|