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 +2 -2
- package/package.json +1 -1
- package/references/scripts.md +3 -2
- package/scripts/batch.py +10 -2
- package/scripts/caption.py +14 -6
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -2
- package/scripts/fit.py +3 -2
- package/scripts/join.py +1 -1
- package/scripts/multicam.py +2 -0
- package/scripts/render.py +4 -2
- package/scripts/sequence.py +1 -1
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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`
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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"):
|
package/scripts/multicam.py
CHANGED
|
@@ -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(
|
|
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
|
package/scripts/sequence.py
CHANGED
|
@@ -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
|