ffmpeg-skill 1.5.0 → 1.5.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 (`1.5.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.5.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`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.5.0", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.5.1", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "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": "1.5.0",
3
+ "version": "1.5.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",
@@ -504,8 +504,9 @@ not — a timecode-shaped cue with no fps available is refused rather than misre
504
504
  Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
505
505
  `--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
506
506
  SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
507
- word from `--color` to `--highlight-color` evenly across the cue (word timing
508
- is distributed, not transcribed). The ASS is kept next to the output so the
507
+ word from `--color` to `--highlight-color` across the cue; `--karaoke-timing
508
+ energy` (default) follows the speech loudness in the audio, `even` splits the
509
+ cue equally (word timing is derived, not transcribed). The ASS is kept next to the
509
510
  user can hand-tune timings and re-run with `--ass`.
510
511
  `--mode burn` (default) renders subtitles into the picture and always
511
512
  re-encodes both streams. `--mode mux` copies video and audio untouched and
package/scripts/batch.py CHANGED
@@ -224,12 +224,20 @@ def main() -> int:
224
224
  results = one_pass()
225
225
  if args.watch:
226
226
  info(f"watching {folder} every {args.watch:g}s (Ctrl-C to stop)")
227
+ # the shared SIGINT handler (install_signal_handlers) exits 130 with "nothing was written",
228
+ # which is wrong for a watch that already processed files: while idle between passes,
229
+ # let Ctrl-C be a plain KeyboardInterrupt so the summary below prints (review 5)
230
+ import signal
227
231
  try:
228
232
  while True:
229
- time.sleep(args.watch)
233
+ previous = signal.signal(signal.SIGINT, signal.default_int_handler)
234
+ try:
235
+ time.sleep(args.watch)
236
+ finally:
237
+ signal.signal(signal.SIGINT, previous)
230
238
  results = one_pass()
231
239
  except KeyboardInterrupt:
232
- pass
240
+ info("watch stopped")
233
241
  done = sum(1 for r in results if r["ok"])
234
242
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
235
243
  if not args.json:
@@ -487,9 +487,17 @@ def main() -> int:
487
487
  if args.transcribe:
488
488
  if not args.input:
489
489
  die("--transcribe needs the input video")
490
- srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
491
- cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
492
- info(f"wrote {srt_path} ({len(cues)} cues)")
490
+ # the sidecar goes next to the output like the --text one, not into the source folder
491
+ # where it silently replaced a hand-written <input>.srt (review 5)
492
+ srt_path = args.write_srt or os.path.splitext(args.output or default_output(args.input, "captioned"))[0] + ".srt"
493
+ if STATE.dry_run:
494
+ cues = []
495
+ info(f"[dry-run] would transcribe {args.input} and write {srt_path}")
496
+ else:
497
+ if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
498
+ info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
499
+ cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
500
+ info(f"wrote {srt_path} ({len(cues)} cues)")
493
501
  args.text = None
494
502
  if args.text:
495
503
  cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
@@ -515,7 +523,7 @@ def main() -> int:
515
523
  output = args.output or default_output(args.input, "captioned")
516
524
 
517
525
  if args.mode == "mux":
518
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
526
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
519
527
  die(f"SRT file not found: {srt_path}")
520
528
  codec = mux_subtitle_codec(output)
521
529
  # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
@@ -543,7 +551,7 @@ def main() -> int:
543
551
  return 0
544
552
 
545
553
  if (args.animate != "none" or args.karaoke) and not args.ass:
546
- cues_for_ass = cues if args.text else parse_srt(srt_path)
554
+ cues_for_ass = cues if (args.text or args.transcribe) else parse_srt(srt_path)
547
555
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
548
556
  w, h = meta["video"]["width"], meta["video"]["height"]
549
557
  if meta["video"].get("rotation") in (90, -90, 270, -270):
@@ -563,7 +571,7 @@ def main() -> int:
563
571
  if args.fonts_dir:
564
572
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
565
573
  else:
566
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
574
+ if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
567
575
  die(f"SRT file not found: {srt_path}")
568
576
  style = [
569
577
  f"FontName={ass_font_name(args.font)}",
package/scripts/check.py CHANGED
@@ -127,7 +127,7 @@ def main() -> int:
127
127
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
128
128
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
129
129
  if spec["codecs"]:
130
- row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"),
130
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube").replace("broadcast", "prores"),
131
131
  reason="the platform's player may refuse to decode this codec at all, not just look worse")
132
132
  pf = v.get("pix_fmt") or ""
133
133
  if args.platform in ("reels", "tiktok", "x", "linkedin"):
package/scripts/color.py CHANGED
@@ -22,7 +22,7 @@ import os
22
22
  import sys
23
23
  from typing import List
24
24
 
25
- from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
25
+ from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
26
26
 
27
27
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
28
28
 
@@ -293,7 +293,7 @@ def main() -> int:
293
293
  tag = "correct"
294
294
  # OBSERVED technical measurements (signalstats: luma / saturation distribution), never a
295
295
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
296
- measurements = {"input": analyze_levels(args.input)}
296
+ measurements = None if STATE.dry_run else {"input": analyze_levels(args.input)}
297
297
  else:
298
298
  if v.get("hdr") and not args.force:
299
299
  die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
package/scripts/fit.py CHANGED
@@ -239,12 +239,13 @@ def main() -> int:
239
239
  else:
240
240
  cmd += ["-an"]
241
241
  cmd += post
242
- if abs(factor - 1.0) > 1e-4:
242
+ if abs(factor - 1.0) > 1e-4 or "-ss" in pre_input:
243
243
  # A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
244
244
  # original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
245
245
  # no equivalent way to retime a copied subtitle track, so it would desync from the
246
246
  # now-faster/slower picture. Drop them here rather than ship a captions track that lies
247
- # about when a line is spoken.
247
+ # about when a line is spoken. --method trim's -ss moves the timeline the same way: a copied
248
+ # track kept its cues at the source's times and doubled the output's length (review 5).
248
249
  run(cmd + [output])
249
250
  dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
250
251
  else:
package/scripts/join.py CHANGED
@@ -68,7 +68,7 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
68
68
  cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
69
69
  run(cmd)
70
70
  expected = sum(durs) - d * (n - 1)
71
- r = probe(output)
71
+ r = probe(output, role="output")
72
72
  a = r.get("audio") or {}
73
73
  if not STATE.dry_run:
74
74
  if r.get("video"):
@@ -77,6 +77,8 @@ def main() -> int:
77
77
  die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
78
78
  if args.fps is not None and args.fps <= 0:
79
79
  die(f"--fps must be positive, got {args.fps:g}")
80
+ if not 0 <= args.audio < len(args.inputs):
81
+ die(f"--audio {args.audio}: inputs are numbered 0..{len(args.inputs) - 1}")
80
82
 
81
83
  n = len(args.inputs)
82
84
  if n < 2:
package/scripts/render.py CHANGED
@@ -55,7 +55,7 @@ import sys
55
55
  from pathlib import Path
56
56
  from typing import Any, Dict, List
57
57
 
58
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output
58
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input
59
59
 
60
60
  HERE = Path(__file__).resolve().parent
61
61
 
@@ -133,6 +133,8 @@ def main() -> int:
133
133
  if not clips:
134
134
  die("project.clips is empty")
135
135
  output = rel(proj.get("output") or "final.mp4")
136
+ # the final stage is a copy from the work dir, so run()'s own guard never sees the sources (review 5)
137
+ refuse_output_is_input(output, *[rel(c.get("src")) for c in clips if c.get("src")])
136
138
  # The default work dir name comes only from the output path, with no PID or timestamp --
137
139
  # two concurrent render.py runs targeting the same output (a batch.py "project" recipe
138
140
  # processing several files in parallel, or simply running render.py twice by mistake) shared
@@ -363,7 +365,7 @@ def main() -> int:
363
365
  else:
364
366
  if not STATE.dry_run:
365
367
  place_output(current, output)
366
- info(f"copied final stage to {output}")
368
+ info(("[dry-run] would copy" if STATE.dry_run else "copied") + f" final stage to {output}")
367
369
  current = output
368
370
 
369
371
  # ---- check
@@ -1,4 +1,3 @@
1
- import re
2
1
  #!/usr/bin/env python3
3
2
  """Turn a numbered image sequence into a video.
4
3
 
@@ -16,6 +15,7 @@ Examples:
16
15
  python3 sequence.py --dir frames --pattern "*.png" --fps 30 --start-number 1
17
16
  """
18
17
  import argparse
18
+ import re
19
19
  import sys
20
20
  import tempfile
21
21
  from pathlib import Path