ffmpeg-skill 3.4.3 → 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 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, the MCP Registry and Glama. This table gains a row when a listing goes live; until then the routes above are the whole list.
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.4.3`) | any release |
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.4.3", "execution_mode": "local", "kind": "execution",
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.4.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": [
@@ -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). `--speech-aware` keeps the short
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
 
@@ -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")
@@ -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) -> List[Tuple[float, float]]:
87
- """Each silence with --margin kept on both sides: the stretch actually removed. A silence too
88
- short to hold two margins removes nothing (it is dropped, never turned inside out).
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, args.margin) + list(filler_ranges))
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, args.margin)), duration, 0.0, args.min_keep)
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: