ffmpeg-skill 3.4.2 → 3.5.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 -2
- package/package.json +1 -1
- package/references/scripts.md +6 -2
- package/scripts/_contract.py +1 -0
- package/scripts/render.py +3 -2
- package/scripts/silence.py +31 -7
package/README.md
CHANGED
|
@@ -587,9 +587,10 @@ Where it can be installed from today. Each route is spelled out below the table.
|
|
|
587
587
|
| Codex plugin | Codex CLI | `codex plugin marketplace add kajisho5/ffmpeg-skill --sparse .claude-plugin`, then `codex plugin add ffmpeg-skill@ffmpeg-skill` |
|
|
588
588
|
| Gemini CLI extension | Gemini CLI | `gemini extensions install https://github.com/kajisho5/ffmpeg-skill` |
|
|
589
589
|
| MCP server | clients without a shell | `npx -y ffmpeg-skill mcp` (see [MCP](#mcp)) |
|
|
590
|
+
| MCP Registry | clients that install from the registry | `io.github.kajisho5/ffmpeg-skill` ([entry](https://registry.modelcontextprotocol.io/v0/servers/io.github.kajisho5%2Fffmpeg-skill/versions/latest)); the release workflow publishes a new version there after npm, and a failed registry step leaves the entry on the previous version |
|
|
590
591
|
| GitHub release | a pinned copy | [releases](https://github.com/kajisho5/ffmpeg-skill/releases) |
|
|
591
592
|
|
|
592
|
-
Not listed in a directory yet: the Claude plugin directory, the Cursor Marketplace, the Gemini CLI extension gallery
|
|
593
|
+
Not listed in a directory yet: the Claude plugin directory, the Cursor Marketplace, the Gemini CLI extension gallery and Glama (each is submitted and waiting on its review or crawl). This table gains a row when a listing goes live; until then the routes above are the whole list.
|
|
593
594
|
|
|
594
595
|
```bash
|
|
595
596
|
npx ffmpeg-skill # Claude Code → ~/.claude/skills/ffmpeg-skill
|
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.5.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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"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"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.5.0", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -752,6 +752,7 @@ silence a tool reported as fine while another measurement disagreed):
|
|
|
752
752
|
| `verification[]` step `duration` | `export.py` (a preset with a platform maximum, without `--allow-long`) | the written file's length against the maximum, measured as `check.py` measures it (the container's duration, `tolerance` 0). A length that cannot be read fails the step with `reason: not_measured` |
|
|
753
753
|
| `verification[]` step `duration` (`property: audio_duration`) | `loudness.py` | the output's sound against the input's, to one AAC frame (at least 0.03 s): a levels pass changes no timing. A length that cannot be read fails the step with `reason: not_measured` |
|
|
754
754
|
| `inputs_duration`, `transition_shortening`; step `duration`'s `inputs_total` | `join.py` | the clips' own total and how much the transitions took out of it. `expected_duration` is derived from the transition the join applied, so it cannot show that on its own |
|
|
755
|
+
| `margins` | `silence.py` | `{lead, tail}`: the seconds of each silence kept before speech starts (`--margin-lead`) and after it ends (`--margin-tail`); both equal `--margin` unless one is named. Absent under `--filler` without `--speech-aware`, which removes no silences |
|
|
755
756
|
| `silence_kinds`, `silence_breakdown` | `silence.py` | per detected silence, `digital` (at or below -90 dBFS) or `below_threshold` (quiet sound under `--threshold`), and the count and seconds of each with the level that defines it |
|
|
756
757
|
|
|
757
758
|
Keys added after 2.21, all additive (#367 follow-up, and a report of a filename with a space that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
package/references/scripts.md
CHANGED
|
@@ -697,7 +697,7 @@ each shorter clip's last frame (with silence) out to the longest.
|
|
|
697
697
|
|
|
698
698
|
### silence.py — remove dead air / jump cuts
|
|
699
699
|
```
|
|
700
|
-
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.2] [--min-keep 0.2] [--list] [--edl keep.txt] [--timemap map.json|map.csv] [--speech-aware] [-o OUT]
|
|
700
|
+
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.2] [--margin-lead S] [--margin-tail S] [--min-keep 0.2] [--list] [--edl keep.txt] [--timemap map.json|map.csv] [--speech-aware] [-o OUT]
|
|
701
701
|
silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|zh|es|de|fr|pt|it|nl|pl|ru|tr]
|
|
702
702
|
[--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
|
|
703
703
|
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
@@ -713,7 +713,11 @@ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
|
713
713
|
|
|
714
714
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
715
715
|
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off). It is kept around
|
|
716
|
-
silences only: a filler word's cut is the word plus `--filler-pad` (since 3.1.6). `--
|
|
716
|
+
silences only: a filler word's cut is the word plus `--filler-pad` (since 3.1.6). `--margin-tail`
|
|
717
|
+
is what is kept after speech ends and `--margin-lead` what is kept before it starts again; each
|
|
718
|
+
defaults to `--margin`, and the result's `margins` gives the two values used. A last syllable that
|
|
719
|
+
is mostly breath wants more tail than the next word wants lead (`--margin-lead 0.1 --margin-tail
|
|
720
|
+
0.4`). `--speech-aware` keeps the short
|
|
717
721
|
breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
|
|
718
722
|
(-25, say) counts a weak word ending as silence: the result then warns.
|
|
719
723
|
|
package/scripts/_contract.py
CHANGED
|
@@ -652,6 +652,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
652
652
|
"beat_grid": {"type": "object", "description": "--beats (1.17): {supported_beats, tempo_bpm, interval, confidence, phase, onsets, supported, unsupported, method, step_s (the envelope's real step: 220/22050 s for --beat-step 0.01, after 3.2.0), range_bpm, usable, onset_refinement (after 3.2.0: pcm-window-ratio when each onset was placed on the decoded samples, else null)}"}}
|
|
653
653
|
elif name == "silence":
|
|
654
654
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
655
|
+
"margins": {"type": "object", "description": "{lead, tail}: the seconds of each silence kept before speech starts (--margin-lead) and after it ends (--margin-tail); both are --margin unless one is named; absent under --filler without --speech-aware, which removes no silences"},
|
|
655
656
|
"removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
|
|
656
657
|
"cut_points": {"type": ["array", "null"], "description": "2.22: per cut {source_time, output_time, edge (out|in), rms_dbfs (the loudest channel 50 ms either side on the source; null for digital silence), filler (true on a removed filler word's edge)}; null when the source could not be decoded for it (cut_levels_unmeasured warning)"},
|
|
657
658
|
"cut_warnings": {"type": ["array", "null"], "description": "the cut_points (not filler cuts) within 10 dB of --threshold: where to listen (a cut_near_sound warning); verified is unchanged"},
|
package/scripts/render.py
CHANGED
|
@@ -134,7 +134,7 @@ OBJECT_KEYS: Dict[str, frozenset] = {
|
|
|
134
134
|
"snap": frozenset({"to", "tolerance", "min_confidence", "source"}),
|
|
135
135
|
"frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
|
|
136
136
|
"transition": frozenset({"type", "duration"}),
|
|
137
|
-
"silence": frozenset({"threshold", "min_silence", "margin"}),
|
|
137
|
+
"silence": frozenset({"threshold", "min_silence", "margin", "margin_lead", "margin_tail"}),
|
|
138
138
|
# 1.16: the picture an audio-only source gets before the rest of the chain can work on it
|
|
139
139
|
"audiogram": frozenset({"image", "image_fit", "style", "position", "vis_height", "opacity",
|
|
140
140
|
"platform", "title", "color", "background", "width", "height", "fps"}),
|
|
@@ -1428,7 +1428,8 @@ def main() -> int:
|
|
|
1428
1428
|
if sil:
|
|
1429
1429
|
nxt = str(work / f"tight{mid}")
|
|
1430
1430
|
argv = [current, "-o", nxt]
|
|
1431
|
-
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")
|
|
1431
|
+
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
|
|
1432
|
+
("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
|
|
1432
1433
|
if sil.get(k) is not None:
|
|
1433
1434
|
argv += [flag, str(sil[k])]
|
|
1434
1435
|
sh("silence.py", *argv, stage="silence")
|
package/scripts/silence.py
CHANGED
|
@@ -9,6 +9,7 @@ cut.py --segments --accurate cuts them (both streams on one set of frame-grid bo
|
|
|
9
9
|
Examples:
|
|
10
10
|
python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.2 s margin
|
|
11
11
|
python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
|
|
12
|
+
python3 silence.py talk.mp4 --margin-lead 0.1 --margin-tail 0.4 # less before a word, more after one
|
|
12
13
|
python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
|
|
13
14
|
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
14
15
|
"""
|
|
@@ -83,9 +84,12 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
|
|
|
83
84
|
return boundaries, breaths
|
|
84
85
|
|
|
85
86
|
|
|
86
|
-
def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float,
|
|
88
|
+
lead: Optional[float] = None) -> List[Tuple[float, float]]:
|
|
89
|
+
"""Each silence with a margin kept on both sides: the stretch actually removed. `margin` is
|
|
90
|
+
kept after the speech that ends where the silence starts (--margin-tail), `lead` before the
|
|
91
|
+
speech that starts where it ends (--margin-lead; the same as `margin` when not given). A
|
|
92
|
+
silence too short to hold both removes nothing (it is dropped, never turned inside out).
|
|
89
93
|
|
|
90
94
|
The margin is applied to the silences alone, before they are merged with filler spans (#414):
|
|
91
95
|
a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
|
|
@@ -94,7 +98,7 @@ def inside_margin(silences: List[Tuple[float, float]], duration: float, margin:
|
|
|
94
98
|
out: List[Tuple[float, float]] = []
|
|
95
99
|
for s, e in silences:
|
|
96
100
|
a = s + margin
|
|
97
|
-
b = duration if e == float("inf") else min(duration, e - margin)
|
|
101
|
+
b = duration if e == float("inf") else min(duration, e - (margin if lead is None else lead))
|
|
98
102
|
if b > a:
|
|
99
103
|
out.append((a, b))
|
|
100
104
|
return out
|
|
@@ -455,6 +459,13 @@ def main() -> int:
|
|
|
455
459
|
ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
|
|
456
460
|
ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
|
|
457
461
|
ap.add_argument("--margin", type=float, default=0.2, help="seconds of silence to keep on each side of speech (default 0.2; 0.15 before 2.22)")
|
|
462
|
+
ap.add_argument("--margin-lead", type=float, default=None, metavar="SECONDS",
|
|
463
|
+
help="seconds of silence to keep before speech starts, when it should differ from --margin "
|
|
464
|
+
"(default: --margin)")
|
|
465
|
+
ap.add_argument("--margin-tail", type=float, default=None, metavar="SECONDS",
|
|
466
|
+
help="seconds of silence to keep after speech ends, when it should differ from --margin "
|
|
467
|
+
"(default: --margin); a speaker whose last syllable trails off needs more here than "
|
|
468
|
+
"before the next word")
|
|
458
469
|
ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
|
|
459
470
|
ap.add_argument("--speech-aware", action="store_true",
|
|
460
471
|
help="keep breaths shorter than --min-silence when they sit inside a sentence "
|
|
@@ -525,6 +536,13 @@ def main() -> int:
|
|
|
525
536
|
if not math.isfinite(args.margin) or args.margin < 0:
|
|
526
537
|
# nan made inside_margin() drop every silence and re-encode the whole file unchanged
|
|
527
538
|
die(f"--margin must be a finite number of seconds, 0 or more (got {args.margin:g})", kind="input")
|
|
539
|
+
for flag, value in (("--margin-lead", args.margin_lead), ("--margin-tail", args.margin_tail)):
|
|
540
|
+
if value is not None and (not math.isfinite(value) or value < 0):
|
|
541
|
+
die(f"{flag} must be a finite number of seconds, 0 or more (got {value:g})", kind="input")
|
|
542
|
+
# what is kept after speech ends and before it starts again; --margin is the value of both
|
|
543
|
+
# unless one is named
|
|
544
|
+
tail = args.margin if args.margin_tail is None else args.margin_tail
|
|
545
|
+
lead = args.margin if args.margin_lead is None else args.margin_lead
|
|
528
546
|
if args.filler_pad < 0:
|
|
529
547
|
die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
|
|
530
548
|
"(end before start) and the span is then silently dropped, so nothing is removed",
|
|
@@ -549,7 +567,7 @@ def main() -> int:
|
|
|
549
567
|
# time-range removal, so it reuses keep_ranges() and the same select chain.
|
|
550
568
|
# --margin is kept around the silences only (inside_margin), not around filler spans, whose
|
|
551
569
|
# own trim is --filler-pad (#414); the merged list then goes through with no further margin.
|
|
552
|
-
removals = merge_spans(inside_margin(silences, duration,
|
|
570
|
+
removals = merge_spans(inside_margin(silences, duration, tail, lead) + list(filler_ranges))
|
|
553
571
|
keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
|
|
554
572
|
# what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
|
|
555
573
|
# and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
|
|
@@ -564,7 +582,7 @@ def main() -> int:
|
|
|
564
582
|
# the silences alone; everything removed is `removed_seconds_total`.
|
|
565
583
|
silence_only = removed
|
|
566
584
|
if args.filler:
|
|
567
|
-
silence_keeps = keep_ranges(merge_spans(inside_margin(silences, duration,
|
|
585
|
+
silence_keeps = keep_ranges(merge_spans(inside_margin(silences, duration, tail, lead)), duration, 0.0, args.min_keep)
|
|
568
586
|
silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
|
|
569
587
|
kinds = silence_kinds(args.input, silences, duration, args.threshold)
|
|
570
588
|
summary = {
|
|
@@ -574,6 +592,11 @@ def main() -> int:
|
|
|
574
592
|
"kept_duration": round(kept, 3),
|
|
575
593
|
"removed_seconds": round(silence_only, 3),
|
|
576
594
|
}
|
|
595
|
+
if silences or not args.filler or args.speech_aware:
|
|
596
|
+
# the seconds of each silence this run kept: `tail` from where the silence starts (after
|
|
597
|
+
# the speech before it), `lead` up to where it ends (before the speech after it). Absent
|
|
598
|
+
# under --filler alone, which detects no silences and so applies neither.
|
|
599
|
+
summary["margins"] = {"lead": lead, "tail": tail}
|
|
577
600
|
if kinds:
|
|
578
601
|
summary.update(kinds)
|
|
579
602
|
if args.speech_aware:
|
|
@@ -641,7 +664,8 @@ def main() -> int:
|
|
|
641
664
|
if near:
|
|
642
665
|
where = ", ".join("%.2fs" % c["source_time"] for c in near[:5]) + (", ..." if len(near) > 5 else "")
|
|
643
666
|
warn(f"{len(near)} cut point(s) sit within {CUT_SOUND_MARGIN_DB:g} dB of --threshold ({where}): "
|
|
644
|
-
"a breath or a word's weak tail may be cut; listen there, or lower --threshold / raise --margin"
|
|
667
|
+
"a breath or a word's weak tail may be cut; listen there, or lower --threshold / raise --margin "
|
|
668
|
+
"(--margin-tail for an `out` edge, --margin-lead for an `in` edge)",
|
|
645
669
|
"cut_near_sound", cuts=len(near))
|
|
646
670
|
|
|
647
671
|
def finish_time_map() -> None:
|