ffmpeg-skill 2.10.5 → 2.10.7
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/bin/install.js +37 -12
- package/docs/contract.md +3 -3
- package/mcp/server.py +55 -7
- package/package.json +1 -1
- package/references/gotchas.md +6 -2
- package/references/scripts.md +21 -8
- package/scripts/_common/decision.py +5 -2
- package/scripts/_common/emoji.py +15 -2
- package/scripts/broll.py +3 -0
- package/scripts/cropdetect.py +5 -0
- package/scripts/fit.py +1 -1
- package/scripts/look.py +5 -2
- package/scripts/overlay.py +15 -4
- package/scripts/render.py +7 -1
- package/scripts/speedramp.py +6 -1
- package/scripts/sync.py +12 -1
package/bin/install.js
CHANGED
|
@@ -41,21 +41,39 @@ if (has('--help') || has('-h')) {
|
|
|
41
41
|
process.exit(0);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
// Windows Python installers commonly expose `python`/`py`, not `python3`. The Microsoft Store
|
|
45
|
+
// app-execution alias is a real python3.exe that spawns (no ENOENT) and exits non-zero, so a
|
|
46
|
+
// missing `error` is not proof an interpreter ran. Probe with a version check, then fall through.
|
|
47
|
+
const PYTHON_PROBE = ['-c', 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'];
|
|
48
|
+
|
|
49
|
+
function pythonCandidates(platform) {
|
|
50
|
+
return platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runWithPython(scriptArgs, platform, spawn) {
|
|
54
|
+
const candidates = pythonCandidates(platform);
|
|
55
|
+
const run = spawn || spawnSync;
|
|
56
|
+
let last = null;
|
|
51
57
|
for (const cmd of candidates) {
|
|
52
|
-
|
|
53
|
-
if (
|
|
58
|
+
const probe = run(cmd, PYTHON_PROBE, { stdio: 'ignore', timeout: 15000 });
|
|
59
|
+
if (probe.error || probe.status !== 0) {
|
|
60
|
+
last = probe;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
return run(cmd, scriptArgs, { stdio: 'inherit' });
|
|
54
64
|
}
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
return { missing: true, error: last && last.error, status: last && last.status, candidates };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function main() {
|
|
69
|
+
// `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
|
|
70
|
+
if (args[0] === 'contract' || args[0] === 'doctor') {
|
|
71
|
+
const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
|
|
72
|
+
if (py.missing) {
|
|
73
|
+
console.error(`error: could not find a Python 3.9+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
|
|
74
|
+
process.exit(127);
|
|
57
75
|
}
|
|
58
|
-
process.exit(py.error ? 127 : py.status);
|
|
76
|
+
process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
const home = os.homedir();
|
|
@@ -159,3 +177,10 @@ if (!has('--uninstall')) {
|
|
|
159
177
|
console.log('\nDone. Ask your agent things like "make this video 60 seconds and add captions".');
|
|
160
178
|
}
|
|
161
179
|
process.exit(failed ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (require.main === module) {
|
|
183
|
+
main();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { pythonCandidates, runWithPython };
|
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.10.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.10.7`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.10.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.10.7", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -613,7 +613,7 @@ Per-tool keys added in 1.18.0, all additive:
|
|
|
613
613
|
| `shots` | `scenes.py --shots` | `[{start, end, label, flow_magnitude}]` per detected scene, `label` one of `static`/`pan`/`motion` from a lightweight block-matching optical-flow proxy (frames decoded at 4 fps, 48x27, no external dependency). A shot too short to sample two frames is `static` with `flow_magnitude: 0` |
|
|
614
614
|
| `audio_peaks_db` | `scenes.py --audio-peaks` | `[{time, level}]`, measured dBFS loudness peaks. A **new** key: the pre-existing `audio_peaks` (always reported, unrelated unitless RMS figures used for `--highlights` scoring) keeps its 1.0 meaning unchanged |
|
|
615
615
|
| `speech` | `scenes.py --speech` | `[{time, speech_music_ratio}]`, a per-second zero-crossing-rate ratio against the file's own median — a measured proxy, not a speech/music classification |
|
|
616
|
-
| `motion_centre` | `cropdetect.py --motion-centre` | `[{time, x, y, x_frac, y_frac, motion}]` per second, sampled over the same windows as the crop-bar detection. `x`/`y` are source pixels, `x_frac`/`y_frac` a 0..1 fraction of `source_width`/`source_height
|
|
616
|
+
| `motion_centre` | `cropdetect.py --motion-centre` | `[{time, x, y, x_frac, y_frac, motion}]` per second, sampled over the same windows as the crop-bar detection. `x`/`y` are source pixels, `x_frac`/`y_frac` a 0..1 fraction of `source_width`/`source_height`. Those two sizes are the displayed frame (a 90° or 270° rotation swaps the stored size), the same frame the crop rectangle and `crop.py` use. A window with no measured motion reports `x`/`y`/`x_frac`/`y_frac: null`. Report only — this tool never picks a reframe |
|
|
617
617
|
| `speech_aware`, `speech_aware.breaths` | `silence.py --speech-aware` | `{min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}`. `breaths` are the sub-`--min-silence` gaps kept because they sit inside a sentence; the removal list (`silences`, `keep`, `removed_seconds`) already reflects the speech-aware classification. Composes with `--filler` through the same `keep_ranges()`/`merge_spans()` pipeline, so `--speech-aware --filler` produces one removal list |
|
|
618
618
|
| `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
|
|
619
619
|
| `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
|
package/mcp/server.py
CHANGED
|
@@ -21,7 +21,7 @@ import os
|
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
|
-
from typing import Any, Dict, List
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
26
|
HERE = Path(__file__).resolve().parent
|
|
27
27
|
SCRIPTS = HERE.parent / "scripts"
|
|
@@ -90,18 +90,66 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
|
90
90
|
return argv
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _timeout_in_argv(argv: List[str]) -> Optional[float]:
|
|
94
|
+
"""The last `--timeout` / `--timeout=N` value in a raw argv list.
|
|
95
|
+
|
|
96
|
+
None when the flag is absent or its value is not a number (the child argparse then fails,
|
|
97
|
+
and the outer ceiling stays the environment default)."""
|
|
98
|
+
found: Optional[float] = None
|
|
99
|
+
seen = False
|
|
100
|
+
i = 0
|
|
101
|
+
n = len(argv)
|
|
102
|
+
while i < n:
|
|
103
|
+
token = argv[i]
|
|
104
|
+
if token == "--":
|
|
105
|
+
break
|
|
106
|
+
if token == "--timeout":
|
|
107
|
+
if i + 1 >= n:
|
|
108
|
+
return None
|
|
109
|
+
raw = argv[i + 1]
|
|
110
|
+
i += 2
|
|
111
|
+
elif token.startswith("--timeout="):
|
|
112
|
+
raw = token.split("=", 1)[1]
|
|
113
|
+
i += 1
|
|
114
|
+
else:
|
|
115
|
+
i += 1
|
|
116
|
+
continue
|
|
117
|
+
seen = True
|
|
118
|
+
try:
|
|
119
|
+
found = float(raw)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
return found if seen else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def timeout_from_call(args: Dict[str, Any]) -> float:
|
|
126
|
+
"""Per-call seconds for the MCP outer ceiling.
|
|
127
|
+
|
|
128
|
+
Structured `timeout` is the canonical key. A raw `argv` list ignores every other key, so
|
|
129
|
+
`--timeout` has to be read from that list: `--timeout 0` otherwise left the ceiling at 4x
|
|
130
|
+
the 1800 s default while the child itself ran with no limit. A negative value is 0, matching
|
|
131
|
+
apply_common (0 means no limit)."""
|
|
132
|
+
if isinstance(args.get("argv"), list):
|
|
133
|
+
parsed = _timeout_in_argv([str(a) for a in args["argv"]])
|
|
134
|
+
if parsed is None:
|
|
135
|
+
return _common._env_timeout()
|
|
136
|
+
return max(0.0, parsed)
|
|
137
|
+
try:
|
|
138
|
+
return max(0.0, float(args.get("timeout", _common._env_timeout())))
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return _common._env_timeout()
|
|
141
|
+
|
|
142
|
+
|
|
93
143
|
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
144
|
script = SCRIPTS / f"{name}.py"
|
|
95
145
|
if name not in specs() or not script.exists():
|
|
96
146
|
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
97
147
|
argv = build_argv(name, args or {})
|
|
98
148
|
# The child enforces --timeout on each ffmpeg call; this outer ceiling (4x that plus 60 s)
|
|
99
|
-
# is the only thing that ends a child hung for any other reason.
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
except (TypeError, ValueError):
|
|
104
|
-
per_call = _common._env_timeout()
|
|
149
|
+
# is the only thing that ends a child hung for any other reason. Structured `timeout` or
|
|
150
|
+
# `--timeout` inside a raw argv list sets both (0 = no limit); otherwise
|
|
151
|
+
# FFMPEG_SKILL_TIMEOUT / the 1800 s default.
|
|
152
|
+
per_call = timeout_from_call(args or {})
|
|
105
153
|
proc = _common.run_tool([str(script)] + argv, per_call=per_call)
|
|
106
154
|
stdout = proc.stdout.strip()
|
|
107
155
|
text = stdout
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.7",
|
|
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/gotchas.md
CHANGED
|
@@ -281,8 +281,12 @@ see what the app covers. An explicit `--margin` always wins.
|
|
|
281
281
|
|
|
282
282
|
### Dimensions and rotation
|
|
283
283
|
`yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
|
|
284
|
-
automatically. Phone footage often carries a `rotation` tag
|
|
285
|
-
|
|
284
|
+
automatically. Phone footage often carries a `rotation` tag. `probe.py` reports
|
|
285
|
+
the stored width and height plus that rotation. A tool that places or measures
|
|
286
|
+
a rectangle (`fit.py`, `crop.py`, `cropdetect.py`, `broll.py`, `overlay.py
|
|
287
|
+
--scale-percent` / `--platform`, `look.py --safe`) uses the displayed size,
|
|
288
|
+
the two swapped for 90° and 270°, because ffmpeg applies the tag before the
|
|
289
|
+
filter graph.
|
|
286
290
|
|
|
287
291
|
## Planning
|
|
288
292
|
|
package/references/scripts.md
CHANGED
|
@@ -242,7 +242,10 @@ cropdetect.py INPUT [--seconds N] [--samples N] [--limit F] [--round N] [--motio
|
|
|
242
242
|
Measurement only -- writes no file. Samples `--samples` windows spread
|
|
243
243
|
across the file (default 5, totalling `--seconds` 10s of footage) and
|
|
244
244
|
reports the crop rectangle FFmpeg's `cropdetect` filter found most often, as
|
|
245
|
-
`{x, y, width, height}` ready to hand to `crop.py`.
|
|
245
|
+
`{x, y, width, height}` ready to hand to `crop.py`. `source_width` /
|
|
246
|
+
`source_height` are that same displayed frame: a 90° or 270° rotation swaps
|
|
247
|
+
the stored size, because the filter measures the picture after ffmpeg applies
|
|
248
|
+
the tag. Distinct from
|
|
246
249
|
`fit.py --fit crop`, which crops to a target aspect ratio it computes
|
|
247
250
|
itself with no black-bar measurement involved. A rectangle that matches the
|
|
248
251
|
full source frame means no bars were found. Does not decide whether
|
|
@@ -501,7 +504,8 @@ broll.py A.mp4 --insert B.mp4 --at T [--duration D | --end T2] [--from T3]
|
|
|
501
504
|
[--insert ... --at ...] [--audio a|b|mix] [--pad-color black] [-o OUT]
|
|
502
505
|
```
|
|
503
506
|
A plays as it is; during each window B's picture is shown instead (scaled and
|
|
504
|
-
padded to A's frame, A's fps
|
|
507
|
+
padded to A's displayed frame, A's fps; a 90° or 270° rotation swaps the
|
|
508
|
+
stored size), and A resumes at its own time when the window
|
|
505
509
|
ends -- a cutaway, not a splice, so the output is exactly as long as A. One
|
|
506
510
|
`--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
|
|
507
511
|
cutaway too, or given once for all; defaults 4 s and 0); windows may not
|
|
@@ -755,10 +759,13 @@ An `overlays[]` entry draws one of `"logo": true`, `"image"`, `"text"` or
|
|
|
755
759
|
`"video"` (#326: a picture-in-picture or green-screen layer, `overlay.py
|
|
756
760
|
--video`), with `position`, `scale`, `opacity`, `start`, `end`, `margin`,
|
|
757
761
|
`platform` (and `fade`, `box`, `font*` for the still/text kinds) mapping to
|
|
758
|
-
overlay.py's flags. `
|
|
759
|
-
`"
|
|
760
|
-
|
|
761
|
-
|
|
762
|
+
overlay.py's flags. `scale` is the layer width in px for a logo, image or
|
|
763
|
+
video (`"scale": 480` is 480 px of the delivered frame, because the stage
|
|
764
|
+
runs after `fit`). A text entry's size is `font_size`; `scale` on a text-only
|
|
765
|
+
entry is refused (`kind: input`) rather than drawn at the default font size.
|
|
766
|
+
`"chromakey": "0x00ff00"`, `"chromakey_similarity"` and
|
|
767
|
+
`"chromakey_blend"` key a colour out of the video layer. A template's
|
|
768
|
+
safe margins apply as they do to a logo. Also refused before the
|
|
762
769
|
first stage: `video` together with `logo`/`image`/`text` in one entry (a
|
|
763
770
|
logo, image and text together still draw the first of them, as before), a
|
|
764
771
|
`chromakey*` key without `video`, `chromakey_similarity`/`_blend` without
|
|
@@ -1217,7 +1224,8 @@ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
|
|
|
1217
1224
|
frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
|
|
1218
1225
|
destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
|
|
1219
1226
|
chrome -- on the sheet or the frame, so "is the caption readable" can be answered about the app
|
|
1220
|
-
rather than about the file.
|
|
1227
|
+
rather than about the file. The "wrong aspect" note compares the displayed frame, so a phone
|
|
1228
|
+
file stored sideways is not flagged when the picture already matches the destination.
|
|
1221
1229
|
|
|
1222
1230
|
### caption.py — subtitles (static, animated, karaoke)
|
|
1223
1231
|
```
|
|
@@ -1463,8 +1471,13 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
|
|
|
1463
1471
|
`--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
|
|
1464
1472
|
(`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
|
|
1465
1473
|
sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
|
|
1474
|
+
`--scale-percent` and those margins use the displayed frame width and height:
|
|
1475
|
+
a 90° or 270° rotation swaps the stored size, which is the frame the overlay
|
|
1476
|
+
is composited on.
|
|
1466
1477
|
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
|
|
1467
|
-
playing. `--
|
|
1478
|
+
playing. `--scale` / `--scale-percent` set the width of an image, logo or
|
|
1479
|
+
`--video` layer. They are refused with `--text`: text size is `--font-size`.
|
|
1480
|
+
`--video` composites a second video as a picture-in-picture layer
|
|
1468
1481
|
(same position/scale/opacity/time-range knobs as `--image`); only the main
|
|
1469
1482
|
input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
|
|
1470
1483
|
(with `--video`) keys out that colour first for green-screen compositing.
|
|
@@ -244,7 +244,7 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
|
244
244
|
return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
|
|
245
245
|
|
|
246
246
|
|
|
247
|
-
def escape_filter_path(path: str) -> str:
|
|
247
|
+
def escape_filter_path(path: str, *, bind: bool = True) -> str:
|
|
248
248
|
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
249
249
|
|
|
250
250
|
A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
|
|
@@ -258,8 +258,11 @@ def escape_filter_path(path: str) -> str:
|
|
|
258
258
|
token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
|
|
259
259
|
filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
|
|
260
260
|
(measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
|
|
261
|
+
|
|
262
|
+
`bind=False` escapes without recording the path as a plan input. The colour-emoji probe
|
|
263
|
+
uses that: its SRT lives in a temp directory that is gone before any plan is written.
|
|
261
264
|
"""
|
|
262
|
-
if os.path.isfile(path) and path not in STATE.plan_inputs:
|
|
265
|
+
if bind and os.path.isfile(path) and path not in STATE.plan_inputs:
|
|
263
266
|
STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
|
|
264
267
|
p = str(Path(path))
|
|
265
268
|
p = p.replace("\\", "/")
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -8,6 +8,7 @@ import os
|
|
|
8
8
|
import shutil
|
|
9
9
|
import subprocess
|
|
10
10
|
from typing import Any, Dict, List, Tuple, Optional
|
|
11
|
+
from _common.decision import escape_filter_path
|
|
11
12
|
from _common.emit import die
|
|
12
13
|
from _common.runner import ffmpeg_exe
|
|
13
14
|
|
|
@@ -165,6 +166,18 @@ EMOJI_ASSET_HINT = (
|
|
|
165
166
|
_EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
def _fc_list_path(line: str) -> str:
|
|
170
|
+
"""The file from one `fc-list ... file` line.
|
|
171
|
+
|
|
172
|
+
fontconfig prints `<file>: <families>`. The separator is a colon followed by a space.
|
|
173
|
+
A Windows drive letter is `C:` followed by a backslash, so splitting on the first colon
|
|
174
|
+
yields `C` and the font is reported missing.
|
|
175
|
+
"""
|
|
176
|
+
if ": " in line:
|
|
177
|
+
return line.partition(": ")[0].strip()
|
|
178
|
+
return line.strip().rstrip(":")
|
|
179
|
+
|
|
180
|
+
|
|
168
181
|
_EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
|
|
169
182
|
|
|
170
183
|
|
|
@@ -182,7 +195,7 @@ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
|
|
|
182
195
|
if proc.returncode != 0:
|
|
183
196
|
return None, None, False
|
|
184
197
|
for line in proc.stdout.splitlines():
|
|
185
|
-
path = line
|
|
198
|
+
path = _fc_list_path(line)
|
|
186
199
|
if path and os.path.exists(path):
|
|
187
200
|
return family, path, True
|
|
188
201
|
return None, None, True
|
|
@@ -205,7 +218,7 @@ def _libass_color_probe() -> "Optional[bool]":
|
|
|
205
218
|
proc = subprocess.run(
|
|
206
219
|
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
|
|
207
220
|
"-i", "color=c=black:s=64x64:d=0.04",
|
|
208
|
-
"-vf", "subtitles=" + srt
|
|
221
|
+
"-vf", "subtitles=" + escape_filter_path(srt, bind=False), "-frames:v", "1",
|
|
209
222
|
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
210
223
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
|
|
211
224
|
except (subprocess.TimeoutExpired, OSError):
|
package/scripts/broll.py
CHANGED
|
@@ -56,6 +56,9 @@ def main() -> int:
|
|
|
56
56
|
die("A-roll has no video stream")
|
|
57
57
|
dur_a = meta_a.get("duration") or 0.0
|
|
58
58
|
w, h = meta_a["video"]["width"], meta_a["video"]["height"]
|
|
59
|
+
# the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one
|
|
60
|
+
if meta_a["video"].get("rotation") in (90, -90, 270, -270):
|
|
61
|
+
w, h = h, w
|
|
59
62
|
fps = meta_a["video"].get("fps") or 30.0
|
|
60
63
|
has_audio_a = bool(meta_a.get("audio"))
|
|
61
64
|
|
package/scripts/cropdetect.py
CHANGED
|
@@ -138,6 +138,11 @@ def main() -> int:
|
|
|
138
138
|
if not meta.get("video"):
|
|
139
139
|
die("input has no video stream")
|
|
140
140
|
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
141
|
+
# cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
|
|
142
|
+
# rectangle and the motion points are in that frame. Reporting the stored size made a
|
|
143
|
+
# 176x320 crop look taller than a 320x180 source.
|
|
144
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
145
|
+
sw, sh = sh, sw
|
|
141
146
|
duration = meta.get("duration") or 0.0
|
|
142
147
|
|
|
143
148
|
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration)
|
package/scripts/fit.py
CHANGED
|
@@ -198,7 +198,7 @@ def main() -> int:
|
|
|
198
198
|
darken = 0.0 if meta["video"].get("bt2020_or_hdr") else BLUR_DARKEN
|
|
199
199
|
if not darken:
|
|
200
200
|
info("--fit blur: HDR source, so the blurred background is not dimmed "
|
|
201
|
-
"(an eq on PQ/HLG code values is not the -15
|
|
201
|
+
"(an eq on PQ/HLG code values is not the -15% dim it is on SDR); "
|
|
202
202
|
"run color.py --to-sdr first for the SDR look")
|
|
203
203
|
vf.append(pad_filters(out_w, out_h, "blur", args.pad_color, args.pad_blur, darken))
|
|
204
204
|
else:
|
package/scripts/look.py
CHANGED
|
@@ -170,8 +170,11 @@ def main() -> int:
|
|
|
170
170
|
z = PLATFORMS[args.safe]["safe"]
|
|
171
171
|
frame = PLATFORMS[args.safe]["frame"]
|
|
172
172
|
src = meta["video"]
|
|
173
|
-
|
|
174
|
-
|
|
173
|
+
dw, dh = src.get("width") or 0, src.get("height") or 0
|
|
174
|
+
if src.get("rotation") in (90, -90, 270, -270):
|
|
175
|
+
dw, dh = dh, dw
|
|
176
|
+
if dw and dh and abs(dw / dh - frame["w"] / frame["h"]) > 0.02:
|
|
177
|
+
info(f"--safe {args.safe}: this source is {dw}x{dh}, not {args.safe}'s "
|
|
175
178
|
f"{frame['w']}x{frame['h']} -- the zones are drawn as fractions of the frame you gave, "
|
|
176
179
|
f"so reframe first (fit.py --aspect) to see what the app really covers")
|
|
177
180
|
boxes = []
|
package/scripts/overlay.py
CHANGED
|
@@ -116,8 +116,10 @@ def main() -> int:
|
|
|
116
116
|
ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
|
|
117
117
|
ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
|
|
118
118
|
img = ap.add_argument_group("image options")
|
|
119
|
-
img.add_argument("--scale", type=int, help="
|
|
120
|
-
img.add_argument("--scale-percent", type=float,
|
|
119
|
+
img.add_argument("--scale", type=int, help="width in px for an image, logo or video layer (keeps aspect); text size is --font-size")
|
|
120
|
+
img.add_argument("--scale-percent", type=float,
|
|
121
|
+
help="width as a %% of the displayed frame for an image, logo or video layer (keeps aspect); "
|
|
122
|
+
"a 90 or 270 degree rotation uses the stored height; text size is --font-size")
|
|
121
123
|
txt = ap.add_argument_group("text options")
|
|
122
124
|
txt.add_argument("--font", default="DejaVu Sans", help="fontconfig font name")
|
|
123
125
|
txt.add_argument("--font-file", help="explicit .ttf/.otf/.ttc path (use this for CJK fonts)")
|
|
@@ -158,6 +160,10 @@ def main() -> int:
|
|
|
158
160
|
die("--text is blank -- give the words to draw", kind="input")
|
|
159
161
|
if not (args.image or args.text or args.video):
|
|
160
162
|
die("give --image, --text, --logo or --video")
|
|
163
|
+
# --scale is a layer width. On a text draw it used to be accepted and ignored (fontsize stayed
|
|
164
|
+
# at --font-size), so a project that asked for 200 px text completed with the 42 px default.
|
|
165
|
+
if args.text and not (args.image or args.video) and (args.scale is not None or args.scale_percent is not None):
|
|
166
|
+
die("--scale and --scale-percent size an image, logo or video layer; text size is --font-size", kind="input")
|
|
161
167
|
if args.brand:
|
|
162
168
|
if args.margin == ap.get_default("margin"):
|
|
163
169
|
args.margin = int(brand.get("safe_margin", args.margin))
|
|
@@ -205,7 +211,12 @@ def main() -> int:
|
|
|
205
211
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
206
212
|
if args.audio_stream and not audio_streams:
|
|
207
213
|
die("--audio-stream needs an input with audio streams")
|
|
208
|
-
vw = meta["video"]["width"]
|
|
214
|
+
vw = meta["video"]["width"] or 0
|
|
215
|
+
vh = meta["video"].get("height") or 0
|
|
216
|
+
# ffmpeg applies display rotation before the filter graph, so the frame this overlay is
|
|
217
|
+
# composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
|
|
218
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
219
|
+
vw, vh = vh, vw
|
|
209
220
|
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
210
221
|
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
211
222
|
# and wins; without either, the historical 24 px default is unchanged.
|
|
@@ -216,7 +227,7 @@ def main() -> int:
|
|
|
216
227
|
# fall back to the destination's own delivery frame -- which is what the fitted
|
|
217
228
|
# intermediate this stage runs on will be anyway
|
|
218
229
|
frame = PLATFORMS[args.platform]["frame"]
|
|
219
|
-
pw, ph = vw or frame["w"],
|
|
230
|
+
pw, ph = vw or frame["w"], vh or frame["h"]
|
|
220
231
|
safe_margins = safe_margins_px(args.platform, pw, ph)
|
|
221
232
|
info(f"--platform {args.platform}: safe margins top {safe_margins['top']} / bottom {safe_margins['bottom']} / "
|
|
222
233
|
f"left {safe_margins['left']} / right {safe_margins['right']} px (clear of the app's own UI)")
|
package/scripts/render.py
CHANGED
|
@@ -40,7 +40,9 @@ skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
|
40
40
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
41
41
|
An overlay entry draws one of logo, image, text or video (a picture-in-picture
|
|
42
42
|
layer, overlay.py --video; "chromakey", "chromakey_similarity" and
|
|
43
|
-
"chromakey_blend" key a colour out of that video and need "video").
|
|
43
|
+
"chromakey_blend" key a colour out of that video and need "video"). "scale"
|
|
44
|
+
is that layer's width in px for a logo, image or video; a text entry's size
|
|
45
|
+
is "font_size", and "scale" on a text entry is refused.
|
|
44
46
|
|
|
45
47
|
"check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
|
|
46
48
|
itself failing to run) exits 1, same as running check.py directly would --
|
|
@@ -436,6 +438,10 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
436
438
|
tuned = [k for k in ("chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
437
439
|
if tuned:
|
|
438
440
|
die(f"{label}: {tuned[0]} tunes \"chromakey\", which this entry does not set")
|
|
441
|
+
# scale is a layer width (overlay.py --scale). A text entry that carries it used to complete
|
|
442
|
+
# with the default font size, because the text branch never reads the flag.
|
|
443
|
+
if ov.get("text") and not any(ov.get(k) for k in ("logo", "image", "video")) and ov.get("scale") is not None:
|
|
444
|
+
die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
|
|
439
445
|
|
|
440
446
|
|
|
441
447
|
def require_object(obj: Any, label: str) -> None:
|
package/scripts/speedramp.py
CHANGED
|
@@ -84,9 +84,14 @@ def main() -> int:
|
|
|
84
84
|
output = args.output or default_output(args.input, "ramp")
|
|
85
85
|
|
|
86
86
|
vparts, aparts, labels = [], [], []
|
|
87
|
+
# The coverage check above allows the last end to sit up to 0.5 s off the probed duration.
|
|
88
|
+
# That slack is for a caller who rounded the duration, not a reason to redraw the tail at
|
|
89
|
+
# the last factor. The end bound is omitted only when the stated end is the file's end
|
|
90
|
+
# (within 50 ms): a trim a few milliseconds short of the last packet drops that packet.
|
|
91
|
+
last_runs_to_eof = abs(segments[-1][1] - dur) <= 0.05
|
|
87
92
|
for i, (start, end, factor) in enumerate(segments):
|
|
88
93
|
vlabel, alabel = f"v{i}", f"a{i}"
|
|
89
|
-
end_expr = f"{end:.3f}" if i
|
|
94
|
+
end_expr = f"{end:.3f}" if not (i == len(segments) - 1 and last_runs_to_eof) else None
|
|
90
95
|
trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
|
|
91
96
|
vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
|
|
92
97
|
labels.append(f"[{vlabel}]")
|
package/scripts/sync.py
CHANGED
|
@@ -36,6 +36,17 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
36
36
|
|
|
37
37
|
SR = 8000 # decode sample rate
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
def sync_meaning(offset: float) -> str:
|
|
41
|
+
"""The sentence a caller reads next to offset_seconds.
|
|
42
|
+
|
|
43
|
+
A zero offset used to take the else branch of `later if offset > 0` and say the second
|
|
44
|
+
file starts earlier. Values that print as 0.000 s (under half a millisecond) say the two
|
|
45
|
+
recordings start together; anything larger keeps later/earlier."""
|
|
46
|
+
if abs(offset) < 5e-4:
|
|
47
|
+
return "second starts together with the reference"
|
|
48
|
+
return "second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")
|
|
49
|
+
|
|
39
50
|
# Scoring constants for coarse alignment. Both come from tests/bench_sync.py on real dialogue and
|
|
40
51
|
# music with +/-30 s offsets, gain, noise and EQ (see evals/results and CHANGELOG 0.8.0):
|
|
41
52
|
#
|
|
@@ -307,7 +318,7 @@ def main() -> int:
|
|
|
307
318
|
"second": args.second,
|
|
308
319
|
"offset_seconds": offset,
|
|
309
320
|
"confidence": score,
|
|
310
|
-
"meaning": (
|
|
321
|
+
"meaning": sync_meaning(offset),
|
|
311
322
|
"sources": sources_block,
|
|
312
323
|
}
|
|
313
324
|
if drift_info:
|