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 CHANGED
@@ -41,21 +41,39 @@ if (has('--help') || has('-h')) {
41
41
  process.exit(0);
42
42
  }
43
43
 
44
- // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
45
- if (args[0] === 'contract' || args[0] === 'doctor') {
46
- // Windows Python installers commonly expose `python`/`py`, not `python3` (only the
47
- // Microsoft Store package does); try python3 first (macOS/Linux convention), then fall
48
- // back so `npx ffmpeg-skill doctor` doesn't silently fail with ENOENT on Windows.
49
- const candidates = process.platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
50
- let py;
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
- py = spawnSync(cmd, [path.join(ROOT, 'scripts', '_contract.py'), ...args], { stdio: 'inherit' });
53
- if (!py.error) break;
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
- if (py.error) {
56
- console.error(`error: could not find a Python interpreter (tried: ${candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
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.5`) | any release |
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.5", "execution_mode": "local", "kind": "execution",
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`; a window with no measured motion reports `x`/`y`/`x_frac`/`y_frac: null`. Report only — this tool never picks a reframe |
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. The caller's own `timeout`
100
- # argument sets both; otherwise FFMPEG_SKILL_TIMEOUT / the 1800 s default.
101
- try:
102
- per_call = float((args or {}).get("timeout", _common._env_timeout()))
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.5",
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",
@@ -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; `probe.py` reports
285
- it and `fit.py` accounts for it when computing the output frame.
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
 
@@ -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`. Distinct from
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), and A resumes at its own time when the window
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. `"chromakey": "0x00ff00"`, `"chromakey_similarity"` and
759
- `"chromakey_blend"` key a colour out of the video layer. The stage runs after
760
- `fit`, so `"scale": 480` is 480 px of the delivered frame, and a template's
761
- safe margins apply as they do to a logo. Refused (`kind: input`) before the
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. `--video` composites a second video as a picture-in-picture layer
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("\\", "/")
@@ -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.split(":", 1)[0].strip()
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.replace("\\", "/"), "-frames:v", "1",
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
 
@@ -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%% dim it is on SDR); "
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
- if src.get("width") and src.get("height") and abs(src["width"] / src["height"] - frame["w"] / frame["h"]) > 0.02:
174
- info(f"--safe {args.safe}: this source is {src['width']}x{src['height']}, not {args.safe}'s "
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 = []
@@ -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="scale the image to this width in px (keeps aspect)")
120
- img.add_argument("--scale-percent", type=float, help="scale the image to this %% of the video width")
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"], meta["video"].get("height") or frame["h"]
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:
@@ -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 < len(segments) - 1 else None
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": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
321
+ "meaning": sync_meaning(offset),
311
322
  "sources": sources_block,
312
323
  }
313
324
  if drift_info: