ffmpeg-skill 2.3.2 → 2.4.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/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.3.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.4.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`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.3.2", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.4.1", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -521,7 +521,7 @@ Per-tool keys added after 2.2.2, all additive:
521
521
  | key | tool | what it holds |
522
522
  |---|---|---|
523
523
  | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
524
- | `silent` | `join.py` (every run) | `[{index, path, peak_db}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
524
+ | `silent` | `join.py` (every run) | `[{index, path, peak_db, at, end}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `at`/`end` are where the input sits in the output, in seconds (a transition overlaps its neighbours). An input named by `--allow-silent N[,N]` (1-based) is a planned pause: always joined, listed with `intended: true`, no warning or note, whatever `--on-silent` says. `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
525
525
  | `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
526
526
  | `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
527
527
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.3.2",
3
+ "version": "2.4.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",
@@ -560,7 +560,7 @@ writes the resulting cut list exactly as it does today.
560
560
  ```
561
561
  join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
562
562
  [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
563
- join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--silence-threshold -50] [...]
563
+ join.py --list parts.txt [--on-missing fail|skip] [--on-silent warn|fail|skip] [--allow-silent N[,N]] [--silence-threshold -50] [...]
564
564
  ```
565
565
  `--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
566
566
  file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
@@ -579,9 +579,11 @@ frame, rate or xfade offsets that rest on a pending input's unmeasured placehold
579
579
  Every existing input with an audio stream is also measured (one volumedetect pass; not under `--dry-run`
580
580
  too): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
581
581
  call that wrote a valid but empty file. `--on-silent warn` (default) joins it and names it under
582
- `silent: [{index, path, peak_db}]` and in `notes`; `fail` refuses it in the same `kind: input`
583
- document (`silent (peak -91.0 dBFS)`); `skip` leaves it out under `skipped`. A clip without an
584
- audio stream is never silent.
582
+ `silent: [{index, path, peak_db, at, end}]` (`at`/`end`: where it sits in the output, seconds)
583
+ and in `notes`; `fail` refuses it in the same `kind: input` document (`silent (peak -91.0 dBFS)`);
584
+ `skip` leaves it out under `skipped`. A pause that is meant to be silent: `--allow-silent 3,7`
585
+ (1-based) joins those inputs whatever `--on-silent` says and lists them with `intended: true`,
586
+ no warning. A clip without an audio stream is never silent.
585
587
  Two warnings never refuse or change the command: `short_segments: [{index, path, duration}]`
586
588
  names a clip shorter than 2 frames at the join's fps (audio-only: 0.05 s), and
587
589
  `duplicates: [{path, indices}]` a path listed more than once (a repeat can be intended); both
@@ -589,10 +591,10 @@ are always present (`[]` when none) and noted.
589
591
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
590
592
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
591
593
  silent track generated for clips without audio), then chains `xfade` +
592
- `acrossfade`. With a transition each clip has one length for picture and sound
594
+ `acrossfade` (or `concat` for `--transition none`). Every clip has one length for picture and sound
593
595
  -- the video stream's, or the audio's when it runs more than a frame longer --
594
596
  and the shorter stream is padded (silence / last frame held), so every clip's
595
- sound stays with its picture. Output length = sum of clip lengths −
597
+ sound stays with its picture and a plain cut leaves no hole in the video. Output length = sum of clip lengths −
596
598
  transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
597
599
  longer than 2 × the transition. Use `--transition none` for a plain cut.
598
600
  A subtitle/data track in the source is not carried into the retimed/concatenated
@@ -464,7 +464,7 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
464
464
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
465
465
  "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
466
466
  "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
467
- "silent": {"type": "array", "description": "inputs whose audio peak is at or below --silence-threshold (default -50 dBFS) and that --on-silent warn (default) joined anyway: [{index, path, peak_db}] ([] otherwise; fail refuses them under problems, skip lists them under skipped); an input with no audio stream is never silent"},
467
+ "silent": {"type": "array", "description": "inputs whose audio peak is at or below --silence-threshold (default -50 dBFS) and that --on-silent warn (default) joined anyway, plus every --allow-silent input that was silent: [{index, path, peak_db, at, end, intended?}]; at/end are the input's start and end in output seconds (a transition overlaps neighbours), intended: true marks an --allow-silent input (joined whatever --on-silent says, no warning). [] otherwise; fail refuses unallowed ones under problems, skip lists them under skipped; an input with no audio stream is never silent"},
468
468
  "short_segments": {"type": "array", "description": "warning, never a refusal: measured inputs shorter than 2 frames at the join's fps (audio-only join: shorter than 0.05 s), [{index, path, duration}] ([] when none), each also in notes; a pending --dry-run input is not measured"},
469
469
  "duplicates": {"type": "array", "description": "warning, never a refusal (a repeat can be intended): a path (compared resolved) listed more than once and joined each time, [{path, indices}] with 0-based list positions ([] when none), also in notes"},
470
470
  "pending": {"type": "array", "description": "--dry-run: inputs that do not exist yet, planned on as an earlier step's output and never also under skipped: [{index, path}] ([] otherwise)"},
package/scripts/join.py CHANGED
@@ -81,7 +81,7 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
81
81
 
82
82
 
83
83
  def clip_length(meta: Dict[str, Any], fps: float) -> float:
84
- """The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
84
+ """The one length a clip holds in a join, for its picture and its sound alike: the
85
85
  video stream's, or the audio stream's when the sound runs more than a frame past the picture
86
86
  (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
87
87
  a longer track) is only the fallback when a stream length is unknown (a pending input)."""
@@ -127,6 +127,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
127
127
  n = len(args.inputs)
128
128
  durs = [m.get("duration") or 0.0 for m in metas]
129
129
  d = args.duration if args.transition != "none" else 0.0
130
+ place_silent(durs, d)
130
131
  note_short_segments(args.inputs, metas, MIN_AUDIO_SEGMENT, f"{MIN_AUDIO_SEGMENT:g}s")
131
132
  for p, dur in zip(args.inputs, durs):
132
133
  if d and dur <= d * 2 and not STATE.dry_run:
@@ -258,6 +259,32 @@ def find_silent(paths: List[str], audible: List[int], threshold: float) -> List[
258
259
  return silent
259
260
 
260
261
 
262
+ def place_silent(lens: List[float], d: float) -> None:
263
+ """Where each joined silent input sits in the output: `at`/`end` in output seconds. Input k
264
+ (in kept order) starts after the kept inputs before it, each overlapped by the transition d."""
265
+ for sil in STATE_SILENT:
266
+ if sil["index"] not in STATE_KEPT:
267
+ continue
268
+ k = STATE_KEPT.index(sil["index"])
269
+ if k >= len(lens):
270
+ continue
271
+ at = sum(lens[:k]) - k * d
272
+ sil["at"], sil["end"] = round(at, 3), round(at + lens[k], 3)
273
+
274
+
275
+ def parse_allow_silent(value: Optional[str], n: int) -> List[int]:
276
+ """--allow-silent 3,7 -> 0-based indices; each must name an input (1..n)."""
277
+ if not value:
278
+ return []
279
+ out = []
280
+ for tok in value.split(","):
281
+ tok = tok.strip()
282
+ if not tok.isdigit() or not 1 <= int(tok) <= n:
283
+ die(f"--allow-silent takes input numbers 1..{n} separated by commas, got {tok!r}")
284
+ out.append(int(tok) - 1)
285
+ return out
286
+
287
+
261
288
  def main() -> int:
262
289
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
263
290
  ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
@@ -270,6 +297,9 @@ def main() -> int:
270
297
  help="an input whose audio peak is at or below --silence-threshold: warn (default; join it, "
271
298
  "name it under `silent`), fail (refuse with the other input problems) or skip (leave it out, "
272
299
  "under `skipped`); a clip with no audio stream is never silent")
300
+ ap.add_argument("--allow-silent", metavar="N[,N...]",
301
+ help="input numbers (1-based) whose silence is intended, e.g. a planned pause: always joined, "
302
+ "listed under `silent` with intended: true, no warning, whatever --on-silent says")
273
303
  ap.add_argument("--silence-threshold", type=float, default=-50.0, metavar="DB",
274
304
  help="peak level in dBFS at or below which an input counts as silent (default -50)")
275
305
  ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
@@ -303,7 +333,10 @@ def main() -> int:
303
333
  orig_inputs = list(args.inputs)
304
334
  audible: List[int] = []
305
335
  problems, pending = preflight(args.inputs, audible)
306
- silent = find_silent(args.inputs, audible, args.silence_threshold)
336
+ allowed = parse_allow_silent(args.allow_silent, len(args.inputs))
337
+ found = find_silent(args.inputs, audible, args.silence_threshold)
338
+ intended = [dict(s, intended=True) for s in found if s["index"] in allowed]
339
+ silent = [s for s in found if s["index"] not in allowed]
307
340
  silent_problems = [{"index": s["index"], "path": s["path"], "reason": f"silent (peak {s['peak_db']:.1f} dBFS)"}
308
341
  for s in silent]
309
342
  refused = sorted((problems if args.on_missing == "fail" else [])
@@ -325,8 +358,8 @@ def main() -> int:
325
358
  args.inputs = [p for i, p in enumerate(args.inputs) if i not in bad]
326
359
  for p in dropped:
327
360
  info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
361
+ STATE_SILENT[:] = sorted(intended + (silent if args.on_silent == "warn" else []), key=lambda s: s["index"])
328
362
  if silent and args.on_silent == "warn":
329
- STATE_SILENT[:] = silent
330
363
  listing = ", ".join(f"#{s['index'] + 1} {s['path']} (peak {s['peak_db']:.1f} dBFS)" for s in silent)
331
364
  info(f"warning: {len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}")
332
365
  STATE_NOTES.append(f"{len(silent)} input(s) have no audible sound (peak at or below {args.silence_threshold:g} dBFS) "
@@ -410,9 +443,13 @@ def main() -> int:
410
443
  note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
411
444
  durs = [m.get("duration") or 0.0 for m in metas]
412
445
  d = args.duration if args.transition != "none" else 0.0
413
- # a crossfaded join gives each clip one length for both streams, so the offsets that place
414
- # every picture also place its sound; the plain cut (concat) keeps the streams paired itself
415
- lens = [clip_length(m, fps) for m in metas] if d else durs
446
+ # every clip gets one length for both streams, so whatever places the next clip -- xfade's
447
+ # offsets, or the concat filter, which starts it after the LONGER stream of this one -- places
448
+ # its picture and its sound together. The plain cut used to take the raw streams, and a sound
449
+ # past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
450
+ # the video there.
451
+ lens = [clip_length(m, fps) for m in metas]
452
+ place_silent(lens, d)
416
453
  for p, dur in zip(args.inputs, lens):
417
454
  if d and dur <= d * 2 and not STATE.dry_run:
418
455
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
@@ -463,18 +500,17 @@ def main() -> int:
463
500
  parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
464
501
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
465
502
 
503
+ # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
504
+ # its last frame, then cut both there -- each clip boundary is then the same for both chains
505
+ for i in range(n):
506
+ vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
507
+ hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
508
+ parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
509
+ parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
466
510
  if args.transition == "none":
467
- chain = "".join(f"[v{i}][a{i}]" for i in range(n))
511
+ chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
468
512
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
469
513
  else:
470
- # pad the shorter stream to the clip's length: the audio with silence, the picture by
471
- # holding its last frame, then cut both there -- each xfade and acrossfade then starts
472
- # at the same clip boundary instead of the audio chain drifting by every clip's gap
473
- for i in range(n):
474
- vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
475
- hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
476
- parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
477
- parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
478
514
  vprev, aprev = "vp0", "ap0"
479
515
  offset = 0.0
480
516
  for i in range(1, n):
@@ -495,11 +531,11 @@ def main() -> int:
495
531
  "by its measured length")
496
532
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
497
533
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
498
- if d:
499
- # the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
500
- # 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
501
- # dissolve came out 52), which newer builds no longer do -- pass the frames through as built
502
- cmd += ["-fps_mode", "passthrough"]
534
+ # the fps filter already made the picture constant-rate, and the per-clip trims (then xfade or
535
+ # concat) place every frame: FFmpeg 5.1's default cfr output mode still duplicated the last one
536
+ # to meet the AAC tail (a 51-frame dissolve came out 52), which newer builds no longer do -- pass
537
+ # the frames through as built, for a plain cut as much as for a transition
538
+ cmd += ["-fps_mode", "passthrough"]
503
539
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
504
540
  run(cmd)
505
541
  expected = unpending_length(lens, d)