ffmpeg-skill 2.12.1 → 2.14.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 +3 -3
- package/bin/install.js +15 -2
- package/docs/contract.md +10 -4
- package/package.json +1 -1
- package/references/scripts.md +15 -0
- package/scripts/_contract.py +24 -3
- package/scripts/overlay.py +79 -3
package/README.md
CHANGED
|
@@ -247,7 +247,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
247
247
|
| Tool | What it does |
|
|
248
248
|
|---|---|
|
|
249
249
|
| `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area — with `--platform`, the destination's own left/right safe zone, which since 1.17.2 is also what the burnt ASS states as its side margins — with a phrase-aware breaker (`--wrap phrase|measured`) and `--max-lines`/`--min-duration`/`--offset`; `--mode mux` takes a repeated `--srt file:lang` for several language-tagged, toggleable tracks in one file; picks a font by script for non-Latin text (`--lang`); animated and word-by-word karaoke timed to the speech energy or real word timings; `--fit-size` shrinks the size until a cue fits `--max-lines` instead of splitting the sentence (on by default on the delivery-template path since 1.17.1, where the size comes from the platform table); says `caption text unchanged` when it burned the cues exactly as given; optional local transcription |
|
|
250
|
-
| `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing |
|
|
250
|
+
| `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing, `--place` to lay ranges of a layer at chosen times |
|
|
251
251
|
| `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs, social stickers, opening hook cards and meme captions drawn by FFmpeg from a brand kit; `--platform NAME` keeps them inside that destination's safe zone; `--text-render` routes shaping scripts through libass and `--emoji-assets` composites colour emoji |
|
|
252
252
|
| `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
|
|
253
253
|
|
|
@@ -420,7 +420,7 @@ The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc
|
|
|
420
420
|
|
|
421
421
|
## FFmpeg compatibility
|
|
422
422
|
|
|
423
|
-
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only)
|
|
423
|
+
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). FFmpeg 6.1+ is recommended: FFmpeg 5.x still works but is deprecated since 2.14.0, `doctor` and the `npx` installer warn on it, and 3.0 requires 6.1. Python 3.10+ is recommended too: 3.9 still works but is deprecated since 2.10.9 (CPython 3.9 is end of life), `doctor` and the `npx` installer warn on it, and 3.0 drops it. What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
|
|
424
424
|
|
|
425
425
|
| FFmpeg | `-filters` row layout | Source |
|
|
426
426
|
|---|---|---|
|
|
@@ -527,7 +527,7 @@ FFmpeg itself:
|
|
|
527
527
|
|
|
528
528
|
## Requirements
|
|
529
529
|
|
|
530
|
-
- FFmpeg 5.0
|
|
530
|
+
- FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended). Always required: `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `scdet`, `silencedetect` and `tile` filters. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
|
|
531
531
|
- Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
|
|
532
532
|
- Node 16+ only for the `npx` installer
|
|
533
533
|
|
package/bin/install.js
CHANGED
|
@@ -55,6 +55,16 @@ function pythonDeprecation(version) {
|
|
|
55
55
|
return version === '3.9' ? PY39_DEPRECATION : null;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// 2.14.0 deprecates FFmpeg 5.x the same way. The same line as scripts/_contract.py's
|
|
59
|
+
// FFMPEG5_DEPRECATION (a test keeps them equal).
|
|
60
|
+
const FFMPEG5_DEPRECATION = 'warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (the 5.x branches date from 2022; Ubuntu 24.04 ships 6.1); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.';
|
|
61
|
+
|
|
62
|
+
// The line for `ffmpeg -version`'s first line, or null (not 5.x, or not a release number).
|
|
63
|
+
function ffmpegDeprecation(versionLine) {
|
|
64
|
+
const m = /^ffmpeg version n?(\d+)\.\d+/.exec(versionLine || '');
|
|
65
|
+
return m && m[1] === '5' ? FFMPEG5_DEPRECATION : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
function pythonCandidates(platform) {
|
|
59
69
|
return platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
|
|
60
70
|
}
|
|
@@ -146,7 +156,10 @@ function checkFfmpeg() {
|
|
|
146
156
|
console.warn(' Windows: winget install Gyan.FFmpeg\n');
|
|
147
157
|
return false;
|
|
148
158
|
}
|
|
149
|
-
|
|
159
|
+
const first = r.stdout.split('\n')[0];
|
|
160
|
+
console.log(` found ${first}`);
|
|
161
|
+
const line = ffmpegDeprecation(first);
|
|
162
|
+
if (line) console.warn(`\n ${line}`);
|
|
150
163
|
return true;
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -211,4 +224,4 @@ if (require.main === module) {
|
|
|
211
224
|
main();
|
|
212
225
|
}
|
|
213
226
|
|
|
214
|
-
module.exports = { pythonCandidates, runWithPython, findPython, pythonDeprecation, warnDeprecatedPython, PY39_DEPRECATION };
|
|
227
|
+
module.exports = { pythonCandidates, runWithPython, findPython, pythonDeprecation, warnDeprecatedPython, PY39_DEPRECATION, ffmpegDeprecation, FFMPEG5_DEPRECATION };
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.14.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`
|
|
@@ -88,6 +88,11 @@ in October 2025 and gets no security fixes, but raising the floor is a break, so
|
|
|
88
88
|
recommended: on 3.9, `doctor` and the `npx` installer print one stderr line,
|
|
89
89
|
`warning: Python 3.9 is deprecated since ffmpeg-skill 2.10.9 (...); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.`
|
|
90
90
|
Like every deprecation warning it is stderr only: `doctor --json`'s document has no key for it.
|
|
91
|
+
Since 2.14.0 there is a second entry, **running on FFmpeg 5.x** (`where: behaviour`, `removed_in: 3.0.0`),
|
|
92
|
+
handled the same way: `requirements.ffmpeg` stays `>=5.0` and CI keeps its 5.1.1 job until 3.0, which
|
|
93
|
+
requires 6.1. When `ffmpeg -version` reads 5.x, `doctor` and the `npx` installer print one stderr line,
|
|
94
|
+
`warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (...); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.`
|
|
95
|
+
A version that is not a release number (a git snapshot) gets no line.
|
|
91
96
|
`tests/test_contract.py` refuses a package.json major bump that still lists an entry that major should remove, or that
|
|
92
97
|
comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's is.
|
|
93
98
|
|
|
@@ -123,12 +128,13 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
123
128
|
```json
|
|
124
129
|
{
|
|
125
130
|
"contract_version": "1.0",
|
|
126
|
-
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}
|
|
131
|
+
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
|
+
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
127
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
128
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.14.0", "execution_mode": "local", "kind": "execution",
|
|
129
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
130
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
131
|
-
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
137
|
+
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
|
132
138
|
"execution": {"shell": false, "arbitrary_executables": false, "network": false,
|
|
133
139
|
"model_downloads": {"when": "...", "in_process": {"faster-whisper": "..."},
|
|
134
140
|
"child_process": {"parakeet-mlx": "...", "openai-whisper": "..."},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/scripts.md
CHANGED
|
@@ -1534,6 +1534,7 @@ stream selection would have picked.
|
|
|
1534
1534
|
overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
|
|
1535
1535
|
[--emoji auto|mono|none] [--emoji-assets DIR]
|
|
1536
1536
|
| --video CLIP [--chromakey COLOR [--chromakey-similarity 0-1] [--chromakey-blend 0-1]]
|
|
1537
|
+
[--place SRC_START-SRC_END@AT ...]
|
|
1537
1538
|
[--position top-right|bottom-left|center|X,Y] [--margin N] [--platform NAME] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
|
|
1538
1539
|
```
|
|
1539
1540
|
`--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
|
|
@@ -1550,6 +1551,20 @@ playing. `--scale` / `--scale-percent` set the width of an image, logo or
|
|
|
1550
1551
|
input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
|
|
1551
1552
|
(with `--video`) keys out that colour first for green-screen compositing.
|
|
1552
1553
|
|
|
1554
|
+
`--place SRC_START-SRC_END@AT` (with `--video`, repeatable) shows only that range
|
|
1555
|
+
of the layer, starting at `AT` on the main timeline; outside its placed ranges
|
|
1556
|
+
the layer is hidden and the main picture is untouched. One pass lays several
|
|
1557
|
+
pieces of one layer at their own times -- e.g. graphics rendered back to back
|
|
1558
|
+
(only the seconds that have graphics) and placed where each belongs:
|
|
1559
|
+
`--video cues.mov --position 0,0 --margin 0 --place 0-2.5@12 --place 2.5-6@40`.
|
|
1560
|
+
An `hh:mm:ss:ff` timecode counts the layer's frames in `SRC_START-SRC_END` and
|
|
1561
|
+
the main video's in `AT` (an `@fps` suffix names the rate explicitly).
|
|
1562
|
+
Refused (`kind: input`): a range that ends before it starts, negative times,
|
|
1563
|
+
two pieces overlapping on the main timeline, a range past the layer's last
|
|
1564
|
+
picture (by more than half a frame), a
|
|
1565
|
+
piece starting after the main video ends, `--place` without `--video`, and
|
|
1566
|
+
`--place` with `--start`/`--end`/`--fade` (the placements already say when).
|
|
1567
|
+
|
|
1553
1568
|
`--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
|
|
1554
1569
|
only at `--end`, so a logo with no `--end` stays to the last frame.
|
|
1555
1570
|
|
package/scripts/_contract.py
CHANGED
|
@@ -987,6 +987,20 @@ def python_deprecation(version_info: Optional[tuple] = None) -> Optional[str]:
|
|
|
987
987
|
return PY39_DEPRECATION if vi == (3, 9) else None
|
|
988
988
|
|
|
989
989
|
|
|
990
|
+
# 2.14.0 deprecates FFmpeg 5.x the same way (DEPRECATED below): 2.x keeps running on it and CI keeps
|
|
991
|
+
# its 5.1.1 job, and the line names 3.0.0 as the release that raises the floor to 6.1. bin/install.js
|
|
992
|
+
# prints it too (FFMPEG5_DEPRECATION there; a test keeps the two equal).
|
|
993
|
+
FFMPEG5_DEPRECATION = ("warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (the 5.x branches date from 2022; "
|
|
994
|
+
"Ubuntu 24.04 ships 6.1); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def ffmpeg_deprecation(version: Optional[str]) -> Optional[str]:
|
|
998
|
+
"""The deprecation line for this ffmpeg's version token, or None (not 5.x, or not a release
|
|
999
|
+
number: a git snapshot gets no line, as with the security advisory)."""
|
|
1000
|
+
v = _parse_ffmpeg_version(version)
|
|
1001
|
+
return FFMPEG5_DEPRECATION if v and v[0] == 5 else None
|
|
1002
|
+
|
|
1003
|
+
|
|
990
1004
|
def _exe_report() -> Dict[str, Any]:
|
|
991
1005
|
"""`ffmpeg_path` / `ffmpeg_source` / `ffprobe_path` / `ffprobe_source`: which binary the tools run
|
|
992
1006
|
and why ("path": what PATH resolves; "homebrew-ffmpeg-full": macOS, a lean Homebrew ffmpeg on
|
|
@@ -1549,6 +1563,12 @@ DEPRECATED: List[Dict[str, str]] = [
|
|
|
1549
1563
|
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1550
1564
|
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1551
1565
|
"replacement": "Python 3.10 or later; on 3.9, doctor and the npx installer print a one-line warning"},
|
|
1566
|
+
# FFmpeg 5.x: the 5.x branches date from 2022 and keep version-specific workarounds alive.
|
|
1567
|
+
# 2.x keeps running on 5.0+ (CI still tests 5.1.1) and doctor / bin/install.js print
|
|
1568
|
+
# FFMPEG5_DEPRECATION; 3.0.0 raises requirements.ffmpeg to 6.1.
|
|
1569
|
+
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)",
|
|
1570
|
+
"since": "2.14.0", "removed_in": "3.0.0", "where": "behaviour",
|
|
1571
|
+
"replacement": "FFmpeg 6.1 or later; on 5.x, doctor and the npx installer print a one-line warning"},
|
|
1552
1572
|
]
|
|
1553
1573
|
|
|
1554
1574
|
# What 2.0.0 removed or changed, with the version that deprecated each: the "Removed" half of the
|
|
@@ -1787,7 +1807,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1787
1807
|
},
|
|
1788
1808
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
1789
1809
|
},
|
|
1790
|
-
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
|
|
1810
|
+
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
|
|
1791
1811
|
"execution": {
|
|
1792
1812
|
"shell": False,
|
|
1793
1813
|
"arbitrary_executables": False,
|
|
@@ -1872,8 +1892,9 @@ def main() -> int:
|
|
|
1872
1892
|
# Advisories, never failures: on stderr in both modes, so `doctor --json`'s stdout stays one
|
|
1873
1893
|
# JSON document. The FFmpeg ones are also in the document (`ffmpeg_security.warnings`); the
|
|
1874
1894
|
# Python 3.9 deprecation is stderr-only, as every deprecation warning is (docs/contract.md
|
|
1875
|
-
# "Deprecation policy" step 1), and machine-readable in `contract --json`'s `deprecated
|
|
1876
|
-
|
|
1895
|
+
# "Deprecation policy" step 1), and machine-readable in `contract --json`'s `deprecated`;
|
|
1896
|
+
# so is the FFmpeg 5.x one.
|
|
1897
|
+
for line in [python_deprecation(), ffmpeg_deprecation(d.get("ffmpeg"))] + d["ffmpeg_security"]["warnings"]:
|
|
1877
1898
|
if line:
|
|
1878
1899
|
print(line, file=sys.stderr)
|
|
1879
1900
|
if args.json:
|
package/scripts/overlay.py
CHANGED
|
@@ -7,7 +7,10 @@ bottom-right, or explicit "X,Y" pixels (negative counts from the far edge).
|
|
|
7
7
|
|
|
8
8
|
--video composites a second VIDEO as a picture-in-picture layer (position,
|
|
9
9
|
scale, opacity, time-range -- same knobs as --image), instead of a still
|
|
10
|
-
image or text.
|
|
10
|
+
image or text. --place SRC_START-SRC_END@AT (repeatable, with --video) shows only
|
|
11
|
+
that range of the layer, starting at AT on the main timeline: one pass lays
|
|
12
|
+
several pieces of a layer (say, graphics rendered back to back) at their own
|
|
13
|
+
times, and the main picture shows untouched everywhere else. Only the main input's audio is kept; the PiP layer's own
|
|
11
14
|
audio track, if any, is dropped -- mixing two audio tracks is a job for
|
|
12
15
|
audio.py, not this tool. --chromakey COLOR (with --video) turns that colour
|
|
13
16
|
transparent first (green-screen removal) before compositing.
|
|
@@ -19,6 +22,7 @@ Examples:
|
|
|
19
22
|
python3 overlay.py input.mp4 --text "こんにちは" --font-file /path/NotoSansCJK-Bold.ttc --box
|
|
20
23
|
python3 overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480 --opacity 0.9
|
|
21
24
|
python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
|
|
25
|
+
python3 overlay.py talk.mp4 --video cues.mov --position 0,0 --margin 0 --place 0-2.5@12 --place 2.5-6@40
|
|
22
26
|
"""
|
|
23
27
|
import argparse
|
|
24
28
|
import re
|
|
@@ -140,6 +144,34 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
|
|
|
140
144
|
return ",".join(filters)
|
|
141
145
|
|
|
142
146
|
|
|
147
|
+
# one time inside --place: seconds, mm:ss, hh:mm:ss.ms or hh:mm:ss:ff with an optional @fps
|
|
148
|
+
# suffix (the time grammar's own); times are never negative, so '-' only separates the range
|
|
149
|
+
_PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float]) -> List[tuple]:
|
|
153
|
+
"""--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
|
|
154
|
+
overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
|
|
155
|
+
SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
|
|
156
|
+
layer's frames; AT is a position on the main timeline and counts the main video's."""
|
|
157
|
+
out = []
|
|
158
|
+
for spec in specs:
|
|
159
|
+
m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
|
|
160
|
+
if not m:
|
|
161
|
+
die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
|
|
162
|
+
a = time_arg(m.group(1), "--place", layer_fps)
|
|
163
|
+
b = time_arg(m.group(2), "--place", layer_fps)
|
|
164
|
+
at = time_arg(m.group(3), "--place", main_fps)
|
|
165
|
+
if b <= a:
|
|
166
|
+
die(f"--place {spec!r}: the layer range ends before it starts")
|
|
167
|
+
out.append((a, b, at))
|
|
168
|
+
spans = sorted((at, at + (b - a)) for a, b, at in out)
|
|
169
|
+
for (s1, e1), (s2, _e2) in zip(spans, spans[1:]):
|
|
170
|
+
if s2 < e1 - 1e-6:
|
|
171
|
+
die(f"--place: pieces overlap on the main timeline ({s1:g}-{e1:g} s and from {s2:g} s)")
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
143
175
|
def main() -> int:
|
|
144
176
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
145
177
|
ap.add_argument("input")
|
|
@@ -153,6 +185,10 @@ def main() -> int:
|
|
|
153
185
|
src.add_argument("--text", help="text to draw (drawtext)")
|
|
154
186
|
src.add_argument("--logo", action="store_true", help="composite the brand logo from --brand (position/scale/opacity from brand.json)")
|
|
155
187
|
src.add_argument("--video", help="a second video to composite as a picture-in-picture layer")
|
|
188
|
+
ap.add_argument("--place", action="append", default=[], metavar="SRC_START-SRC_END@AT",
|
|
189
|
+
help="with --video, repeatable: show this range of the layer starting at AT on the main "
|
|
190
|
+
"timeline (e.g. 0-2.5@12); the layer is hidden outside its placed ranges. Times in "
|
|
191
|
+
"seconds or HH:MM:SS. Not with --start/--end/--fade")
|
|
156
192
|
ck = ap.add_argument_group("chroma key (with --video)")
|
|
157
193
|
ck.add_argument("--chromakey", help="colour to key out (green-screen removal), e.g. 0x00ff00 or green")
|
|
158
194
|
ck.add_argument("--chromakey-similarity", type=float, default=0.15, help="how close a pixel must be to --chromakey to become transparent, 0..1 (default 0.15)")
|
|
@@ -304,6 +340,10 @@ def main() -> int:
|
|
|
304
340
|
die("--chromakey-similarity must be within (0, 1]")
|
|
305
341
|
if not 0 <= args.chromakey_blend <= 1:
|
|
306
342
|
die("--chromakey-blend must be within 0..1")
|
|
343
|
+
if args.place and not args.video:
|
|
344
|
+
die("--place needs --video")
|
|
345
|
+
if args.place and (args.start or args.end or args.fade):
|
|
346
|
+
die("--place sets when each piece shows: drop --start / --end / --fade")
|
|
307
347
|
|
|
308
348
|
output = args.output or default_output(args.input, "overlay")
|
|
309
349
|
enable = enable_expr(start, end)
|
|
@@ -355,6 +395,24 @@ def main() -> int:
|
|
|
355
395
|
pip_meta = probe(args.video)
|
|
356
396
|
if not pip_meta.get("video"):
|
|
357
397
|
die(f"--video {args.video} has no video stream")
|
|
398
|
+
layer_fps = pip_meta["video"].get("fps")
|
|
399
|
+
placements = parse_placements(args.place, fps, layer_fps) if args.place else []
|
|
400
|
+
# bounds from the video streams (a longer audio track would let a range run past the last
|
|
401
|
+
# picture); the container duration only when a stream has none
|
|
402
|
+
layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
|
|
403
|
+
main_dur = meta["video"].get("duration") or meta.get("duration")
|
|
404
|
+
# half a layer frame of slack absorbs a hand-typed end that rounds the last frame's end
|
|
405
|
+
# (2.033 for 61 frames at 30 fps); anything longer asks for pictures the layer does not have
|
|
406
|
+
slack = 0.5 / layer_fps if layer_fps else 0.02
|
|
407
|
+
for a, b, at in placements:
|
|
408
|
+
if layer_dur and a >= layer_dur:
|
|
409
|
+
# the slack below is for an end rounded past the last frame; a start there selects
|
|
410
|
+
# no picture at all and the "placed" piece would silently never show
|
|
411
|
+
die(f"--place {a:g}-{b:g}@{at:g}: starts at or after the layer's end ({layer_dur:.3f} s)")
|
|
412
|
+
if layer_dur and b > layer_dur + slack:
|
|
413
|
+
die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
|
|
414
|
+
if main_dur and at >= main_dur:
|
|
415
|
+
die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
|
|
358
416
|
chain = []
|
|
359
417
|
if args.scale_percent:
|
|
360
418
|
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
|
|
@@ -376,8 +434,26 @@ def main() -> int:
|
|
|
376
434
|
base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
|
|
377
435
|
if base or pip:
|
|
378
436
|
base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
|
|
379
|
-
|
|
380
|
-
|
|
437
|
+
if placements:
|
|
438
|
+
# each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
|
|
439
|
+
# the main picture untouched once a piece ends (the default would freeze its last frame)
|
|
440
|
+
pieces = len(placements)
|
|
441
|
+
# trim selects by timestamp: start the layer's at 0 so SRC_START/SRC_END count from its
|
|
442
|
+
# first picture even when its video stream starts later than the file (audio first)
|
|
443
|
+
fc = f"[1:v]setpts=PTS-STARTPTS,{pip}{','.join(chain)},split={pieces}" + "".join(f"[p{i}]" for i in range(pieces)) + ";"
|
|
444
|
+
for i, (a, b, at) in enumerate(placements):
|
|
445
|
+
fc += f"[p{i}]trim=start={a:.6f}:end={b:.6f},setpts=PTS-STARTPTS+{at:.6f}/TB[q{i}];"
|
|
446
|
+
prev = f"[0:v]{base}null[m]" if base else None
|
|
447
|
+
if prev:
|
|
448
|
+
fc += prev + ";"
|
|
449
|
+
cur = "[m]" if base else "[0:v]"
|
|
450
|
+
for i in range(pieces):
|
|
451
|
+
nxt = "[out]" if i == pieces - 1 else f"[m{i}]"
|
|
452
|
+
fc += f"{cur}[q{i}]{ov}:eof_action=pass{nxt}" + ("" if i == pieces - 1 else ";")
|
|
453
|
+
cur = nxt
|
|
454
|
+
else:
|
|
455
|
+
fc = (f"[1:v]{pip}{','.join(chain)}[ov];"
|
|
456
|
+
+ (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
|
|
381
457
|
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
382
458
|
if meta.get("duration"):
|
|
383
459
|
# See the --image branch above: -t (exact, bounds only the main input) instead of
|