ffmpeg-skill 1.3.0 → 1.4.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
@@ -26,7 +26,7 @@ npx ffmpeg-skill
26
26
 
27
27
  ![before / after demo](assets/demo.gif)
28
28
 
29
- `ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **41 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also an MCP tool, and the whole set is described by a machine-readable contract.
29
+ `ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **42 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also an MCP tool, and the whole set is described by a machine-readable contract.
30
30
 
31
31
  If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
32
32
 
@@ -140,7 +140,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
140
140
  1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
141
141
  2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
142
142
  3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent and ETA). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
143
- 4. **Machine-readable contract.** `contract --json` describes all 41 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
143
+ 4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 40 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
144
144
  5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
145
145
  6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
146
146
  7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
@@ -149,7 +149,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
149
149
 
150
150
  ## Tools
151
151
 
152
- 41 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
152
+ 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, non-zero exit and a reason on stderr on failure.
153
153
 
154
154
  **Analysis and inspection**
155
155
 
@@ -184,6 +184,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
184
184
  | `pad.py` | Add black/silent padding at the start and/or end of the timeline (`--start`, `--end`) — distinct from `fit.py --fit pad`'s per-frame letterbox bars |
185
185
  | `speedramp.py` | Step through different constant speeds across a clip via `--segment START-END:FACTOR` (repeatable) — distinct from `fit.py`'s single whole-clip speed factor |
186
186
  | `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
187
+ | `broll.py` | Cut away to a B-roll clip over the A-roll for a window (`--insert B --at T --duration D`, repeatable) and come back; A's length and audio untouched by default |
187
188
  | `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit |
188
189
  | `grid.py` | Composite `--cols`x`--rows` clips into one grid, each cell letterboxed and labelled with its filename by default (`--label none` to skip) |
189
190
 
@@ -273,7 +274,7 @@ npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py -
273
274
  npx ffmpeg-skill contract --json --static # without environment detection
274
275
  ```
275
276
 
276
- The contract is generated from the code that runs, not maintained beside it. For each of the 41 tools (`ffmpeg-skill/<name>`) it states:
277
+ The contract is generated from the code that runs, not maintained beside it. For each of the 42 tools (`ffmpeg-skill/<name>`) it states:
277
278
 
278
279
  | Field | Meaning |
279
280
  |---|---|
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". Python 3.9 standard library only, no cloud, no API keys.
3
+ description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". Python 3.9 standard library only, no cloud, no API keys.'
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
@@ -118,7 +118,7 @@ This skill cuts, joins, measures, syncs, exports and checks files — it execute
118
118
 
119
119
  The line in general: if the same input and the same explicit parameters always produce the same, verifiable output, it belongs here. If the "right" answer depends on taste, content understanding, or what looks or sounds good, it belongs to whichever skill or agent makes that judgement — this skill only ever executes parameters it's given, never infers them from what something looks or sounds like.
120
120
 
121
- If a request needs an FFmpeg feature none of the 41 scripts expose, say so and name the closest built-in option (`--dry-run` to show what would run, or a documented limitation) — never fall back to guessing a raw `ffmpeg`/`ffprobe` invocation or a hand-built filter graph outside `scripts/*.py`. A raw command bypasses every guarantee this skill makes (no shell, typed arguments, verification afterwards); it is exactly the failure mode this skill exists to prevent, so it is never the fallback when a script's flag doesn't cover something.
121
+ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and name the closest built-in option (`--dry-run` to show what would run, or a documented limitation) — never fall back to guessing a raw `ffmpeg`/`ffprobe` invocation or a hand-built filter graph outside `scripts/*.py`. A raw command bypasses every guarantee this skill makes (no shell, typed arguments, verification afterwards); it is exactly the failure mode this skill exists to prevent, so it is never the fallback when a script's flag doesn't cover something.
122
122
 
123
123
  ## Request → script
124
124
 
@@ -149,6 +149,7 @@ If a request needs an FFmpeg feature none of the 41 scripts expose, say so and n
149
149
  | "add some black at the start before the title card" | `pad.py clip.mp4 --start 1.5` |
150
150
  | "speed up here, slam into slow-mo there, then speed back up" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
151
151
  | "loop this background clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
152
+ | "cut to the product shot from 0:12 to 0:16, keep my voice underneath", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at` per cutaway; `--audio b|mix` to hear B) |
152
153
  | "add chapters at 0:00 Intro, 2:15 Setup, …", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (one `TIME TITLE` per line; streams are copied, nothing re-encodes) |
153
154
  | "set the title / artist / comment on the file" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
154
155
  | "put these videos in a 4x2 grid with the filename on each" | `grid.py t1.mp4 t2.mp4 t3.mp4 t4.mp4 t5.mp4 t6.mp4 t7.mp4 t8.mp4 --cols 4 --rows 2` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.3.0",
4
- "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 41 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.",
3
+ "version": "1.4.0",
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",
7
7
  "video",
@@ -8,6 +8,8 @@ Every script prints the same information with `--help`; this file exists so the
8
8
  - fit.py — target duration and/or aspect, rotate/flip
9
9
  - crop.py — crop to an exact pixel rectangle
10
10
  - insert.py — still image to a timed silent clip, with Ken Burns zoom/pan
11
+ - broll.py — cut away to a B-roll clip for a window and come back
12
+ - metadata.py — chapter markers and title/artist/comment tags, streams copied
11
13
  - background.py — generate a solid-colour or gradient clip
12
14
  - reverse.py — reverse playback
13
15
  - stabilize.py — motion stabilisation (vidstab)
@@ -287,6 +289,21 @@ loop point (no crossfade at the seam) -- a clip that doesn't already loop
287
289
  cleanly will show a visible cut/pop at each repeat, which is a property of
288
290
  the source material this tool cannot fix.
289
291
 
292
+ ### broll.py — cut away to a B-roll clip and come back
293
+ ```
294
+ broll.py A.mp4 --insert B.mp4 --at T [--duration D | --end T2] [--from T3]
295
+ [--insert ... --at ...] [--audio a|b|mix] [--pad-color black] [-o OUT]
296
+ ```
297
+ A plays as it is; during each window B's picture is shown instead (scaled and
298
+ padded to A's frame, A's fps), and A resumes at its own time when the window
299
+ ends -- a cutaway, not a splice, so the output is exactly as long as A. One
300
+ `--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
301
+ cutaway too, or given once for all; defaults 4 s and 0); windows may not
302
+ overlap or run past A's end, and B must have enough material from `--from`.
303
+ `--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
304
+ it inside each window with B's; `mix` plays both. The output's length is
305
+ verified against A's.
306
+
290
307
  ### metadata.py — chapter markers and container tags, streams copied
291
308
  ```
292
309
  metadata.py INPUT [--chapters chapters.txt | --clear-chapters]
@@ -113,6 +113,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
113
113
  "speedramp": dict(role="execution", inputs=["video asset"], outputs=["video artifact with a stepped speed ramp applied across segments"],
114
114
  required=FF + [X264, AAC], optional=[HDR_X265],
115
115
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
116
+ "broll": dict(role="execution", inputs=["A-roll video asset", "one or more B-roll video assets (--insert)"], outputs=["video artifact of exactly the A-roll's length with the B-roll shown during each cutaway window"],
117
+ required=FF + [X264, AAC, "filter:overlay", "filter:amix"], optional=[HDR_X265],
118
+ video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
116
119
  "metadata": dict(role="execution", inputs=["video or audio asset", "chapters text file (--chapters)"], outputs=["the same streams, stream-copied, with chapter markers and/or title/artist/comment tags written"],
117
120
  required=FF, optional=[],
118
121
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
@@ -243,6 +246,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
243
246
  "freeze": dict(video="always", audio="always", note="the tpad/concat filter graph always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
244
247
  "pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
245
248
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
249
+ "broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
246
250
  "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change"),
247
251
  "loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
248
252
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
@@ -421,7 +425,14 @@ def skill_description() -> str:
421
425
  try:
422
426
  text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
423
427
  m = re.search(r"^description:\s*(.+)$", text, re.M)
424
- return m.group(1).strip() if m else ""
428
+ value = m.group(1).strip() if m else ""
429
+ # The scalar is single-quoted in SKILL.md: unquoted, the ": " inside the text ("...
430
+ # requests: cut, trim ...") is a new mapping key to a strict YAML parser and the whole
431
+ # frontmatter fails to load (GitHub's renderer reported it; npx skills add and Claude
432
+ # Code's loader parse it strictly). '' is the only escape inside a YAML single-quoted scalar.
433
+ if len(value) >= 2 and value[0] == value[-1] == "'":
434
+ value = value[1:-1].replace("''", "'")
435
+ return value
425
436
  except OSError:
426
437
  return ""
427
438
 
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env python3
2
+ """Cut away to clip B over clip A for a while, then come back -- A's timeline unchanged.
3
+
4
+ "Cut to the product shot from 0:12 to 0:16, keep my voice underneath" is the
5
+ classic B-roll instruction. A plays as it is; during each window B's picture is
6
+ shown instead (scaled/padded to A's frame like join.py normalises), and A resumes
7
+ at its own time when the window ends. This is a cutaway, not a splice: the output
8
+ is exactly as long as A, and A is re-encoded once.
9
+
10
+ --insert/--at come in pairs, once per cutaway; --duration (or --end) and --from
11
+ (where in B to start) are per cutaway too and default to 4 s and 0. --audio says
12
+ what plays under a cutaway: `a` (default, A's own audio untouched, stream-copied),
13
+ `b` (B's audio replaces A's inside the window), or `mix` (both).
14
+
15
+ Examples:
16
+ python3 broll.py talk.mp4 --insert product.mp4 --at 12 --duration 4
17
+ python3 broll.py talk.mp4 --insert shot1.mp4 --at 12 --end 16 --insert shot2.mp4 --at 40 --from 2
18
+ python3 broll.py talk.mp4 --insert demo.mp4 --at 30 --duration 8 --audio mix
19
+ """
20
+ import argparse
21
+ import sys
22
+ from typing import Any, Dict, List
23
+
24
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
25
+
26
+
27
+ def main() -> int:
28
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
29
+ ap.add_argument("input", help="the A-roll (its timeline and length are kept)")
30
+ ap.add_argument("-o", "--output", help="output file (default: <name>_broll.<ext>)")
31
+ ap.add_argument("--insert", action="append", required=True, metavar="B", help="B-roll clip (repeat with --at for several cutaways)")
32
+ ap.add_argument("--at", action="append", required=True, metavar="TIME", help="where in A the cutaway starts (seconds or mm:ss); one per --insert")
33
+ ap.add_argument("--duration", action="append", metavar="T", help="cutaway length (default 4); one per --insert, or omit")
34
+ ap.add_argument("--end", action="append", metavar="TIME", help="where in A the cutaway ends, instead of --duration")
35
+ ap.add_argument("--from", dest="from_", action="append", metavar="TIME", help="where in B to start from (default 0); one per --insert, or omit")
36
+ ap.add_argument("--audio", choices=["a", "b", "mix"], default="a", help="under a cutaway: A's audio (default), B's audio, or both mixed")
37
+ ap.add_argument("--pad-color", default="black", help="pad colour when B's aspect differs from A's (default black)")
38
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
39
+ ap.add_argument("--preset", default="medium", help="x264 preset")
40
+ add_common(ap)
41
+ args = ap.parse_args()
42
+ apply_common(args)
43
+
44
+ n = len(args.insert)
45
+ if len(args.at) != n:
46
+ die(f"{n} --insert but {len(args.at)} --at: give one --at per --insert")
47
+ for name, values in (("--duration", args.duration), ("--end", args.end), ("--from", args.from_)):
48
+ if values and len(values) not in (1, n):
49
+ die(f"{name} given {len(values)} times for {n} cutaways: give it once per --insert, or once for all, or not at all")
50
+ if args.duration and args.end:
51
+ die("--duration and --end exclude each other")
52
+
53
+ meta_a = probe(args.input)
54
+ if not meta_a.get("video"):
55
+ die("A-roll has no video stream")
56
+ dur_a = meta_a.get("duration") or 0.0
57
+ w, h = meta_a["video"]["width"], meta_a["video"]["height"]
58
+ fps = meta_a["video"].get("fps") or 30.0
59
+ has_audio_a = bool(meta_a.get("audio"))
60
+
61
+ def per(values: List[str], i: int, default: str) -> str:
62
+ if not values:
63
+ return default
64
+ return values[i] if len(values) == n else values[0]
65
+
66
+ cutaways: List[Dict[str, Any]] = []
67
+ for i, path in enumerate(args.insert):
68
+ meta_b = probe(path)
69
+ if not meta_b.get("video"):
70
+ die(f"{path} has no video stream")
71
+ at = parse_time(args.at[i], fps)
72
+ start_b = parse_time(per(args.from_, i, "0"), fps)
73
+ if args.end:
74
+ end = parse_time(per(args.end, i, "0"), fps)
75
+ length = end - at
76
+ else:
77
+ length = parse_time(per(args.duration, i, "4"), fps)
78
+ if length <= 0:
79
+ die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
80
+ if dur_a and at >= dur_a:
81
+ die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
82
+ if dur_a and at + length > dur_a + 0.01:
83
+ die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
84
+ dur_b = meta_b.get("duration") or 0.0
85
+ if dur_b and start_b + length > dur_b + 0.01 and not STATE["dry_run"]:
86
+ die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
87
+ if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
88
+ die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
89
+ cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio"))})
90
+ if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
91
+ die("--audio b/mix needs audio on every B-roll clip")
92
+
93
+ output = args.output or default_output(args.input, "broll")
94
+ cmd = ffmpeg_base() + ["-i", args.input]
95
+ for c in cutaways:
96
+ cmd += ["-i", c["path"]]
97
+
98
+ # Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
99
+ # --at time, and overlaid on A; before its first frame and after its last (eof_action=pass)
100
+ # A shows through unchanged, so A's timeline is never touched.
101
+ parts: List[str] = []
102
+ cur = "[0:v]"
103
+ for i, c in enumerate(cutaways):
104
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
105
+ parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
106
+ f"{geo},setsar=1,fps={fps:g},format=yuv420p[b{i}]")
107
+ parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
108
+ cur = f"[v{i}]"
109
+ vout = cur
110
+
111
+ # Audio: `a` stream-copies A's track; `b`/`mix` build A (muted inside the windows for `b`)
112
+ # plus each B window delayed to its --at time.
113
+ aout = None
114
+ if has_audio_a or args.audio != "a":
115
+ if args.audio == "a":
116
+ aout = "0:a:0" if has_audio_a else None
117
+ else:
118
+ layers: List[str] = []
119
+ if has_audio_a:
120
+ gate = "".join(f",volume=0:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'" for c in cutaways) if args.audio == "b" else ""
121
+ parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
122
+ layers.append("[a0]")
123
+ for i, c in enumerate(cutaways):
124
+ parts.append(f"[{i + 1}:a:0]atrim=start={c['from']:.3f}:duration={c['length']:.3f},asetpts=PTS-STARTPTS,"
125
+ f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
126
+ layers.append(f"[ab{i}]")
127
+ parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
128
+ aout = "[aout]"
129
+
130
+ cmd += ["-filter_complex", ";".join(parts), "-map", vout]
131
+ if aout:
132
+ cmd += ["-map", aout]
133
+ cmd += video_args(meta_a, args.crf, args.preset) + cfr_args(meta_a, None)
134
+ if aout == "0:a:0":
135
+ cmd += ["-c:a", "copy"]
136
+ elif aout:
137
+ cmd += aac_args()
138
+ else:
139
+ cmd += ["-an"]
140
+ cmd += ["-t", f"{dur_a:.3f}", output]
141
+ run(cmd)
142
+
143
+ result = probe(output, role="output")
144
+ if not STATE["dry_run"] and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
145
+ die(f"output is {result.get('duration'):.3f}s but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
146
+ info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
147
+ emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())