ffmpeg-skill 2.16.1 → 2.17.1

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 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**
@@ -496,13 +497,15 @@ npx ffmpeg-skill --dir ./my-skills
496
497
  npx ffmpeg-skill --uninstall # remove from the selected targets (--codex also clears the older ~/.codex/skills location)
497
498
  ```
498
499
 
499
- As a Claude Code plugin (no Node needed, updates with `claude plugin update`):
500
+ As a Claude Code plugin (no Node needed, updates with `claude plugin update`). This repository is its own plugin marketplace:
500
501
 
501
502
  ```bash
502
- claude plugin install kajisho5/ffmpeg-skill
503
+ /plugin install ffmpeg-skill --marketplace kajisho5/ffmpeg-skill # in Claude Code 2.1.275 or later
504
+ claude plugin marketplace add kajisho5/ffmpeg-skill # older versions: add the marketplace,
505
+ claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
503
506
  ```
504
507
 
505
- The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`; the manifest is [.claude-plugin/plugin.json](.claude-plugin/plugin.json) and its version follows every release automatically.
508
+ The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
506
509
 
507
510
  Without Node: clone this repository and copy `SKILL.md`, `scripts/`, `references/` and `mcp/` into your agent's skills directory.
508
511
 
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.16.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.17.1`) | 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.16.1", "execution_mode": "local", "kind": "execution",
134
+ "skill": {"id": "ffmpeg-skill", "version": "2.17.1", "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.16.1",
3
+ "version": "2.17.1",
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",
@@ -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%). A render project enables it with `"check": {"platform":
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
@@ -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 three content rows from one decode pass: black (share of the
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%). Without it the row set is unchanged.
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 silence rows")
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"]