ffmpeg-skill 2.4.0 → 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.4.0`) | 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.4.0", "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"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.4.0",
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",
@@ -591,10 +591,10 @@ are always present (`[]` when none) and noted.
591
591
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
592
592
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
593
593
  silent track generated for clips without audio), then chains `xfade` +
594
- `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
595
595
  -- the video stream's, or the audio's when it runs more than a frame longer --
596
596
  and the shorter stream is padded (silence / last frame held), so every clip's
597
- 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 −
598
598
  transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
599
599
  longer than 2 × the transition. Use `--transition none` for a plain cut.
600
600
  A subtitle/data track in the source is not carried into the retimed/concatenated
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)."""
@@ -443,9 +443,12 @@ def main() -> int:
443
443
  note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
444
444
  durs = [m.get("duration") or 0.0 for m in metas]
445
445
  d = args.duration if args.transition != "none" else 0.0
446
- # a crossfaded join gives each clip one length for both streams, so the offsets that place
447
- # every picture also place its sound; the plain cut (concat) keeps the streams paired itself
448
- 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]
449
452
  place_silent(lens, d)
450
453
  for p, dur in zip(args.inputs, lens):
451
454
  if d and dur <= d * 2 and not STATE.dry_run:
@@ -497,18 +500,17 @@ def main() -> int:
497
500
  parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
498
501
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]")
499
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}]")
500
510
  if args.transition == "none":
501
- 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))
502
512
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
503
513
  else:
504
- # pad the shorter stream to the clip's length: the audio with silence, the picture by
505
- # holding its last frame, then cut both there -- each xfade and acrossfade then starts
506
- # at the same clip boundary instead of the audio chain drifting by every clip's gap
507
- for i in range(n):
508
- vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
509
- hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
510
- parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
511
- parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
512
514
  vprev, aprev = "vp0", "ap0"
513
515
  offset = 0.0
514
516
  for i in range(1, n):
@@ -529,11 +531,11 @@ def main() -> int:
529
531
  "by its measured length")
530
532
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
531
533
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
532
- if d:
533
- # the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
534
- # 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
535
- # dissolve came out 52), which newer builds no longer do -- pass the frames through as built
536
- 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"]
537
539
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
538
540
  run(cmd)
539
541
  expected = unpending_length(lens, d)