ffmpeg-skill 3.8.17 → 3.8.18

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/docs/contract.md CHANGED
@@ -26,7 +26,7 @@ The contract is derived from the code that runs, not maintained beside it:
26
26
  | Field | Meaning | Changes when |
27
27
  |---|---|---|
28
28
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
29
- | `skill.version` | the npm / package.json version (`3.8.17`) | any release |
29
+ | `skill.version` | the npm / package.json version (`3.8.18`) | any release |
30
30
 
31
31
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
32
32
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -154,7 +154,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
154
154
  "deprecated": [],
155
155
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
156
156
  {"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"}],
157
- "skill": {"id": "ffmpeg-skill", "version": "3.8.17", "execution_mode": "local", "kind": "execution",
157
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.18", "execution_mode": "local", "kind": "execution",
158
158
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
159
159
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
160
160
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.17",
3
+ "version": "3.8.18",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -656,6 +656,7 @@ ends -- a cutaway, not a splice, so the output is exactly as long as A. One
656
656
  `--insert`/`--at` pair per cutaway (`--duration`, `--end`, `--from` are per
657
657
  cutaway too, or given once for all; defaults 4 s and 0); windows may not
658
658
  overlap or run past A's end, and B must have enough material from `--from`.
659
+ A still picture as B is shown for the whole window.
659
660
  `--audio a` (default) keeps A's audio untouched and stream-copied; `b` replaces
660
661
  it inside each window with B's; `mix` plays both. The output's length is
661
662
  verified against A's.
@@ -717,7 +718,8 @@ bottom-right corner; `--label none` skips it. No audio unless `--audio-from`
717
718
  picks one input's track by index -- mixing every clip's audio together is
718
719
  rarely useful for a comparison grid, so this tool never does that silently.
719
720
  Runs only as long as the shortest clip by default; `--pad` instead holds
720
- each shorter clip's last frame (with silence) out to the longest.
721
+ each shorter clip's last frame (with silence) out to the longest. A still
722
+ picture fills its cell for the grid's length; a grid of stills only is refused.
721
723
 
722
724
  ### silence.py — remove dead air / jump cuts
723
725
  ```
@@ -1192,7 +1194,8 @@ hand-built step:
1192
1194
  and `check.py --platform podcast`. `--template all` renders `tiktok, reels, shorts, youtube, x,
1193
1195
  linkedin, facebook` (not the audio template, not the `youtube-shorts` alias) into
1194
1196
  `<stem>_<platform>.mp4`, runs each platform's check and writes `<stem>_pack.md` with one row per
1195
- destination; `report.py --pack <stem>_pack.md` renders that table as a single HTML page. A pack
1197
+ destination; `report.py --pack <stem>_pack.md` renders that table (and refuses a file whose first
1198
+ table has other columns) as a single HTML page. A pack
1196
1199
  whose destinations did not all pass exits non-zero with the per-destination rows in `pack`.
1197
1200
 
1198
1201
  `audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
@@ -1607,7 +1610,9 @@ like column; `--margin PX` sets all four edges and wins over `--platform`.
1607
1610
  read by `caption.py` and, for `font` and `colour`, by `graphics.py`; it wins
1608
1611
  over the older top-level `caption` block where both set the same key, and that
1609
1612
  block still carries the burn-in-only defaults (`animate`, `karaoke`, `bold`,
1610
- `outline`). `"lang"` is the script hint `--lang` would give. When a
1613
+ `outline`). `"lang"` is the script hint `--lang` would give. A key no tool
1614
+ reads is refused, naming it (`loudness`, read by none, gets a note), and a
1615
+ `font_file` that is not a font is refused where text is drawn with it. When a
1611
1616
  user mentions brand guidelines, colours, "our font" or a logo, ask for or
1612
1617
  write a brand.json once and reuse it across every output.
1613
1618
 
@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
16
16
  from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
17
17
  is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
18
18
  from _common.alpha import rgb_to_bt709_line
19
- from _common.emit import die, input_length, record_fallback, refuse, warn
19
+ from _common.emit import die, input_length, note, record_fallback, refuse, warn
20
20
  from _common.runner import read_json
21
21
  from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
22
22
 
@@ -1295,6 +1295,46 @@ BRAND_DEFAULTS: Dict[str, Any] = {
1295
1295
  }
1296
1296
 
1297
1297
 
1298
+ # Every key a tool reads from a brand file, per block (#510). `styles.caption` is merged over the
1299
+ # top-level `caption` block (brand_caption_style()), so the two take the same keys. `colors.accent`
1300
+ # is a palette entry BRAND_DEFAULTS and examples/brand.json carry; `loudness` is read by no tool
1301
+ # (A107) and is kept out of the refusal, since the example shipped it until 3.8.8: it gets a note.
1302
+ BRAND_KEYS: Dict[str, frozenset] = {
1303
+ "": frozenset({"font", "font_file", "colors", "logo", "logo_position", "logo_scale", "logo_opacity",
1304
+ "safe_margin", "caption", "styles", "lang", "emoji_assets", "loudness"}),
1305
+ "colors": frozenset({"primary", "text", "outline", "background", "accent", "upcoming"}),
1306
+ "caption": frozenset({"size", "position", "animate", "karaoke", "bold", "outline", "box", "color", "colour",
1307
+ "font", "emoji_assets"}),
1308
+ "styles": frozenset({"caption"}),
1309
+ }
1310
+ BRAND_KEYS["styles.caption"] = BRAND_KEYS["caption"]
1311
+
1312
+
1313
+ def check_brand_keys(data: Dict[str, Any], path: str) -> None:
1314
+ """Refuse a brand file key no tool reads, as render.py refuses a project's (check_keys()): a
1315
+ misspelt `"safe_margn"` or `"colours"` was dropped without a word and the defaults drawn."""
1316
+ import difflib
1317
+ import json
1318
+ blocks = [("", data)]
1319
+ for name in ("colors", "caption", "styles"):
1320
+ if isinstance(data.get(name), dict):
1321
+ blocks.append((name, data[name]))
1322
+ if isinstance(data.get("styles"), dict) and isinstance(data["styles"].get("caption"), dict):
1323
+ blocks.append(("styles.caption", data["styles"]["caption"]))
1324
+ for block, obj in blocks:
1325
+ for key in obj:
1326
+ if key in BRAND_KEYS[block]:
1327
+ continue
1328
+ where = f"{block}.{key}" if block else str(key)
1329
+ near = difflib.get_close_matches(str(key), sorted(BRAND_KEYS[block]), n=1)
1330
+ # quoted as JSON: the key is the caller's text, and a quote or a line break in it stays one line
1331
+ die(f"brand file {path}: unknown key {json.dumps(where)}" + (f' (did you mean "{near[0]}"?)' if near else ""),
1332
+ kind="input", hint=f"the keys read{' in ' + block if block else ''}: " + ", ".join(sorted(BRAND_KEYS[block])))
1333
+ if "loudness" in data:
1334
+ note(f'brand file {path}: "loudness" is read by no tool and was ignored (a target is loudness.py -I, '
1335
+ 'export.py\'s preset, or a render project\'s "loudness")')
1336
+
1337
+
1298
1338
  def load_brand(path: Optional[str]) -> Dict[str, Any]:
1299
1339
  """Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
1300
1340
  import copy
@@ -1322,6 +1362,7 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
1322
1362
  die(f'brand file {path}: "{key}" must be an object {{...}}, got {type(value).__name__}', kind="input")
1323
1363
  if key in ("logo", "font_file", "font") and not isinstance(value, str):
1324
1364
  die(f'brand file {path}: "{key}" must be a string, got {type(value).__name__}', kind="input")
1365
+ check_brand_keys(data, path)
1325
1366
  base = Path(path).resolve().parent
1326
1367
  for k, v in data.items():
1327
1368
  if v is None and isinstance(brand.get(k), dict):
@@ -278,6 +278,37 @@ _SCRIPT_RANGES = (
278
278
  )
279
279
 
280
280
 
281
+ # The first bytes of the font files FreeType opens (drawtext and libass both load through it):
282
+ # TrueType, OpenType/CFF, Apple 'true'/'typ1', a collection, WOFF/WOFF2, PostScript Type 1 (PFA
283
+ # text, PFB segment marker), BDF and PCF bitmap fonts.
284
+ _FONT_MAGIC = (b"\x00\x01\x00\x00", b"OTTO", b"true", b"typ1", b"ttcf", b"wOFF", b"wOF2",
285
+ b"%!PS-AdobeFont", b"%!FontType1", b"\x80\x01", b"STARTFONT", b"\x01fcp")
286
+
287
+
288
+ def require_font_file(path: str, label: str = "--font-file") -> str:
289
+ """Refuse a font file that is not there or is not a font, before anything is drawn with it
290
+ (#510). drawtext that cannot load its `fontfile=` asks fontconfig for a default face instead
291
+ and exits 0, and libass given the file's directory finds no face in it and does the same, so a
292
+ text file named `brand.ttf` drew DejaVu Sans with `font_used.substituted: false`. Read by the
293
+ first bytes, not the extension; a gzipped PCF (`.pcf.gz`) and a Mac `.dfont` (no signature)
294
+ pass by name."""
295
+ shown_path = str(path)
296
+ if not os.path.isfile(shown_path):
297
+ die(f"{label} {shown_path}: no such file", kind="input")
298
+ try:
299
+ with open(shown_path, "rb") as fh:
300
+ head = fh.read(16)
301
+ except OSError as exc:
302
+ die(f"{label} {shown_path} cannot be read: {exc.strerror or exc}", kind="input")
303
+ lower = shown_path.lower()
304
+ if head.startswith(_FONT_MAGIC) or lower.endswith(".dfont") or (lower.endswith(".pcf.gz") and head.startswith(b"\x1f\x8b")):
305
+ return shown_path
306
+ die(f"{label} {shown_path} is not a font file (it starts with {head[:8]!r}, which no TrueType, OpenType, "
307
+ "WOFF, Type 1, BDF or PCF font does): drawing with it would fall back to another face without a word",
308
+ kind="input", hint="name a .ttf, .otf or .ttc file (fc-list lists the installed ones), or --font FAMILY")
309
+ return shown_path
310
+
311
+
281
312
  def font_family_of_file(path: str) -> "Optional[str]":
282
313
  """The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
283
314
  the file directly; without fontconfig the file stem is the honest best guess."""
@@ -905,6 +905,20 @@ def refuse_not_a_still(path: str, meta: Dict[str, Any], flag: str, instead: str)
905
905
  + (f" ({length:.3f}s)" if length else "") + f", and {flag} is read as one picture", kind="input", hint=instead)
906
906
 
907
907
 
908
+ def is_still_input(meta: Dict[str, Any]) -> bool:
909
+ """A probe document of one picture read by an image demuxer (_is_still()'s rule, on the
910
+ document): a still given where a clip is taken. Read with a plain `-i` it is one frame, so a
911
+ tool that shows it for a while opens it with still_loop_args() (#510)."""
912
+ fmt = str((meta or {}).get("format") or "")
913
+ return not (meta or {}).get("dry_run") and (fmt == "image2" or fmt.endswith("_pipe"))
914
+
915
+
916
+ def still_loop_args(path: str, seconds: float) -> List[str]:
917
+ """The input options that show a still picture for `seconds`: `-loop 1` repeats its one frame
918
+ (the image demuxers' option) and `-t` ends the repeat, so the input is bounded."""
919
+ return ["-loop", "1", *still_input_args(path), "-t", f"{max(seconds, 0.001):.3f}"]
920
+
921
+
908
922
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
909
923
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
910
924
 
package/scripts/broll.py CHANGED
@@ -26,7 +26,7 @@ from _common.decision import (av_trim, conform_output_t, conform_to_length, fram
26
26
  grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
27
27
  from _common import STATE, add_common, aac_args, apply_common, cfr_args, cfr_at_input, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
28
28
  from _common.emit import note, record_fallback
29
- from _common.probe import display_size, square_pixels, square_pixels_note
29
+ from _common.probe import display_size, is_still_input, square_pixels, square_pixels_note, still_loop_args
30
30
  from _common.emit import input_length
31
31
 
32
32
 
@@ -109,7 +109,9 @@ def main() -> int:
109
109
  die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
110
110
  if dur_a and at + length > dur_a + 0.01:
111
111
  die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
112
- dur_b = input_length(meta_b)
112
+ # a still has no material to run out of: it is looped for the window. image2 gives some (a
113
+ # TGA) a duration of one frame, which this refused as "only -0.460s from 0.5s" (review of #532)
114
+ dur_b = None if is_still_input(meta_b) else input_length(meta_b)
113
115
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
114
116
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
115
117
  # the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
@@ -139,6 +141,15 @@ def main() -> int:
139
141
  output = args.output or default_output(args.input, "broll")
140
142
  cmd = ffmpeg_base() + ["-i", args.input]
141
143
  for c in cutaways:
144
+ # a still B-roll is one frame to a plain -i: it showed for one frame of the cutaway and A
145
+ # came back under it (#510). Looped, it lasts the window it is trimmed to. The bound is past
146
+ # the trim's end, which runs from B's first frame at or after --from on the loop's own
147
+ # grid for the window's whole frames: --from + --duration cut that frame off a short window
148
+ # (--from 0.041 --duration 0.033 needs the frame at 0.08, review of #532). A frame of B
149
+ # past it costs nothing: the trim drops it.
150
+ if is_still_input(c["meta"]):
151
+ frame_b = 1 / c["rate"] if c["rate"] else 0.04
152
+ cmd += still_loop_args(c["path"], max(c["from"], c["first"]) + max(c["length"], c["span"] or 0) + 2 * frame_b)
142
153
  cmd += ["-i", c["path"]]
143
154
 
144
155
  # Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
@@ -56,7 +56,7 @@ from _common.asr import ENGINE_CHOICES
56
56
  from _common.emit import warn
57
57
  from _common.probe import display_size, square_pixels, square_pixels_note
58
58
  from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
59
- from _common.fonts import font_choice, record_font_choice
59
+ from _common.fonts import font_choice, record_font_choice, require_font_file
60
60
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
61
61
  write_srt)
62
62
  from _common.asr import srt_blocks
@@ -1327,6 +1327,11 @@ def main() -> int:
1327
1327
  if args.brand and bcap.get("karaoke") and not args.karaoke:
1328
1328
  args.karaoke = True
1329
1329
  if args.brand and brand.get("font_file") and not args.fonts_dir:
1330
+ # the brand's face is found in its directory by libass, so a file there that is no font
1331
+ # left libass to another face without a word (#510); a soft track (--mode mux) draws nothing,
1332
+ # and nor does a run with no input video, which only writes the SRT (review of #532)
1333
+ if args.mode != "mux" and args.input:
1334
+ require_font_file(brand["font_file"], "the --brand file's font_file")
1330
1335
  args.fonts_dir = str(Path(brand["font_file"]).parent)
1331
1336
  if not (args.srt or args.ass or args.text or args.transcribe):
1332
1337
  die("give one of --srt, --ass, --text or --transcribe")
@@ -47,7 +47,7 @@ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_
47
47
  from _common.emit import note, warn
48
48
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
49
49
  report as alpha_report, source_alpha, source_format, tag_args, video_line)
50
- from _common.fonts import font_choice, record_font_choice
50
+ from _common.fonts import font_choice, record_font_choice, require_font_file
51
51
  from _common.probe import display_size, square_pixels, square_pixels_note
52
52
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
53
53
  from _common import drawtext_fontfile # noqa: E402
@@ -175,6 +175,9 @@ def main(force_route: Optional[str] = None) -> int:
175
175
  if args.brand and style.get("color") and not args.text_color:
176
176
  text_c = color_hex(style["color"])
177
177
  args.lang = args.lang or (brand.get("lang") if args.brand else None)
178
+ # checked where text is drawn with it: the progress template draws a bar only (review of #532)
179
+ if args.template != "progress" and (args.font_file or brand.get("font_file")):
180
+ require_font_file(args.font_file or brand["font_file"], "--font-file" if args.font_file else "the --brand file's font_file")
178
181
  # a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
179
182
  _script, script_file, _family = script_font_for_text(
180
183
  " ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
package/scripts/grid.py CHANGED
@@ -32,7 +32,7 @@ from _common import drawtext_fontfile # noqa: E402
32
32
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
33
33
  from _common.emit import note, record_fallback # noqa: E402
34
34
  from _common.fonts import script_font_for_text # noqa: E402
35
- from _common.probe import square_pixels # noqa: E402
35
+ from _common.probe import is_still_input, square_pixels, still_loop_args # noqa: E402
36
36
  from _common.wrap import text_width_em # noqa: E402
37
37
  from _common.emit import input_length # noqa: E402
38
38
  from _common.decision import conform_output_t, conform_to_length # noqa: E402
@@ -116,8 +116,15 @@ def main() -> int:
116
116
  if args.audio_from is not None and not metas[args.audio_from].get("audio"):
117
117
  die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
118
118
 
119
- durations = [input_length(m) for m in metas]
119
+ # a still picture has no length of its own: it lasts as long as the grid (#510). Counted as a
120
+ # clip of 0 s, it made the grid 0 s long, and the run ended in a traceback on the empty file.
121
+ stills = [is_still_input(m) for m in metas]
122
+ if all(stills):
123
+ die("every cell is a still picture, so the grid has no length to take from a clip", kind="input",
124
+ hint="give at least one video cell, or make a clip of a still first (insert.py STILL --duration T)")
125
+ durations = [input_length(m) for m, still in zip(metas, stills) if not still]
120
126
  target_duration = max(durations) if args.pad else min(durations)
127
+ durations = [target_duration if still else input_length(m) for m, still in zip(metas, stills)]
121
128
 
122
129
  # each label is drawn with a font that covers its own script, as every other text-drawing tool
123
130
  # picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
@@ -139,11 +146,13 @@ def main() -> int:
139
146
  if args.label == "auto" and cut:
140
147
  note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
141
148
  f"{args.font_size} were shortened from the end with an ellipsis")
142
- output = args.output or default_output(args.inputs[0], "grid")
149
+ # named after the first cell; a still there would give its own extension (white_grid.png), and
150
+ # the image muxer cannot write the grid's frames to one file name (review of #532)
151
+ output = args.output or default_output(args.inputs[0], "grid", "mp4" if stills[0] else None)
143
152
 
144
153
  cmd = ffmpeg_base()
145
- for p in args.inputs:
146
- cmd += ["-i", p]
154
+ for p, still in zip(args.inputs, stills):
155
+ cmd += (still_loop_args(p, target_duration) if still else []) + ["-i", p]
147
156
 
148
157
  # each cell converted by its own tags to the BT.709 the grid is tagged with (a BT.601 or an RGB
149
158
  # input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
@@ -46,7 +46,7 @@ from _common.probe import display_size, refuse_not_a_still, square_pixels, squar
46
46
  from _common.drawtext import shaping_refusal
47
47
  from _common.wrap import wrap_text
48
48
  from _common import text_width_em
49
- from _common.fonts import font_choice, record_font_choice
49
+ from _common.fonts import font_choice, record_font_choice, require_font_file
50
50
  from _common import drawtext_fontfile # noqa: E402
51
51
 
52
52
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
@@ -361,6 +361,7 @@ def main() -> int:
361
361
  # at --font-size), so a project that asked for 200 px text completed with the 42 px default.
362
362
  if args.text and not (args.image or args.video) and (args.scale is not None or args.scale_percent is not None):
363
363
  die("--scale and --scale-percent size an image, logo or video layer; text size is --font-size", kind="input")
364
+ font_file_from = "--font-file" # what the refusal of a font file that is not one names
364
365
  if args.brand:
365
366
  if args.margin == ap.get_default("margin"):
366
367
  args.margin = int(brand.get("safe_margin", args.margin))
@@ -368,6 +369,7 @@ def main() -> int:
368
369
  args.font = brand.get("font", args.font)
369
370
  if not args.font_file and brand.get("font_file"):
370
371
  args.font_file = brand["font_file"]
372
+ font_file_from = "the --brand file's font_file"
371
373
  if args.text:
372
374
  # 1.15: drawtext never reorders or re-clusters (no harfbuzz), so Devanagari matras and
373
375
  # Thai/Lao mark stacking come out wrong on EVERY build. A wrong frame is not a delivery:
@@ -393,6 +395,8 @@ def main() -> int:
393
395
  "a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
394
396
  "leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
395
397
  "emoji_monochrome")
398
+ if args.text and args.font_file:
399
+ require_font_file(args.font_file, font_file_from)
396
400
  script_file = script_family = None
397
401
  if args.text and not args.font_file:
398
402
  # 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
@@ -533,6 +537,11 @@ def main() -> int:
533
537
  # a --start/--end window is in the layer's position, which is evaluated on the main
534
538
  # picture's frames; overlay's enable is evaluated on the layer's frames too
535
539
  ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
540
+ if not meta.get("duration"):
541
+ # nothing to bound by -t: the overlay ends with the main picture. -shortest alone never
542
+ # ended a still main (one picture, no duration): overlay kept repeating it under the
543
+ # looped layer and the run went on until its timeout (#510)
544
+ ov += ":shortest=1"
536
545
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
537
546
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
538
547
  # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
package/scripts/report.py CHANGED
@@ -14,6 +14,7 @@ import base64
14
14
  import html
15
15
  import json
16
16
  import os
17
+ import re
17
18
  import sys
18
19
  import tempfile
19
20
  from pathlib import Path
@@ -145,6 +146,10 @@ def md_cells(line: str) -> List[str]:
145
146
  return cells
146
147
 
147
148
 
149
+ # the columns of render.py's render_pack() table, in its order
150
+ PACK_HEADER = ["platform", "file", "size", "duration", "check"]
151
+
152
+
148
153
  def pack_report(args) -> int:
149
154
  """render.py --template all writes <stem>_pack.md: one row per destination. This renders the
150
155
  same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
@@ -152,19 +157,37 @@ def pack_report(args) -> int:
152
157
  text = read_text_or_die(args.pack, "--pack")
153
158
  rows: List[List[str]] = []
154
159
  header: List[str] = []
160
+ delimiter: Optional[List[str]] = None
155
161
  for line in text.split("\n"):
156
162
  line = line.strip()
157
163
  if not line.startswith("|"):
164
+ if header:
165
+ break # the table ended: a later table in the file is not the pack's
158
166
  continue
159
167
  cells = md_cells(line)
160
- if all(set(c) <= set("-: ") for c in cells):
161
- continue
162
168
  if not header:
163
169
  header = cells
170
+ elif delimiter is None:
171
+ delimiter = cells # the line under the header, whatever it is: checked below
164
172
  else:
173
+ # every later line is a row, a separator-like one too: `| | |` after a row was skipped
174
+ # as a delimiter and never counted (review of #532)
165
175
  rows.append(cells)
176
+ # the table render.py --template all writes, and only that one: any Markdown table was taken
177
+ # and handed over as a pack report with `verified: true` (#510)
178
+ if header and header != PACK_HEADER:
179
+ die(f"{args.pack}: its table's columns are {' | '.join(header)}, not the pack table's "
180
+ f"({' | '.join(PACK_HEADER)}) that render.py --template all writes", kind="input")
181
+ # a header with a row under it and no delimiter line is no Markdown table (review of #532)
182
+ if header and delimiter is not None and not (
183
+ len(delimiter) == len(PACK_HEADER) and all(re.fullmatch(r":?-+:?", c) for c in delimiter)):
184
+ die(f"{args.pack}: the line under the pack table's header is not its delimiter row "
185
+ f"(|---|---|---|---|---|), so it is no Markdown table", kind="input")
186
+ bad = next((i for i, r in enumerate(rows) if len(r) != len(PACK_HEADER)), None)
187
+ if bad is not None:
188
+ die(f"{args.pack}: row {bad + 1} of the pack table has {len(rows[bad])} cells, not {len(PACK_HEADER)}", kind="input")
166
189
  if not rows:
167
- die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)")
190
+ die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)", kind="input")
168
191
  title = args.title or f"Social pack — {Path(args.pack).stem.replace('_pack', '')}"
169
192
  output = args.output or str(Path(args.pack).with_suffix(".html"))
170
193
  guard_sidecar(output, args.pack)