ffmpeg-skill 2.12.0 → 2.13.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
@@ -247,7 +247,7 @@ These are the rules the skill file gives the agent and the code enforces.
247
247
  | Tool | What it does |
248
248
  |---|---|
249
249
  | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area — with `--platform`, the destination's own left/right safe zone, which since 1.17.2 is also what the burnt ASS states as its side margins — with a phrase-aware breaker (`--wrap phrase|measured`) and `--max-lines`/`--min-duration`/`--offset`; `--mode mux` takes a repeated `--srt file:lang` for several language-tagged, toggleable tracks in one file; picks a font by script for non-Latin text (`--lang`); animated and word-by-word karaoke timed to the speech energy or real word timings; `--fit-size` shrinks the size until a cue fits `--max-lines` instead of splitting the sentence (on by default on the delivery-template path since 1.17.1, where the size comes from the platform table); says `caption text unchanged` when it burned the cues exactly as given; optional local transcription |
250
- | `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing |
250
+ | `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing, `--place` to lay ranges of a layer at chosen times |
251
251
  | `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs, social stickers, opening hook cards and meme captions drawn by FFmpeg from a brand kit; `--platform NAME` keeps them inside that destination's safe zone; `--text-render` routes shaping scripts through libass and `--emoji-assets` composites colour emoji |
252
252
  | `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
253
253
 
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.12.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.13.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`
@@ -125,7 +125,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
125
125
  "contract_version": "1.0",
126
126
  "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
127
127
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
128
- "skill": {"id": "ffmpeg-skill", "version": "2.12.0", "execution_mode": "local", "kind": "execution",
128
+ "skill": {"id": "ffmpeg-skill", "version": "2.13.0", "execution_mode": "local", "kind": "execution",
129
129
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
130
130
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
131
131
  "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.12.0",
3
+ "version": "2.13.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",
@@ -51,6 +51,7 @@
51
51
  ],
52
52
  "scripts": {
53
53
  "test": "python3 tests/test_all.py && python3 tests/test_contract.py",
54
+ "test:fast": "python3 tests/test_contract.py",
54
55
  "release-check": "bash tests/release_check.sh",
55
56
  "demo": "python3 demos/build.py",
56
57
  "demo:pipeline": "bash examples/make_demo.sh",
@@ -1534,6 +1534,7 @@ stream selection would have picked.
1534
1534
  overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
1535
1535
  [--emoji auto|mono|none] [--emoji-assets DIR]
1536
1536
  | --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
1537
+ [--place SRC_START-SRC_END@AT ...]
1537
1538
  [--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
1538
1539
  ```
1539
1540
  `--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
@@ -1550,6 +1551,20 @@ playing. `--scale` / `--scale-percent` set the width of an image, logo or
1550
1551
  input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
1551
1552
  (with `--video`) keys out that colour first for green-screen compositing.
1552
1553
 
1554
+ `--place SRC_START-SRC_END@AT` (with `--video`, repeatable) shows only that range
1555
+ of the layer, starting at `AT` on the main timeline; outside its placed ranges
1556
+ the layer is hidden and the main picture is untouched. One pass lays several
1557
+ pieces of one layer at their own times -- e.g. graphics rendered back to back
1558
+ (only the seconds that have graphics) and placed where each belongs:
1559
+ `--video cues.mov --position 0,0 --margin 0 --place 0-2.5@12 --place 2.5-6@40`.
1560
+ An `hh:mm:ss:ff` timecode counts the layer's frames in `SRC_START-SRC_END` and
1561
+ the main video's in `AT` (an `@fps` suffix names the rate explicitly).
1562
+ Refused (`kind: input`): a range that ends before it starts, negative times,
1563
+ two pieces overlapping on the main timeline, a range past the layer's last
1564
+ picture (by more than half a frame), a
1565
+ piece starting after the main video ends, `--place` without `--video`, and
1566
+ `--place` with `--start`/`--end`/`--fade` (the placements already say when).
1567
+
1553
1568
  `--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
1554
1569
  only at `--end`, so a logo with no `--end` stays to the last frame.
1555
1570
 
package/scripts/look.py CHANGED
@@ -28,7 +28,12 @@ def fmt_hms(sec: float) -> str:
28
28
  return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
29
29
 
30
30
 
31
- def timecode_filter(font_prefix: str) -> str:
31
+ def timecode_filter(font_prefix: str, offset: float = 0.0) -> str:
32
+ """Burned-in timecode. `offset` is added to each frame's pts: an input-side -ss resets
33
+ timestamps to 0 at the seek point, so a sheet that starts reading mid-slice must add the seek
34
+ back or every tile is labelled `offset` seconds early (the contact sheet below seeks step/2)."""
35
+ if offset > 0:
36
+ return f"drawtext=text='%{{pts\\:hms\\:{offset:.6f}}}':{font_prefix}{FONT}"
32
37
  return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
33
38
 
34
39
 
@@ -256,10 +261,12 @@ def main() -> int:
256
261
  tile_w = max(2, (args.width // cols) // 2 * 2)
257
262
  out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
258
263
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
259
- vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
260
- f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
261
264
  # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
262
265
  seek = 0.0 if frames_avail < 4 else step / 2
266
+ # the seek resets pts to 0, so the burned-in timecode gets the seek added back
267
+ tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
268
+ vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
269
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
263
270
  cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
264
271
  run(cmd)
265
272
  outputs.append(out)
@@ -7,7 +7,10 @@ bottom-right, or explicit "X,Y" pixels (negative counts from the far edge).
7
7
 
8
8
  --video composites a second VIDEO as a picture-in-picture layer (position,
9
9
  scale, opacity, time-range -- same knobs as --image), instead of a still
10
- image or text. Only the main input's audio is kept; the PiP layer's own
10
+ image or text. --place SRC_START-SRC_END@AT (repeatable, with --video) shows only
11
+ that range of the layer, starting at AT on the main timeline: one pass lays
12
+ several pieces of a layer (say, graphics rendered back to back) at their own
13
+ times, and the main picture shows untouched everywhere else. Only the main input's audio is kept; the PiP layer's own
11
14
  audio track, if any, is dropped -- mixing two audio tracks is a job for
12
15
  audio.py, not this tool. --chromakey COLOR (with --video) turns that colour
13
16
  transparent first (green-screen removal) before compositing.
@@ -19,6 +22,7 @@ Examples:
19
22
  python3 overlay.py input.mp4 --text "こんにちは" --font-file /path/NotoSansCJK-Bold.ttc --box
20
23
  python3 overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480 --opacity 0.9
21
24
  python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
25
+ python3 overlay.py talk.mp4 --video cues.mov --position 0,0 --margin 0 --place 0-2.5@12 --place 2.5-6@40
22
26
  """
23
27
  import argparse
24
28
  import re
@@ -140,6 +144,34 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
140
144
  return ",".join(filters)
141
145
 
142
146
 
147
+ # one time inside --place: seconds, mm:ss, hh:mm:ss.ms or hh:mm:ss:ff with an optional @fps
148
+ # suffix (the time grammar's own); times are never negative, so '-' only separates the range
149
+ _PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
150
+
151
+
152
+ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float]) -> List[tuple]:
153
+ """--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
154
+ overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
155
+ SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
156
+ layer's frames; AT is a position on the main timeline and counts the main video's."""
157
+ out = []
158
+ for spec in specs:
159
+ m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
160
+ if not m:
161
+ die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
162
+ a = time_arg(m.group(1), "--place", layer_fps)
163
+ b = time_arg(m.group(2), "--place", layer_fps)
164
+ at = time_arg(m.group(3), "--place", main_fps)
165
+ if b <= a:
166
+ die(f"--place {spec!r}: the layer range ends before it starts")
167
+ out.append((a, b, at))
168
+ spans = sorted((at, at + (b - a)) for a, b, at in out)
169
+ for (s1, e1), (s2, _e2) in zip(spans, spans[1:]):
170
+ if s2 < e1 - 1e-6:
171
+ die(f"--place: pieces overlap on the main timeline ({s1:g}-{e1:g} s and from {s2:g} s)")
172
+ return out
173
+
174
+
143
175
  def main() -> int:
144
176
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
145
177
  ap.add_argument("input")
@@ -153,6 +185,10 @@ def main() -> int:
153
185
  src.add_argument("--text", help="text to draw (drawtext)")
154
186
  src.add_argument("--logo", action="store_true", help="composite the brand logo from --brand (position/scale/opacity from brand.json)")
155
187
  src.add_argument("--video", help="a second video to composite as a picture-in-picture layer")
188
+ ap.add_argument("--place", action="append", default=[], metavar="SRC_START-SRC_END@AT",
189
+ help="with --video, repeatable: show this range of the layer starting at AT on the main "
190
+ "timeline (e.g. 0-2.5@12); the layer is hidden outside its placed ranges. Times in "
191
+ "seconds or HH:MM:SS. Not with --start/--end/--fade")
156
192
  ck = ap.add_argument_group("chroma key (with --video)")
157
193
  ck.add_argument("--chromakey", help="colour to key out (green-screen removal), e.g. 0x00ff00 or green")
158
194
  ck.add_argument("--chromakey-similarity", type=float, default=0.15, help="how close a pixel must be to --chromakey to become transparent, 0..1 (default 0.15)")
@@ -304,6 +340,10 @@ def main() -> int:
304
340
  die("--chromakey-similarity must be within (0, 1]")
305
341
  if not 0 <= args.chromakey_blend <= 1:
306
342
  die("--chromakey-blend must be within 0..1")
343
+ if args.place and not args.video:
344
+ die("--place needs --video")
345
+ if args.place and (args.start or args.end or args.fade):
346
+ die("--place sets when each piece shows: drop --start / --end / --fade")
307
347
 
308
348
  output = args.output or default_output(args.input, "overlay")
309
349
  enable = enable_expr(start, end)
@@ -355,6 +395,24 @@ def main() -> int:
355
395
  pip_meta = probe(args.video)
356
396
  if not pip_meta.get("video"):
357
397
  die(f"--video {args.video} has no video stream")
398
+ layer_fps = pip_meta["video"].get("fps")
399
+ placements = parse_placements(args.place, fps, layer_fps) if args.place else []
400
+ # bounds from the video streams (a longer audio track would let a range run past the last
401
+ # picture); the container duration only when a stream has none
402
+ layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
403
+ main_dur = meta["video"].get("duration") or meta.get("duration")
404
+ # half a layer frame of slack absorbs a hand-typed end that rounds the last frame's end
405
+ # (2.033 for 61 frames at 30 fps); anything longer asks for pictures the layer does not have
406
+ slack = 0.5 / layer_fps if layer_fps else 0.02
407
+ for a, b, at in placements:
408
+ if layer_dur and a >= layer_dur:
409
+ # the slack below is for an end rounded past the last frame; a start there selects
410
+ # no picture at all and the "placed" piece would silently never show
411
+ die(f"--place {a:g}-{b:g}@{at:g}: starts at or after the layer's end ({layer_dur:.3f} s)")
412
+ if layer_dur and b > layer_dur + slack:
413
+ die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
414
+ if main_dur and at >= main_dur:
415
+ die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
358
416
  chain = []
359
417
  if args.scale_percent:
360
418
  chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
@@ -376,8 +434,26 @@ def main() -> int:
376
434
  base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
377
435
  if base or pip:
378
436
  base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
379
- fc = (f"[1:v]{pip}{','.join(chain)}[ov];"
380
- + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
437
+ if placements:
438
+ # each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
439
+ # the main picture untouched once a piece ends (the default would freeze its last frame)
440
+ pieces = len(placements)
441
+ # trim selects by timestamp: start the layer's at 0 so SRC_START/SRC_END count from its
442
+ # first picture even when its video stream starts later than the file (audio first)
443
+ fc = f"[1:v]setpts=PTS-STARTPTS,{pip}{','.join(chain)},split={pieces}" + "".join(f"[p{i}]" for i in range(pieces)) + ";"
444
+ for i, (a, b, at) in enumerate(placements):
445
+ fc += f"[p{i}]trim=start={a:.6f}:end={b:.6f},setpts=PTS-STARTPTS+{at:.6f}/TB[q{i}];"
446
+ prev = f"[0:v]{base}null[m]" if base else None
447
+ if prev:
448
+ fc += prev + ";"
449
+ cur = "[m]" if base else "[0:v]"
450
+ for i in range(pieces):
451
+ nxt = "[out]" if i == pieces - 1 else f"[m{i}]"
452
+ fc += f"{cur}[q{i}]{ov}:eof_action=pass{nxt}" + ("" if i == pieces - 1 else ";")
453
+ cur = nxt
454
+ else:
455
+ fc = (f"[1:v]{pip}{','.join(chain)}[ov];"
456
+ + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
381
457
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
382
458
  if meta.get("duration"):
383
459
  # See the --image branch above: -t (exact, bounds only the main input) instead of