ffmpeg-skill 2.4.1 → 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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +9 -1
- package/scripts/_contract.py +6 -3
- package/scripts/join.py +5 -0
- package/scripts/loop.py +95 -11
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
```
|
package/scripts/_contract.py
CHANGED
|
@@ -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="
|
|
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/join.py
CHANGED
|
@@ -529,6 +529,11 @@ def main() -> int:
|
|
|
529
529
|
if d and any(m.get("dry_run") for m in metas[:-1]):
|
|
530
530
|
STATE_NOTES.append("the planned xfade offsets count each pending input as 0 s long: a real run offsets "
|
|
531
531
|
"by its measured length")
|
|
532
|
+
elif not d and any(m.get("dry_run") for m in metas):
|
|
533
|
+
# the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
|
|
534
|
+
# planned trim=duration=0.000 is the stub's placeholder, not the cut
|
|
535
|
+
STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
|
|
536
|
+
"or holds it to its measured length")
|
|
532
537
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
533
538
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
534
539
|
# the fps filter already made the picture constant-rate, and the per-clip trims (then xfade or
|
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 >=
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
75
|
-
|
|
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
|
|