ffmpeg-skill 1.3.1 → 1.4.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/README.md +13 -7
- package/SKILL.md +6 -3
- package/package.json +2 -2
- package/references/process-pitfalls.md +22 -0
- package/references/scripts.md +18 -1
- package/scripts/_common.py +101 -4
- package/scripts/_contract.py +6 -2
- package/scripts/audio.py +14 -3
- package/scripts/broll.py +152 -0
package/README.md
CHANGED
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
15
|
<a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
|
|
16
|
+
<a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
|
|
16
17
|
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
|
|
17
|
-
<img src="https://img.shields.io/
|
|
18
|
-
<img src="https://img.shields.io/
|
|
18
|
+
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/dm/ffmpeg-skill" alt="npm downloads"></a>
|
|
19
|
+
<a href="https://github.com/kajisho5/ffmpeg-skill/stargazers"><img src="https://img.shields.io/github/stars/kajisho5/ffmpeg-skill" alt="GitHub stars"></a>
|
|
20
|
+
<a href="https://github.com/kajisho5/ffmpeg-skill/commits/main"><img src="https://img.shields.io/github/last-commit/kajisho5/ffmpeg-skill" alt="last commit"></a>
|
|
21
|
+
<img src="https://img.shields.io/badge/python-3.9%20%7C%203.13-blue" alt="Python 3.9 and 3.13 tested">
|
|
22
|
+
<a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-5.1%20%7C%206.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 5.1, 6.1, 7.1, 8 and 9 tested in CI"></a>
|
|
19
23
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
20
24
|
<a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
|
|
21
25
|
</p>
|
|
@@ -26,7 +30,7 @@ npx ffmpeg-skill
|
|
|
26
30
|
|
|
27
31
|

|
|
28
32
|
|
|
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 **
|
|
33
|
+
`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
34
|
|
|
31
35
|
If `ffmpeg` and `python3` are on your PATH, it works: offline, on footage you would rather not upload.
|
|
32
36
|
|
|
@@ -139,8 +143,8 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
139
143
|
|
|
140
144
|
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
145
|
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
|
-
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)
|
|
143
|
-
4. **Machine-readable contract.** `contract --json` describes all
|
|
146
|
+
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), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
147
|
+
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
148
|
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
149
|
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
150
|
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 +153,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
149
153
|
|
|
150
154
|
## Tools
|
|
151
155
|
|
|
152
|
-
|
|
156
|
+
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
157
|
|
|
154
158
|
**Analysis and inspection**
|
|
155
159
|
|
|
@@ -184,6 +188,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
184
188
|
| `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
189
|
| `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
190
|
| `loop.py` | Repeat a clip `--times` N or to a target `--duration` — for background loops and filling a fixed slot length |
|
|
191
|
+
| `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
192
|
| `metadata.py` | Write container chapter markers from a `TIME TITLE` text file and title/artist/comment tags, every stream copied bit for bit |
|
|
188
193
|
| `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
194
|
|
|
@@ -273,7 +278,7 @@ npx ffmpeg-skill contract --json # or: python3 scripts/_contract.py -
|
|
|
273
278
|
npx ffmpeg-skill contract --json --static # without environment detection
|
|
274
279
|
```
|
|
275
280
|
|
|
276
|
-
The contract is generated from the code that runs, not maintained beside it. For each of the
|
|
281
|
+
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
282
|
|
|
278
283
|
| Field | Meaning |
|
|
279
284
|
|---|---|
|
|
@@ -335,6 +340,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
|
|
|
335
340
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
336
341
|
| **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
|
|
337
342
|
| **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
|
|
343
|
+
| **36 / 36** | 1.4.0 re-run (2026-09-11, one pass per prompt, Sonnet agent, regex grader + manual review): 24-prompt set routing 20/20, honest refusals 5/5, visual check 8/8, user's language 8/9; exec set real execution 6/6, honest failure on bad inputs 5/5 with 0 false successes, audio-as-audio 3/3; trigger set 22/22. Details and the six findings in `evals/results/iteration-5.json` |
|
|
338
344
|
| **6 / 6** | 0.9.1 audio evals (audio join, extraction, track selection, sample-accurate trim, typed dynamics; 2 in Japanese): routing, report format and audio-as-audio handling 6/6 |
|
|
339
345
|
|
|
340
346
|
```bash
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
|
|
9
9
|
|
|
10
10
|
## Workflow (always follow this order)
|
|
11
11
|
|
|
@@ -41,7 +41,9 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
41
41
|
be a placeholder, not a computed preview — see `docs/contract.md`). Use
|
|
42
42
|
them to confirm a plan before long encodes and to report exact facts.
|
|
43
43
|
`--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
|
|
44
|
-
prints percent and ETA on stderr for long encodes.
|
|
44
|
+
prints percent and ETA on stderr for long encodes. Never point `-o` at a file
|
|
45
|
+
you did not create in this job unless the user asked for it to be replaced;
|
|
46
|
+
pass `--overwrite` only then.
|
|
45
47
|
4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
46
48
|
join → silence → fit → caption/overlay → sync → audio → loudness → export.
|
|
47
49
|
Do frame changes (fit/crop) before captions and overlays so text is sized
|
|
@@ -118,7 +120,7 @@ This skill cuts, joins, measures, syncs, exports and checks files — it execute
|
|
|
118
120
|
|
|
119
121
|
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
122
|
|
|
121
|
-
If a request needs an FFmpeg feature none of the
|
|
123
|
+
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
124
|
|
|
123
125
|
## Request → script
|
|
124
126
|
|
|
@@ -149,6 +151,7 @@ If a request needs an FFmpeg feature none of the 41 scripts expose, say so and n
|
|
|
149
151
|
| "add some black at the start before the title card" | `pad.py clip.mp4 --start 1.5` |
|
|
150
152
|
| "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
153
|
| "loop this background clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
|
|
154
|
+
| "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
155
|
| "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
156
|
| "set the title / artist / comment on the file" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
|
|
154
157
|
| "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.
|
|
4
|
-
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor:
|
|
3
|
+
"version": "1.4.1",
|
|
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",
|
|
@@ -158,3 +158,25 @@ inputs (or `Unexpected input(s)` in the first log) before trusting a parameter,
|
|
|
158
158
|
any step whose output decides an irreversible action as something to unit-test with fixed
|
|
159
159
|
inputs, not something to confirm by reading its YAML. And watch the first real run's
|
|
160
160
|
*effect* (tags, npm), which is how both incidents were actually noticed.
|
|
161
|
+
|
|
162
|
+
### The release bump step required the literal `(nothing yet)` line under `## Unreleased`
|
|
163
|
+
|
|
164
|
+
Found on the first run after #163 (2026-09-11). `release.yml`'s auto-bump located the CHANGELOG
|
|
165
|
+
insertion point with `assert "## Unreleased\n\n(nothing yet)\n\n" in changelog`. #163 did the
|
|
166
|
+
natural thing and wrote its notes under Unreleased, so the bump step failed on the assert
|
|
167
|
+
before the push, the tag or the publish -- a clean no-op, but a red run and no release. The
|
|
168
|
+
script now takes whatever sits under Unreleased into the new version's section and puts the
|
|
169
|
+
placeholder back, so hand-written notes are welcome there. Lesson: an anchor that is also
|
|
170
|
+
prose will be edited; anchor on the heading, not on the placeholder text.
|
|
171
|
+
|
|
172
|
+
### The built-in GITHUB_TOKEN cannot push the release bump through a ruleset
|
|
173
|
+
|
|
174
|
+
Found on the first release after the main ruleset went active (2026-09-11, run for #166):
|
|
175
|
+
`git push origin HEAD:main` from release.yml was declined with GH013 ("Changes must be made
|
|
176
|
+
through a pull request", "8 of 8 required status checks are expected"). GitHub Actions cannot
|
|
177
|
+
be added as a ruleset bypass actor (the import rejects the actor, the UI does not list it), so
|
|
178
|
+
the bump push now uses the `RELEASE_PUSH_TOKEN` secret -- a fine-grained PAT of a repository
|
|
179
|
+
admin with Contents: read/write on this repo -- whose "Repository admin" bypass applies. A PAT
|
|
180
|
+
push triggers workflows (GITHUB_TOKEN's do not), so the bump commit carries `[skip ci]`; the
|
|
181
|
+
tag, Release and npm publish all happen in the originating run. Rotate the PAT before it
|
|
182
|
+
expires or the next release fails at the same step, cleanly, before anything is published.
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Contents
|
|
6
6
|
- probe.py — inspect
|
|
@@ -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]
|
package/scripts/_common.py
CHANGED
|
@@ -62,8 +62,25 @@ ERROR_CODE = {
|
|
|
62
62
|
"missing_tool": "DEPENDENCY_MISSING",
|
|
63
63
|
"ffmpeg": "FFMPEG_EXECUTION_FAILED",
|
|
64
64
|
"output": "OUTPUT_INVALID",
|
|
65
|
+
"timeout": "TIMEOUT",
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
# Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
|
|
69
|
+
# that deadlocks on a filter combination, a stalled network mount, an input that never ends)
|
|
70
|
+
# used to hang the calling agent with it, with no error document and no way out short of
|
|
71
|
+
# killing the process by hand. The ceiling is generous on purpose: it exists to turn a hang
|
|
72
|
+
# into a reported failure, not to police slow encodes. --timeout and FFMPEG_SKILL_TIMEOUT
|
|
73
|
+
# override it; 0 disables it.
|
|
74
|
+
DEFAULT_TIMEOUT = 1800.0
|
|
75
|
+
PROBE_TIMEOUT = 120.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _env_timeout() -> float:
|
|
79
|
+
try:
|
|
80
|
+
return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
|
|
81
|
+
except ValueError:
|
|
82
|
+
return DEFAULT_TIMEOUT
|
|
83
|
+
|
|
67
84
|
# None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
|
|
68
85
|
# always deterministic (the same bad path or absent binary fails identically every time), and a
|
|
69
86
|
# "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
|
|
@@ -182,8 +199,8 @@ class Context:
|
|
|
182
199
|
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
183
200
|
"""
|
|
184
201
|
|
|
185
|
-
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
186
|
-
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands")
|
|
202
|
+
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
203
|
+
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
|
|
187
204
|
|
|
188
205
|
def __init__(self) -> None:
|
|
189
206
|
self.reset()
|
|
@@ -195,6 +212,9 @@ class Context:
|
|
|
195
212
|
self.fast = False # x264 preset forced to veryfast
|
|
196
213
|
self.duration_hint: Optional[float] = None # expected output length, for the progress percent
|
|
197
214
|
self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
|
|
215
|
+
self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
|
|
216
|
+
self.overwrite = False # --overwrite: an existing output may be replaced
|
|
217
|
+
self.written: set = set() # output paths this process has written itself
|
|
198
218
|
|
|
199
219
|
# mapping-style access kept for backwards compatibility
|
|
200
220
|
def __getitem__(self, key: str) -> Any:
|
|
@@ -221,6 +241,11 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
221
241
|
g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
|
|
222
242
|
g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
|
|
223
243
|
g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
|
|
244
|
+
if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
|
|
245
|
+
g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
246
|
+
help=f"kill any single ffmpeg run that exceeds this many seconds and report kind=timeout (default {DEFAULT_TIMEOUT:.0f}, or FFMPEG_SKILL_TIMEOUT; 0 = no limit)")
|
|
247
|
+
g.add_argument("--overwrite", action="store_true",
|
|
248
|
+
help="allow replacing an output file that already exists (without it a warning is printed today; from 2.0 an existing output is refused, and FFMPEG_SKILL_NO_OVERWRITE=1 opts into that now)")
|
|
224
249
|
|
|
225
250
|
|
|
226
251
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -228,6 +253,9 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
228
253
|
STATE.json = bool(getattr(args, "json", False))
|
|
229
254
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
230
255
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
256
|
+
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
257
|
+
if getattr(args, "timeout", None) is not None:
|
|
258
|
+
STATE.timeout = max(0.0, float(args.timeout))
|
|
231
259
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
232
260
|
args.preset = "veryfast"
|
|
233
261
|
|
|
@@ -312,6 +340,46 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
|
312
340
|
continue
|
|
313
341
|
|
|
314
342
|
|
|
343
|
+
def _check_existing_output(cmd: Sequence[str]) -> None:
|
|
344
|
+
"""An output path that already exists is someone's file: a previous result, a source the
|
|
345
|
+
agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
|
|
346
|
+
a run never blocks on a y/N prompt) would replace it without a word. Until 2.0 this only
|
|
347
|
+
warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
|
|
348
|
+
2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
|
|
349
|
+
process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
|
|
350
|
+
output = cmd[-1]
|
|
351
|
+
if STATE.overwrite or output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
|
|
352
|
+
return
|
|
353
|
+
try:
|
|
354
|
+
exists = os.path.isfile(output)
|
|
355
|
+
real = os.path.realpath(output)
|
|
356
|
+
except OSError:
|
|
357
|
+
return
|
|
358
|
+
if not exists or real in STATE.written:
|
|
359
|
+
return
|
|
360
|
+
if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
|
|
361
|
+
die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
|
|
362
|
+
info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
|
|
363
|
+
f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _remember_output(cmd: Sequence[str]) -> None:
|
|
367
|
+
output = cmd[-1]
|
|
368
|
+
if output == "-" or output.startswith("pipe:") or output.startswith("-"):
|
|
369
|
+
return
|
|
370
|
+
try:
|
|
371
|
+
STATE.written.add(os.path.realpath(output))
|
|
372
|
+
except OSError:
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
377
|
+
_cleanup_partial_output(cmd)
|
|
378
|
+
die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
|
|
379
|
+
f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
|
|
380
|
+
code=124, kind="timeout")
|
|
381
|
+
|
|
382
|
+
|
|
315
383
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
316
384
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
317
385
|
|
|
@@ -322,6 +390,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
322
390
|
is_ffmpeg = _is_ffmpeg(cmd)
|
|
323
391
|
if is_ffmpeg:
|
|
324
392
|
_check_no_overwrite_input(cmd)
|
|
393
|
+
_check_existing_output(cmd)
|
|
325
394
|
STATE.commands.append(_cmdline(cmd))
|
|
326
395
|
if not quiet:
|
|
327
396
|
info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
|
|
@@ -346,9 +415,23 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
346
415
|
return True
|
|
347
416
|
|
|
348
417
|
|
|
418
|
+
def _limit_for(cmd: Sequence[str]) -> Optional[float]:
|
|
419
|
+
"""The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a fixed
|
|
420
|
+
short one, ffmpeg the configured one; None means unlimited."""
|
|
421
|
+
if not _is_ffmpeg(cmd):
|
|
422
|
+
return PROBE_TIMEOUT if STATE.timeout else None
|
|
423
|
+
return STATE.timeout or None
|
|
424
|
+
|
|
425
|
+
|
|
349
426
|
def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
350
427
|
"""Plain run with stdout/stderr captured."""
|
|
351
|
-
|
|
428
|
+
limit = _limit_for(cmd)
|
|
429
|
+
try:
|
|
430
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
|
|
431
|
+
except subprocess.TimeoutExpired:
|
|
432
|
+
_timed_out(cmd, limit or 0)
|
|
433
|
+
if proc.returncode == 0 and _is_ffmpeg(cmd):
|
|
434
|
+
_remember_output(cmd)
|
|
352
435
|
if proc.returncode != 0:
|
|
353
436
|
# Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
|
|
354
437
|
# path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
|
|
@@ -377,10 +460,17 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
377
460
|
total = STATE.duration_hint or 0.0
|
|
378
461
|
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
379
462
|
t0 = time.time()
|
|
463
|
+
limit = _limit_for(cmd)
|
|
380
464
|
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
381
465
|
last = ""
|
|
382
466
|
assert proc.stdout is not None
|
|
383
467
|
for line in proc.stdout:
|
|
468
|
+
if limit and time.time() - t0 > limit:
|
|
469
|
+
proc.kill()
|
|
470
|
+
proc.communicate()
|
|
471
|
+
if last:
|
|
472
|
+
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
473
|
+
_timed_out(cmd, limit)
|
|
384
474
|
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
|
|
385
475
|
try:
|
|
386
476
|
done = int(line.split("=")[1]) / 1_000_000
|
|
@@ -391,9 +481,16 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
|
|
|
391
481
|
sys.stderr.write(msg)
|
|
392
482
|
sys.stderr.flush()
|
|
393
483
|
last = msg
|
|
394
|
-
|
|
484
|
+
try:
|
|
485
|
+
_, err = proc.communicate(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
|
|
486
|
+
except subprocess.TimeoutExpired:
|
|
487
|
+
proc.kill()
|
|
488
|
+
proc.communicate()
|
|
489
|
+
_timed_out(cmd, limit or 0)
|
|
395
490
|
if last:
|
|
396
491
|
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
492
|
+
if proc.returncode == 0:
|
|
493
|
+
_remember_output(cmd)
|
|
397
494
|
if proc.returncode != 0:
|
|
398
495
|
_cleanup_partial_output(cmd)
|
|
399
496
|
if check:
|
package/scripts/_contract.py
CHANGED
|
@@ -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"),
|
|
@@ -325,7 +329,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
325
329
|
props: Dict[str, Any] = {}
|
|
326
330
|
required: List[str] = []
|
|
327
331
|
positional: List[str] = []
|
|
328
|
-
common = {"dry_run", "json", "progress", "fast"}
|
|
332
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite"}
|
|
329
333
|
for action in parser._actions:
|
|
330
334
|
if isinstance(action, argparse._HelpAction):
|
|
331
335
|
continue
|
|
@@ -1033,7 +1037,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1033
1037
|
"json_output": {
|
|
1034
1038
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
1035
1039
|
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
|
|
1036
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH"},
|
|
1040
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124"},
|
|
1037
1041
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1038
1042
|
},
|
|
1039
1043
|
"capabilities": caps,
|
package/scripts/audio.py
CHANGED
|
@@ -196,18 +196,29 @@ def main() -> int:
|
|
|
196
196
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
197
197
|
if args.fade_out and dur:
|
|
198
198
|
post.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
|
|
199
|
-
|
|
199
|
+
# The audio track is conformed to the source duration whenever it is known: padded with
|
|
200
|
+
# silence if the graph came out short, trimmed if long. A mixed track can come out a few
|
|
201
|
+
# hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
|
|
202
|
+
# -shortest below that used to shorten the *video* to match: 12.00 s in, 11.925 s out, four
|
|
203
|
+
# frames of a stream-copied picture gone (#164). Padding the audio, not cutting the picture,
|
|
204
|
+
# is the only correct answer for a tool whose contract says the video is never touched.
|
|
205
|
+
keep_video = has_video and not audio_out
|
|
206
|
+
if dur and (args.replace or args.music or keep_video):
|
|
200
207
|
post.append(f"apad,atrim=0:{dur:.3f}")
|
|
201
208
|
if post:
|
|
202
209
|
graph.append(f"[{last}]{','.join(post)}[out]")
|
|
203
210
|
last = "out"
|
|
204
211
|
|
|
205
212
|
cmd = ffmpeg_base() + inputs + ["-filter_complex", ";".join(graph), "-map", f"[{last}]"]
|
|
206
|
-
if
|
|
213
|
+
if keep_video:
|
|
207
214
|
cmd += ["-map", "0:v:0", "-c:v", "copy"]
|
|
208
215
|
elif has_video:
|
|
209
216
|
cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
|
|
210
|
-
cmd += audio_codec_for(output, args.bitrate)
|
|
217
|
+
cmd += audio_codec_for(output, args.bitrate)
|
|
218
|
+
if not keep_video:
|
|
219
|
+
# audio-only outputs: a looped music bed is infinite, -shortest ends the run with the main track
|
|
220
|
+
cmd.append("-shortest")
|
|
221
|
+
cmd.append(output)
|
|
211
222
|
run(cmd)
|
|
212
223
|
r = probe(output, role="output")
|
|
213
224
|
a = r["audio"]
|
package/scripts/broll.py
ADDED
|
@@ -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())
|