ffmpeg-skill 2.1.0 → 2.2.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/SKILL.md +2 -2
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +10 -1
- package/scripts/_contract.py +2 -1
- package/scripts/join.py +96 -6
package/README.md
CHANGED
|
@@ -203,7 +203,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
203
203
|
| Tool | What it does |
|
|
204
204
|
|---|---|
|
|
205
205
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
|
|
206
|
-
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio |
|
|
206
|
+
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` |
|
|
207
207
|
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
|
|
208
208
|
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
|
|
209
209
|
| `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
|
package/SKILL.md
CHANGED
|
@@ -21,7 +21,7 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
|
|
|
21
21
|
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
|
|
22
22
|
6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
|
|
23
23
|
7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite` is the one way to say "yes, replace it".
|
|
24
|
-
8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG
|
|
24
|
+
8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
|
|
25
25
|
- **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
|
|
26
26
|
- **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, which belongs to the calling agent — say what you see in one line and let them judge it.
|
|
27
27
|
With no vision capability, write `Look: PATH (pixels not inspected; agent has no image view)` — never claim a picture was inspected when it wasn't, without stalling for a capability that isn't there.
|
|
@@ -136,7 +136,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
|
|
|
136
136
|
| "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied; in a project: `"chapters"`). `--auto-chapters` proposes them from measured pauses/scene cuts (`Chapter N`) |
|
|
137
137
|
| "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
|
|
138
138
|
| "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
|
|
139
|
-
| "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
|
|
139
|
+
| "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` (TTS parts: `--list parts.txt`, `--on-missing skip`) |
|
|
140
140
|
| "several changes to one edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
|
|
141
141
|
| "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages reused (`--from STAGE` starts there) |
|
|
142
142
|
| "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — the cut as a timeline, nothing rendered; read `not_exported` |
|
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.2.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.2.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.2.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
|
@@ -554,7 +554,16 @@ writes the resulting cut list exactly as it does today.
|
|
|
554
554
|
```
|
|
555
555
|
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
|
|
556
556
|
[--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
|
|
557
|
-
|
|
557
|
+
join.py --list parts.txt [--on-missing fail|skip] [...]
|
|
558
|
+
```
|
|
559
|
+
`--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
|
|
560
|
+
file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
|
|
561
|
+
segments a TTS or render step wrote. Every input -- listed or given as arguments -- is checked
|
|
562
|
+
before anything runs: missing, empty (0 bytes) or unreadable as media. By default all of them
|
|
563
|
+
are named in one refusal (`kind: input`, `problems: [{index, path, reason}]`, nothing encoded);
|
|
564
|
+
`--on-missing skip` joins the usable ones and reports the rest under `skipped` (always present,
|
|
565
|
+
`[]` when nothing was). A list with no entries is refused as such, and a skip that leaves fewer
|
|
566
|
+
than two inputs is refused rather than "joining" one file.
|
|
558
567
|
Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
|
|
559
568
|
channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
|
|
560
569
|
silent track generated for clips without audio), then chains `xfade` +
|
package/scripts/_contract.py
CHANGED
|
@@ -453,7 +453,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
453
453
|
elif name == "join":
|
|
454
454
|
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
|
|
455
455
|
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
456
|
-
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}
|
|
456
|
+
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
|
|
457
|
+
"skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"}}
|
|
457
458
|
elif name == "audio":
|
|
458
459
|
extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
|
|
459
460
|
"audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
|
package/scripts/join.py
CHANGED
|
@@ -18,16 +18,25 @@ Examples:
|
|
|
18
18
|
python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
|
|
19
19
|
python3 join.py intro.wav talk.m4a outro.wav -o episode.flac # audio join, 0.5 s crossfade
|
|
20
20
|
python3 join.py part1.wav part2.wav --transition none -o full.wav # butt join, sample rate of part1
|
|
21
|
+
python3 join.py --list parts.txt --transition none -o voice.wav # the segments a TTS step wrote, in order
|
|
22
|
+
python3 join.py --list parts.txt --on-missing skip -o voice.wav # join what is there, report the rest
|
|
23
|
+
|
|
24
|
+
Every input is checked before anything is joined: a missing, empty or unreadable segment is
|
|
25
|
+
refused with every problem named at once (kind input), never discovered one run at a time.
|
|
26
|
+
--on-missing skip joins the usable segments instead and lists the others under `skipped`.
|
|
21
27
|
"""
|
|
22
28
|
import argparse
|
|
29
|
+
import os
|
|
23
30
|
import sys
|
|
24
|
-
from typing import List
|
|
31
|
+
from typing import Any, Dict, List
|
|
25
32
|
|
|
26
|
-
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, validate_color, X264_PRESETS, fmt_secs
|
|
33
|
+
from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
27
34
|
|
|
28
35
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
29
36
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
30
37
|
LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
|
|
38
|
+
# inputs --on-missing skip dropped, reported in the success document (empty when nothing was)
|
|
39
|
+
STATE_SKIPPED: List[Dict[str, Any]] = []
|
|
31
40
|
|
|
32
41
|
|
|
33
42
|
def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
@@ -77,14 +86,74 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
77
86
|
die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
|
|
78
87
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
|
|
79
88
|
+ ("crossfade" if d else "butt join") + ")")
|
|
80
|
-
emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
|
|
89
|
+
emit(output, mode="audio", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
|
|
81
90
|
sample_rate=rate, channels=channels, video=False)
|
|
82
91
|
return 0
|
|
83
92
|
|
|
84
93
|
|
|
94
|
+
def read_list(path: str) -> List[str]:
|
|
95
|
+
"""--list FILE: one segment per line, in order, relative to the list file's own folder.
|
|
96
|
+
Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
|
|
97
|
+
(`file 'part 01.wav'`) is accepted, so a list written for `ffmpeg -f concat` works as is."""
|
|
98
|
+
try:
|
|
99
|
+
text = open(path, encoding="utf-8").read()
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
die(f"cannot read --list {path}: {exc}")
|
|
102
|
+
base = os.path.dirname(os.path.abspath(path))
|
|
103
|
+
entries: List[str] = []
|
|
104
|
+
for line in text.splitlines():
|
|
105
|
+
line = line.strip()
|
|
106
|
+
if not line or line.startswith("#"):
|
|
107
|
+
continue
|
|
108
|
+
if line.startswith("file "):
|
|
109
|
+
line = line[5:].strip()
|
|
110
|
+
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"":
|
|
111
|
+
line = line[1:-1].replace("'\\''", "'")
|
|
112
|
+
entries.append(line if os.path.isabs(line) else os.path.join(base, line))
|
|
113
|
+
if not entries:
|
|
114
|
+
# the failure this exists for: an upstream step produced nothing, and the join must
|
|
115
|
+
# say so plainly rather than hand ffmpeg an empty concat and die somewhere later
|
|
116
|
+
die(f"--list {path} names no segments (empty, or only blank lines and comments)",
|
|
117
|
+
hint="check the step that writes the list: it produced no files")
|
|
118
|
+
return entries
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def preflight(paths: List[str]) -> List[Dict[str, Any]]:
|
|
122
|
+
"""Every problem with every input, found before anything runs: missing, empty (0 bytes,
|
|
123
|
+
the usual trace of a TTS or download step that failed after creating its file), or not
|
|
124
|
+
readable as media. One ffprobe per file; nothing is decoded. Under --dry-run a file that
|
|
125
|
+
does not exist yet is not a problem: in a planned pipeline it is an earlier step's output."""
|
|
126
|
+
ffprobe = require_tool("ffprobe")
|
|
127
|
+
problems: List[Dict[str, Any]] = []
|
|
128
|
+
for i, p in enumerate(paths):
|
|
129
|
+
if not os.path.exists(p):
|
|
130
|
+
if STATE.dry_run:
|
|
131
|
+
continue # a dry-run pipeline plans on outputs earlier steps have not written yet
|
|
132
|
+
problems.append({"index": i, "path": p, "reason": "missing"})
|
|
133
|
+
continue
|
|
134
|
+
if os.path.isdir(p):
|
|
135
|
+
problems.append({"index": i, "path": p, "reason": "a directory, not a file"})
|
|
136
|
+
continue
|
|
137
|
+
if os.path.getsize(p) == 0:
|
|
138
|
+
problems.append({"index": i, "path": p, "reason": "empty (0 bytes)"})
|
|
139
|
+
continue
|
|
140
|
+
proc = run([ffprobe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", p],
|
|
141
|
+
quiet=True, check=False)
|
|
142
|
+
kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
|
|
143
|
+
if proc.returncode != 0 or not kinds & {"audio", "video"}:
|
|
144
|
+
first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
|
|
145
|
+
problems.append({"index": i, "path": p, "reason": f"unreadable: {first}"})
|
|
146
|
+
return problems
|
|
147
|
+
|
|
148
|
+
|
|
85
149
|
def main() -> int:
|
|
86
150
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
87
|
-
ap.add_argument("inputs", nargs="
|
|
151
|
+
ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
|
|
152
|
+
ap.add_argument("--list", metavar="FILE", help="read the clips from FILE, one per line in order (paths relative to FILE; "
|
|
153
|
+
"ffmpeg concat lines `file 'x.wav'` also accepted); an empty list is refused")
|
|
154
|
+
ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
|
|
155
|
+
help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
|
|
156
|
+
"(join the rest; each skipped input is reported under `skipped`)")
|
|
88
157
|
ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
|
|
89
158
|
ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
|
|
90
159
|
ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
|
|
@@ -104,8 +173,29 @@ def main() -> int:
|
|
|
104
173
|
if args.fps is not None and args.fps <= 0:
|
|
105
174
|
die(f"--fps must be positive, got {args.fps:g}")
|
|
106
175
|
|
|
176
|
+
if args.list:
|
|
177
|
+
if args.inputs:
|
|
178
|
+
die("give the clips either as arguments or with --list, not both")
|
|
179
|
+
args.inputs = read_list(args.list)
|
|
180
|
+
if not args.inputs:
|
|
181
|
+
die("give at least two clips (or --list FILE)")
|
|
182
|
+
skipped: List[Dict[str, Any]] = []
|
|
183
|
+
problems = preflight(args.inputs)
|
|
184
|
+
if problems and args.on_missing == "fail":
|
|
185
|
+
listing = "; ".join(f"#{p['index'] + 1} {p['path']}: {p['reason']}" for p in problems)
|
|
186
|
+
die(f"{len(problems)} of {len(args.inputs)} inputs cannot be joined: {listing}",
|
|
187
|
+
hint="fix or regenerate them, or pass --on-missing skip to join the rest", problems=problems)
|
|
188
|
+
if problems:
|
|
189
|
+
bad = {p["index"] for p in problems}
|
|
190
|
+
skipped = problems
|
|
191
|
+
args.inputs = [p for i, p in enumerate(args.inputs) if i not in bad]
|
|
192
|
+
for p in problems:
|
|
193
|
+
info(f"skipping #{p['index'] + 1} {p['path']}: {p['reason']}")
|
|
194
|
+
STATE_SKIPPED[:] = skipped
|
|
107
195
|
if len(args.inputs) < 2:
|
|
108
|
-
die("give at least two clips"
|
|
196
|
+
die("give at least two clips" if not skipped else
|
|
197
|
+
f"only {len(args.inputs)} usable input(s) left after skipping {len(skipped)}; nothing to join",
|
|
198
|
+
skipped=skipped)
|
|
109
199
|
validate_color(args.pad_color, "--pad-color")
|
|
110
200
|
metas = [probe(p) for p in args.inputs]
|
|
111
201
|
if all(not m.get("video") for m in metas):
|
|
@@ -206,7 +296,7 @@ def main() -> int:
|
|
|
206
296
|
expected = sum(durs) - d * (n - 1)
|
|
207
297
|
r = probe(output, role="output")
|
|
208
298
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
209
|
-
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3),
|
|
299
|
+
emit(output, mode="video", skipped=list(STATE_SKIPPED), clips=n, transition=args.transition, expected_duration=round(expected, 3),
|
|
210
300
|
dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
|
|
211
301
|
return 0
|
|
212
302
|
|