ffmpeg-skill 2.10.6 → 2.10.8
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 +6 -2
- package/docs/contract.md +3 -3
- package/package.json +1 -1
- package/references/gotchas.md +6 -2
- package/references/scripts.md +11 -3
- package/scripts/_common/decision.py +5 -2
- package/scripts/_common/emoji.py +15 -2
- package/scripts/_contract.py +14 -1
- package/scripts/broll.py +3 -0
- package/scripts/cropdetect.py +5 -0
- package/scripts/look.py +5 -2
- package/scripts/overlay.py +10 -3
package/README.md
CHANGED
|
@@ -395,9 +395,13 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
395
395
|
|
|
396
396
|
Each operation is a script whose flags are its argparse parser, and that parser is what `contract --json` and the MCP `inputSchema` are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, `-vf` string, or other ffmpeg option is not an argument any script accepts. `doctor` reads `ffmpeg -filters` (and `-encoders`, `-bsfs`) and reports a filter a tool needs before a job runs into it. MCP does accept an `argv` array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
|
|
397
397
|
|
|
398
|
-
### How does
|
|
398
|
+
### How does ffmpeg-skill compare with Remotion?
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
|
|
401
|
+
|
|
402
|
+
### Does it validate the filter graph before running?
|
|
403
|
+
|
|
404
|
+
No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match, by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
|
|
401
405
|
|
|
402
406
|
### Can it remove burned-in subtitles or logos?
|
|
403
407
|
|
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.8`) | 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.8", "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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.8",
|
|
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
|
|
@@ -1220,7 +1224,8 @@ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
|
|
|
1220
1224
|
frame like an editor would. Use `--compare` to show before/after to the user. `--safe NAME` (1.14) shades the zones that
|
|
1221
1225
|
destination's own UI covers -- TikTok's description block and like column, the Reels/Shorts
|
|
1222
1226
|
chrome -- on the sheet or the frame, so "is the caption readable" can be answered about the app
|
|
1223
|
-
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.
|
|
1224
1229
|
|
|
1225
1230
|
### caption.py — subtitles (static, animated, karaoke)
|
|
1226
1231
|
```
|
|
@@ -1466,6 +1471,9 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
|
|
|
1466
1471
|
`--platform NAME` (1.14) takes each edge's margin from that destination's safe zone
|
|
1467
1472
|
(`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
|
|
1468
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.
|
|
1469
1477
|
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
|
|
1470
1478
|
playing. `--scale` / `--scale-percent` set the width of an image, logo or
|
|
1471
1479
|
`--video` layer. They are refused with `--text`: text size is `--font-size`.
|
|
@@ -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/_contract.py
CHANGED
|
@@ -1465,7 +1465,20 @@ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1465
1465
|
descriptions verbatim (the same ~250 characters repeated 42 times in every tools/list dump);
|
|
1466
1466
|
it now goes once into the server's `initialize` response (`instructions`) instead, so a tool's
|
|
1467
1467
|
own description stays its own one-line sentence."""
|
|
1468
|
-
return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec)
|
|
1468
|
+
return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec),
|
|
1469
|
+
"annotations": mcp_annotations(spec)}
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
def mcp_annotations(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
1473
|
+
"""MCP ToolAnnotations (spec 2025-06-18) derived from the ToolSpec flags. Hints only: clients
|
|
1474
|
+
must not treat them as a security boundary. Every tool runs locally (no open world); none edits
|
|
1475
|
+
its input in place; a tool that writes nothing is read-only."""
|
|
1476
|
+
writes = bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input"))
|
|
1477
|
+
return {"title": spec["name"],
|
|
1478
|
+
"readOnlyHint": not writes,
|
|
1479
|
+
"destructiveHint": bool(spec.get("mutates_input")),
|
|
1480
|
+
"idempotentHint": spec.get("idempotency_hint") in ("bit_exact", "content_equivalent", "cached"),
|
|
1481
|
+
"openWorldHint": False}
|
|
1469
1482
|
|
|
1470
1483
|
|
|
1471
1484
|
def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
|
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/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
|
@@ -117,7 +117,9 @@ def main() -> int:
|
|
|
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
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,
|
|
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)")
|
|
@@ -209,7 +211,12 @@ def main() -> int:
|
|
|
209
211
|
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
210
212
|
if args.audio_stream and not audio_streams:
|
|
211
213
|
die("--audio-stream needs an input with audio streams")
|
|
212
|
-
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
|
|
213
220
|
# --platform: the edges this destination's own UI covers, in pixels of this frame. An
|
|
214
221
|
# explicit --margin (or a brand safe_margin, applied above) is the more specific statement
|
|
215
222
|
# and wins; without either, the historical 24 px default is unchanged.
|
|
@@ -220,7 +227,7 @@ def main() -> int:
|
|
|
220
227
|
# fall back to the destination's own delivery frame -- which is what the fitted
|
|
221
228
|
# intermediate this stage runs on will be anyway
|
|
222
229
|
frame = PLATFORMS[args.platform]["frame"]
|
|
223
|
-
pw, ph = vw or frame["w"],
|
|
230
|
+
pw, ph = vw or frame["w"], vh or frame["h"]
|
|
224
231
|
safe_margins = safe_margins_px(args.platform, pw, ph)
|
|
225
232
|
info(f"--platform {args.platform}: safe margins top {safe_margins['top']} / bottom {safe_margins['bottom']} / "
|
|
226
233
|
f"left {safe_margins['left']} / right {safe_margins['right']} px (clear of the app's own UI)")
|