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 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; the full 4x3 sheet is for whole-clip layout jobs. Not finished until `Look:` names that PNG — a probe cannot see a caption on someone's face. Audio-only jobs write `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
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.1.0`) | any release |
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.1.0", "execution_mode": "local", "kind": "execution",
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.1.0",
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",
@@ -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` +
@@ -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="+", help="two or more clips in order")
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