ffmpeg-skill 3.6.2 → 3.6.3

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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.6.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.6.3`) | 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.6.2", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.6.3", "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"},
@@ -760,6 +760,7 @@ silence a tool reported as fine while another measurement disagreed):
760
760
  | `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` |
761
761
  | `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` |
762
762
  | `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 |
763
+ | `frame_rate`, `clip_frames`, `transition_frames` | `join.py` (video join; added after 3.6.2) | the join's rate as an exact fraction (`"30000/1001"`), the whole frames of it each clip holds (its sound is cut to the samples those frames last), and the frames each transition overlaps (`--duration` rounded to whole frames, at least 1; `0` for `--transition none`). Lengths and xfade offsets are counted in these frames, never in rounded seconds (#455, #468); `inputs_duration` and `expected_duration` are derived from them. A pending `--dry-run` input counts 0 frames |
763
764
  | `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` with neither `--speech-aware` nor a sentence flag, which removes no silences |
764
765
  | `silence_positions`, `sentence` | `silence.py --sentence-min-silence` / `--sentence-margin-tail` | per entry of `silences`, `sentence_end` when the transcript word before it ends in sentence-final punctuation, else `inside`; and `{min_silence, margin_tail, source, word_timings, sentence_end_words, silences}`. `sentence_end_words: 0` means the transcript had no such punctuation, so every silence used `--min-silence` and `--margin-tail` (warning `sentence_ends_not_found`) |
765
766
  | `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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.2",
3
+ "version": "3.6.3",
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": [
@@ -770,6 +770,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
770
770
  elif name == "join":
771
771
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
772
772
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
773
+ "frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
774
+ "clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
775
+ "transition_frames": {"type": "integer", "description": "video mode only: the frames each xfade/acrossfade overlaps (--duration rounded to whole frames, at least 1; 0 for --transition none)"},
773
776
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
774
777
  "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
775
778
  "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
package/scripts/join.py CHANGED
@@ -36,9 +36,11 @@ audio stream is never silent.
36
36
  """
37
37
  import argparse
38
38
  import json
39
+ import math
39
40
  import os
40
41
  import tempfile
41
42
  import sys
43
+ from fractions import Fraction
42
44
  from typing import Any, Dict, List, Optional, Tuple
43
45
 
44
46
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
@@ -46,6 +48,7 @@ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, pla
46
48
  from _common.emit import note, warn
47
49
  from _common.decision import av_clip_length, av_trim, stream_starts
48
50
  from _common.probe import display_size, square_pixels, square_pixels_note
51
+ from _common.timeline import exact_rate
49
52
 
50
53
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
51
54
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -112,6 +115,81 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
112
115
  return av_clip_length(meta, fps)
113
116
 
114
117
 
118
+ # the video join's sound rate (every clip is resampled to it before the trims)
119
+ JOIN_AUDIO_RATE = 48000
120
+
121
+
122
+ def output_rate(fps: float, first: Dict[str, Any], asked: bool) -> Fraction:
123
+ """The join's frame rate as an exact fraction: the first clip's own r_frame_rate/avg_frame_rate
124
+ when `fps` is that clip's (30000/1001, not 29.97), else the NTSC fraction or the plain one
125
+ `fps` names (timeline.exact_rate). Every clip length, offset and sound length is counted in
126
+ frames of this rate, so the graph's fps filter runs at the same fraction."""
127
+ if not asked:
128
+ for key in ("r_frame_rate", "avg_frame_rate"):
129
+ try:
130
+ q = Fraction(str(first.get(key) or ""))
131
+ except (ValueError, ZeroDivisionError):
132
+ continue
133
+ # the declared rate also wins when the average was taken over a length that leaves out
134
+ # the last frame's duration (#475: 5 frames in 0.1333 s read 37.5 fps, and the whole
135
+ # join ran at 75/2)
136
+ if 0 < q <= 240 and (abs(float(q) - fps) < 1e-3 or short_last_frame(first, q)):
137
+ return q
138
+ return exact_rate(fps)
139
+
140
+
141
+ def short_last_frame(video: Optional[Dict[str, Any]], q: Fraction) -> bool:
142
+ """Whether a picture's duration is its frame count less one frame at rate `q`: a container
143
+ that stores no duration for the last frame (MOV written with -fps_mode passthrough, seen on
144
+ FFmpeg 7.1) reads 5 frames as 0.1333 s at 30 fps (#475)."""
145
+ v = video or {}
146
+ nb, dur = v.get("nb_frames"), v.get("duration")
147
+ if not nb or nb < 2 or not dur or q <= 0:
148
+ return False
149
+ step = float(1 / q)
150
+ return abs(dur - (nb - 1) * step) < 0.25 * step and abs(dur - nb * step) > 0.25 * step
151
+
152
+
153
+ def with_whole_picture(meta: Dict[str, Any], q: Fraction) -> Dict[str, Any]:
154
+ """`meta` with its picture counted as every frame it holds when its duration leaves out the
155
+ last one (short_last_frame), so the clip-length rule does not read the sound as running a
156
+ frame past the picture and hold the clip longer (#475). Other clips are returned as they are."""
157
+ v = meta.get("video") or {}
158
+ # each clip at its OWN declared rate: at the join's rate a complete 30 fps clip of 5 frames
159
+ # (5/30 s) also equals 4 frames at --fps 24 and would be stretched to 5/24 s
160
+ try:
161
+ own = Fraction(str(v.get("r_frame_rate") or ""))
162
+ except (ValueError, ZeroDivisionError):
163
+ own = q
164
+ if not 0 < own <= 240:
165
+ own = q
166
+ if not short_last_frame(v, own):
167
+ return meta
168
+ return dict(meta, video=dict(v, duration=float(v["nb_frames"] / own)))
169
+
170
+
171
+ def clip_frames(meta: Dict[str, Any], length: float, q: Fraction) -> int:
172
+ """How many frames of rate `q` a clip of `length` seconds (clip_length) holds in the join: its
173
+ picture's frames, or, when the clip lasts as long as its sound, enough frames to cover that
174
+ sound (narration is never cut; a hundredth of a frame is rounding, not sound). A pending
175
+ dry-run stub's 0 s stays 0; a measured clip holds at least one frame (#455: a length in
176
+ milliseconds rounded up started every next clip late, and the errors added up)."""
177
+ exact = length * q.numerator / q.denominator
178
+ vlen = (meta.get("video") or {}).get("duration") or 0.0
179
+ frames = math.ceil(exact - 0.01) if vlen and length > vlen else round(exact)
180
+ return frames if meta.get("dry_run") else max(1, frames)
181
+
182
+
183
+ def frame_time(frames: int, q: Fraction) -> str:
184
+ """The time of frame `frames` at rate `q` in seconds, rounded down to the microsecond: never
185
+ after that frame's own timestamp, so a filter that starts on the first frame at or after it
186
+ (xfade's offset) starts on that frame and not the next (#468: 2.566667 written 2.567 started
187
+ the transition a frame late). Trailing zeros are dropped (0.5, not 0.500000)."""
188
+ us = (frames * q.denominator * 1_000_000) // q.numerator
189
+ text = f"{us // 1_000_000}.{us % 1_000_000:06d}".rstrip("0").rstrip(".")
190
+ return text or "0"
191
+
192
+
115
193
  def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
116
194
  """Every path (compared by its resolved absolute form) listed more than once, with the 0-based
117
195
  list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
@@ -543,6 +621,10 @@ def main() -> int:
543
621
  apply_common(args)
544
622
  if args.fps is not None and args.fps <= 0:
545
623
  die(f"--fps must be positive, got {args.fps:g}")
624
+ # nan and inf are refused by the parser (finite_float); a negative length was taken as the
625
+ # one-frame minimum and the join succeeded with a transition nobody asked for
626
+ if args.duration < 0:
627
+ die(f"--duration must be 0 or more seconds, got {args.duration:g}")
546
628
 
547
629
  if args.list:
548
630
  if args.inputs:
@@ -666,6 +748,9 @@ def main() -> int:
666
748
  w, h = fw, fh
667
749
  fps = args.fps or first.get("fps") or 30.0
668
750
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
751
+ # the exact rate every length below is counted in (30000/1001, not 29.97)
752
+ rate_q = output_rate(fps, first, bool(args.fps))
753
+ fps = float(rate_q)
669
754
  w, h = w - (w % 2), h - (h % 2)
670
755
  note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
671
756
  durs = [m.get("duration") or 0.0 for m in metas]
@@ -675,11 +760,37 @@ def main() -> int:
675
760
  # its picture and its sound together. The plain cut used to take the raw streams, and a sound
676
761
  # past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
677
762
  # the video there.
678
- lens = [clip_length(m, fps) for m in metas]
763
+ # Each length is a whole number of frames of the output rate, and each sound length the
764
+ # samples those frames last (#455): a length in milliseconds rounded 5 frames at 30 fps
765
+ # (0.166667 s) up to 0.167 s, the concat filter started the next clip after it, and 60 such
766
+ # clips left one-frame holes from about 8 s on. Sample counts come from the running frame
767
+ # total, so the sound never drifts from the picture by more than one sample.
768
+ lmetas = [with_whole_picture(m, rate_q) for m in metas]
769
+ frames = [clip_frames(m, clip_length(m, fps), rate_q) for m in lmetas]
770
+ # the transition is a whole number of frames too, so xfade's offsets (and acrossfade's
771
+ # overlap) land on frames of the output rate (#468). A transition is at least one frame:
772
+ # --duration 0 used to build acrossfade=ns=0, which FFmpeg refuses
773
+ tframes = max(1, round(d * fps)) if args.transition != "none" else 0
774
+ d = tframes / fps
775
+ lens = [f / fps for f in frames]
679
776
  place_silent(lens, d)
680
- for p, dur in zip(args.inputs, lens):
681
- if d and dur <= d * 2 and not STATE.dry_run:
682
- die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
777
+ for p, nf in zip(args.inputs, frames):
778
+ if d and nf <= tframes * 2 and not STATE.dry_run:
779
+ die(f"{p} is only {nf / fps:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
780
+ # every sample boundary is a frame position of the OUTPUT rounded once: clip i spans output
781
+ # frames [start_i, start_i + frames_i) and overlaps the clip before it over [start_i,
782
+ # start_i + tframes), so its samples and each overlap's are differences of rounded
783
+ # cumulative positions and their sum telescopes to the output's own length. Rounding each
784
+ # overlap on its own (14414.4 samples -> 14414 at 30000/1001 and 9 frames) left the sound
785
+ # 0.4 samples longer per transition.
786
+ def at(frame: int) -> int:
787
+ return round(frame * JOIN_AUDIO_RATE / rate_q)
788
+ starts, start = [], 0
789
+ for i, nf in enumerate(frames):
790
+ starts.append(start)
791
+ start += nf - tframes
792
+ samples = [at(s0 + nf) - at(s0) for s0, nf in zip(starts, frames)]
793
+ overlaps = [at(s0 + tframes) - at(s0) for s0 in starts]
683
794
 
684
795
  cmd = ffmpeg_base()
685
796
  extra_inputs: List[str] = []
@@ -745,7 +856,10 @@ def main() -> int:
745
856
  if len(set(chans)) > 1:
746
857
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
747
858
  for i in range(n):
748
- parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
859
+ # the fps filter's time base is one frame (1/rate), so every timestamp downstream is a
860
+ # frame number and an offset in seconds is rounded to a whole frame, never a microsecond
861
+ # off it on any FFmpeg (AVTB left xfade to compare microseconds)
862
+ parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={rate_q.numerator}/{rate_q.denominator},format={pixfmt}[v{i}]")
749
863
  # the sound is shifted by the picture's start, not to its own (#409): #338 started each
750
864
  # clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
751
865
  # after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
@@ -755,23 +869,29 @@ def main() -> int:
755
869
 
756
870
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
757
871
  # its last frame, then cut both there -- each clip boundary is then the same for both chains
872
+ # -- by frame and by sample, never by rounded seconds (#455)
758
873
  for i in range(n):
759
- vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
760
- hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
761
- parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
762
- parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
874
+ vlen = (lmetas[i].get("video") or {}).get("duration") or lens[i]
875
+ have = round(vlen * fps)
876
+ # one frame more than the gap is held, and the trim cuts at the clip's last frame
877
+ hold = f"tpad=stop_mode=clone:stop={frames[i] - have + 1}," if frames[i] > have else ""
878
+ parts.append(f"[v{i}]{hold}trim=end_frame={frames[i]}[vp{i}]")
879
+ parts.append(f"[a{i}]apad=whole_len={samples[i]},atrim=end_sample={samples[i]}[ap{i}]")
763
880
  if args.transition == "none":
764
881
  chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
765
882
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
766
883
  else:
767
884
  vprev, aprev = "vp0", "ap0"
768
- offset = 0.0
885
+ start = 0
769
886
  for i in range(1, n):
770
- offset += lens[i - 1] - d
887
+ # clip i starts on frame `start` of the output; xfade starts on the first frame at or
888
+ # after its offset, so the offset is that frame's time rounded down (#468)
889
+ start += frames[i - 1] - tframes
771
890
  vout = f"vx{i}" if i < n - 1 else "vout"
772
891
  aout = f"ax{i}" if i < n - 1 else "aout"
773
- parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
774
- parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
892
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={frame_time(tframes, rate_q)}"
893
+ f":offset={frame_time(start, rate_q)}[{vout}]")
894
+ parts.append(f"[{aprev}][ap{i}]acrossfade=ns={overlaps[i]}:c1=tri:c2=tri[{aout}]")
775
895
  vprev, aprev = vout, aout
776
896
 
777
897
  # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
@@ -784,7 +904,7 @@ def main() -> int:
784
904
  "by its measured length")
785
905
  elif not d and any(m.get("dry_run") for m in metas):
786
906
  # the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
787
- # planned trim=duration=0.000 is the stub's placeholder, not the cut
907
+ # planned trim=end_frame=0 is the stub's placeholder, not the cut
788
908
  STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
789
909
  "or holds it to its measured length")
790
910
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
@@ -818,6 +938,7 @@ def main() -> int:
818
938
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
819
939
  overlap = overlap_report(lens, expected)
820
940
  emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
941
+ frame_rate=f"{rate_q.numerator}/{rate_q.denominator}", clip_frames=frames, transition_frames=tframes,
821
942
  verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
822
943
  audio_substituted=substituted,
823
944
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))