ffmpeg-skill 2.4.2 → 2.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
@@ -222,7 +222,7 @@ These are the rules the skill file gives the agent and the code enforces.
222
222
  | `freeze.py` | Hold a frame for N seconds (`--at`, `--hold`, `--mode insert/extend`) — an end-card hold or a comedic beat |
223
223
  | `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
224
224
  | `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
225
- | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
225
+ | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length; `--boomerang` plays it forward then backward |
226
226
  | `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
227
227
  | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit; `--auto-chapters` proposes the markers from measured pauses and scene cuts and names them `Chapter N` for you to rename |
228
228
  | `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
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.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.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`
@@ -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.2", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.5.0", "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.2",
3
+ "version": "2.5.0",
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",
@@ -394,7 +394,7 @@ output; the result says so with `dropped_non_av_streams: true`.
394
394
 
395
395
  ### loop.py — repeat a clip
396
396
  ```
397
- loop.py INPUT --times N | --duration T [-o OUT]
397
+ loop.py INPUT --times N | --duration T [--boomerang] [-o OUT]
398
398
  ```
399
399
  `--times` repeats the whole clip that many times back to back (2 =
400
400
  original + 1 repeat). `--duration` instead loops (and trims the last
@@ -403,6 +403,14 @@ bed, or filling a fixed slot length with a short clip. Does not smooth the
403
403
  loop point (no crossfade at the seam) -- a clip that doesn't already loop
404
404
  cleanly will show a visible cut/pop at each repeat, which is a property of
405
405
  the source material this tool cannot fix.
406
+ `--boomerang` plays the clip forward, then backward (frames 0..N-1, then
407
+ N-2..1, repeated), so any clip loops without a jump and neither turnaround
408
+ frame is shown twice. `--times` then counts round trips (1 is allowed), and
409
+ `--duration` trims the last one. A boomerang is silent (reversed sound plays
410
+ backwards; a source's audio is dropped with a `notes` line), so add a bed with
411
+ `audio.py`. It holds the decoded clip in memory like `reverse.py` and warns
412
+ above ~2 GiB; a clip needs 3 frames at least, and one cycle at most 32767
413
+ (ffmpeg's `loop` filter).
406
414
 
407
415
  ### broll.py — cut away to a B-roll clip and come back
408
416
  ```
@@ -126,8 +126,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
126
126
  required=FF, optional=[{"capability": "filter:silencedetect", "when": "--auto-chapters"},
127
127
  {"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
128
128
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
129
- "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
130
- required=FF + [X264, AAC], optional=[],
129
+ "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
130
+ required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}],
131
131
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
132
132
  "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
133
133
  required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
@@ -267,7 +267,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
267
267
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
268
268
  "broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
269
269
  "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
270
- "loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
270
+ "loop": dict(video="always", audio="conditional", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
271
271
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
272
272
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
273
273
  "reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
@@ -451,6 +451,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
451
451
  "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
452
452
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
453
453
  "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
454
+ elif name == "loop":
455
+ extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
456
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
454
457
  elif name == "cut":
455
458
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
456
459
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
package/scripts/loop.py CHANGED
@@ -8,11 +8,17 @@ target length. Audio loops along with the video when present. This tool
8
8
  does not smooth the loop point (no crossfade at the seam) -- a clip that
9
9
  doesn't already loop cleanly will show a visible cut/pop at each repeat;
10
10
  that's a judgement call about the source material, not something a --times
11
- or --duration flag can fix.
11
+ or --duration flag can fix. --boomerang is the exception: it plays the clip
12
+ forward, then backward, so every repeat meets frames that match. Each
13
+ turnaround frame is shown once (frames 0..N-1, then N-2..1), so the motion
14
+ does not stall at either end. A boomerang is silent, because reversed sound
15
+ plays backwards; add a bed with audio.py. Like reverse.py, it holds the
16
+ decoded clip in memory.
12
17
 
13
18
  Examples:
14
19
  python3 loop.py bg_loop.mp4 --times 3
15
20
  python3 loop.py texture.mp4 --duration 30
21
+ python3 loop.py wave.mp4 --boomerang --times 4
16
22
  """
17
23
  import argparse
18
24
  import math
@@ -21,6 +27,72 @@ import sys
21
27
  from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
22
28
 
23
29
 
30
+ # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
31
+ LOOP_FILTER_MAX_FRAMES = 32767
32
+
33
+
34
+ def _boomerang_graph(meta, src_dur: float, times) -> str:
35
+ """Frames 0..N-1, then N-2..1, repeated: `times` round trips, or forever for --duration (-t ends it).
36
+
37
+ The backward half is the source minus frame 0, reversed, minus its first frame (N-1). The
38
+ forward half already shows both ends, so each turnaround appears once. That is unlike the
39
+ common split/reverse/concat recipe, which shows frame N-1 twice and, on every repeat, frame 0
40
+ twice. Trimming by frame index this way needs no frame count."""
41
+ v = meta["video"]
42
+ frames = v.get("nb_frames") or round((v.get("duration") or src_dur) * (v.get("fps") or 30.0))
43
+ if frames < 3:
44
+ die(f"--boomerang needs a clip of at least 3 frames, got {frames}")
45
+ cycle = 2 * frames - 2
46
+ if cycle > LOOP_FILTER_MAX_FRAMES:
47
+ die(f"--boomerang cycle is {cycle} frames; ffmpeg's loop filter holds at most {LOOP_FILTER_MAX_FRAMES} "
48
+ f"-- cut the clip shorter first (cut.py)")
49
+ # reverse holds the clip's decoded frames; split, trim and loop pass references to the same
50
+ # frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
51
+ bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
52
+ held = bytes_per_frame * frames
53
+ if held > 2 * 1024 ** 3:
54
+ info(f"warning: --boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
55
+ f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight")
56
+ repeats = -1 if times is None else times - 1
57
+ # Retimed by frame index at the source's nominal rate: a VFR source's reversed half carries
58
+ # its gaps mirrored, and a CFR conform after the graph duplicated a frame against them (a
59
+ # turnaround shown twice), so the output is made constant-rate here and passed through as is.
60
+ # fps= after setpts drops or repeats nothing; it tells the encoder the rate, whose time base
61
+ # otherwise follows the source's r_frame_rate and rounds the new timestamps unevenly.
62
+ rate = _nominal_rate(v)
63
+ return ("[0:v]split[f][r];[f]setpts=PTS-STARTPTS[fw];"
64
+ "[r]trim=start_frame=1,reverse,trim=start_frame=1,setpts=PTS-STARTPTS[bw];"
65
+ f"[fw][bw]concat=n=2:v=1:a=0,loop=loop={repeats}:size={LOOP_FILTER_MAX_FRAMES},"
66
+ f"setpts=N/(({rate})*TB),fps={rate}[v]")
67
+
68
+
69
+ # Above this a "frame rate" is a time base (90000/1 from MPEG-TS, 1000/1 from Matroska), not a rate.
70
+ MAX_PLAUSIBLE_FPS = 240
71
+
72
+
73
+ def _fraction(text):
74
+ try:
75
+ num, _, den = (text or "").partition("/")
76
+ value = float(num) / float(den or 1)
77
+ except (ValueError, ZeroDivisionError):
78
+ return None
79
+ return value if 0 < value <= MAX_PLAUSIBLE_FPS else None
80
+
81
+
82
+ def _nominal_rate(v) -> str:
83
+ """The source's frame rate as an exact fraction: r_frame_rate when it is within 1% of the
84
+ average (an iPhone clip: 60/1 against 59.95 from a 1/600 time base), else the average; with
85
+ neither usable, frames over the video's duration, else 30."""
86
+ r, avg = v.get("r_frame_rate"), v.get("avg_frame_rate")
87
+ r_val, avg_val = _fraction(r), _fraction(avg)
88
+ if r_val and (not avg_val or abs(r_val - avg_val) <= 0.01 * avg_val):
89
+ return r
90
+ if avg_val:
91
+ return avg
92
+ counted = (v.get("nb_frames") or 0) / (v.get("duration") or float("inf"))
93
+ return f"{counted:g}" if 0 < counted <= MAX_PLAUSIBLE_FPS else "30"
94
+
95
+
24
96
  def main() -> int:
25
97
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
26
98
  ap.add_argument("input")
@@ -28,6 +100,8 @@ def main() -> int:
28
100
  group = ap.add_mutually_exclusive_group(required=True)
29
101
  group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
30
102
  group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
103
+ ap.add_argument("--boomerang", action="store_true",
104
+ help="play forward then backward (--times counts round trips; 1 is allowed); drops audio")
31
105
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
32
106
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
33
107
  add_common(ap)
@@ -40,12 +114,14 @@ def main() -> int:
40
114
  src_dur = meta.get("duration") or 0.0
41
115
  if src_dur <= 0:
42
116
  die("input has no measurable duration to loop")
43
- has_audio = bool(meta.get("audio"))
117
+ has_audio = bool(meta.get("audio")) and not args.boomerang
118
+ notes = []
44
119
  output = args.output or default_output(args.input, "loop")
45
120
 
46
121
  if args.times is not None:
47
- if args.times < 2:
48
- die(f"--times must be >= 2 (1 is just the original clip), got {args.times}")
122
+ if args.times < (1 if args.boomerang else 2):
123
+ die(f"--times must be >= 1 with --boomerang, got {args.times}" if args.boomerang else
124
+ f"--times must be >= 2 (1 is just the original clip), got {args.times}")
49
125
  target = None
50
126
  stream_loop = args.times - 1
51
127
  else:
@@ -54,14 +130,19 @@ def main() -> int:
54
130
  die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
55
131
  stream_loop = math.ceil(target / src_dur) - 1
56
132
 
57
- # -stream_loop repeats the whole input read (video and audio together) at the demuxer level
58
- # -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
59
- # would need separate video/audio filters kept in lockstep by hand.
60
- cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
133
+ if args.boomerang:
134
+ cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
135
+ if meta.get("audio"):
136
+ notes.append("audio dropped: a boomerang plays half its sound backwards -- add a bed with audio.py")
137
+ else:
138
+ # -stream_loop repeats the whole input read (video and audio together) at the demuxer level
139
+ # -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
140
+ # would need separate video/audio filters kept in lockstep by hand.
141
+ cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
61
142
  if target is not None:
62
143
  cmd += ["-t", f"{target:.3f}"]
63
144
  cmd += video_args(meta, args.crf, args.preset)
64
- cmd += cfr_args(meta)
145
+ cmd += ["-fps_mode", "passthrough"] if args.boomerang else cfr_args(meta)
65
146
  if has_audio:
66
147
  cmd += aac_args()
67
148
  else:
@@ -71,8 +152,11 @@ def main() -> int:
71
152
 
72
153
  result = probe(output, role="output")
73
154
  v = result["video"]
74
- info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
75
- emit(output)
155
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s "
156
+ f"{'boomeranged' if args.boomerang else 'looped'})")
157
+ for note in notes:
158
+ info(note)
159
+ emit(output, boomerang=args.boomerang, **({"notes": notes} if notes else {}))
76
160
  return 0
77
161
 
78
162