ffmpeg-skill 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scripts/check.py CHANGED
@@ -173,6 +173,25 @@ def main() -> int:
173
173
  row("chapters", "PASS" if chapters else "WARN", f"{len(chapters)}" if chapters else "none", ">= 1 chapter marker",
174
174
  "metadata.py episode.m4a --chapters chapters.txt (`TIME TITLE` per line; streams copied)",
175
175
  reason="chapter markers are optional, but a podcast app shows them as the episode's seekable table of contents")
176
+ # Subtitles, informational and on every platform: a soft subtitle track that carries no
177
+ # language tag is the defect caption.py --mode mux's multi-track form exists to prevent -- a
178
+ # player lists it as "Track 2" and a viewer cannot tell which language it is. Never counted in
179
+ # `failed`: no platform refuses a delivery over it.
180
+ subs = meta.get("subtitle_stream_details") or []
181
+ if not subs:
182
+ row("subtitles", "WARN", "none", ">= 1 language-tagged subtitle track",
183
+ "caption.py video.mp4 --mode mux --srt subs.srt:en -o video_subs.mkv (streams are copied)",
184
+ reason="subtitles are optional, but they are the cheapest accessibility win a delivery has")
185
+ else:
186
+ untagged = [str(s["index"]) for s in subs if not s.get("language")]
187
+ listed = ", ".join((s.get("language") or "untagged") for s in subs)
188
+ row("subtitles", "WARN" if untagged else "PASS", f"{len(subs)} ({listed})",
189
+ "every track tagged with its language",
190
+ "caption.py video.mp4 --mode mux --srt subs.srt:en -o video_subs.mkv, or re-mux with `--srt file:lang` per track"
191
+ if untagged else "",
192
+ reason=("stream(s) " + ", ".join(untagged) + " carry no language tag: a player lists them "
193
+ "by number and the viewer has to guess") if untagged else "")
194
+
176
195
  if a:
177
196
  if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
178
197
  row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
@@ -38,7 +38,8 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
38
38
  drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
39
39
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
40
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
41
- EMOJI_ASSET_HINT, text_width_em, drawtext_shaping)
41
+ EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
42
+ SAFE_WIDTH_FRACTION)
42
43
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
43
44
 
44
45
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
@@ -91,6 +92,12 @@ def main() -> int:
91
92
  ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
92
93
  "text is Chinese, Japanese or Korean when a font is picked by script")
93
94
  ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
95
+ ap.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
96
+ help="how a label too wide for the frame is broken into lines: 'phrase' (default, 1.16) never "
97
+ "breaks inside a word or a hyphen's wrong side, never leaves a lone digit, kana or "
98
+ "punctuation on its own line, prefers Japanese sentence ends and particles, and never ends "
99
+ "a line on an article or preposition; 'measured' is 1.15's width-only wrap. A label that "
100
+ "already fits one line is untouched either way")
94
101
  ap.add_argument("--text-render", choices=["auto", "ass", "drawtext"], default="auto",
95
102
  help="which renderer draws the template's text: 'auto' (default) uses libass for "
96
103
  "scripts drawtext cannot shape (Devanagari, Bengali, Tamil, Thai, Lao ...) and for "
@@ -247,6 +254,32 @@ def main() -> int:
247
254
  return os.path.dirname(os.path.abspath(script_file))
248
255
  return None
249
256
 
257
+ def chip_frac(left, right, pad):
258
+ """The fraction of the frame a boxed label may use: the span between the margins, less
259
+ drawtext's own box padding on each side."""
260
+ usable = W - left - right - 2 * pad
261
+ return max(0.1, min(SAFE_WIDTH_FRACTION, usable / float(W))) if W else SAFE_WIDTH_FRACTION
262
+
263
+ def wrapped(text, size_px, frac=SAFE_WIDTH_FRACTION):
264
+ """A label broken to the frame's safe width (1.16).
265
+
266
+ drawtext and libass both render a literal newline as a line break, and every template
267
+ below already passes its label through one of them -- so wrapping is a matter of putting
268
+ the breaks in, at the same measured width and by the same four phrase rules caption.py
269
+ uses. A label that already fits comes back unchanged, which is why this is additive: the
270
+ only text it touches is text that used to run off the edge of the frame.
271
+ """
272
+ text = str(text or "")
273
+ if not text or not size_px:
274
+ return text
275
+ max_em = (W * frac) / float(size_px)
276
+ if max_em <= 0:
277
+ return text
278
+ out = []
279
+ for para in text.split("\n"):
280
+ out.extend(wrap_text(para, max_em, mode=args.wrap, lang=args.lang) if para.strip() else [para])
281
+ return "\n".join(out)
282
+
250
283
  def add_text(text, drawtext, *, target=None, **el):
251
284
  """One line of template text: a drawtext filter on the old route, an ASS element on the
252
285
  new one. The geometry is computed identically either way."""
@@ -366,8 +399,11 @@ def main() -> int:
366
399
  align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
367
400
  y_rest = m_top if "top" in pos else H - m_bottom
368
401
  y_start = y_rest + rise if "top" in pos else y_rest + rise
369
- add_text(args.text,
370
- f"drawtext={drawtext_text_opts(args.text)}:{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
402
+ # the chip is a box between the two side margins, not the whole frame: wrapping to the
403
+ # frame width let a long --text run off the plate even though it "fitted"
404
+ sticker_text = wrapped(args.text, fs, chip_frac(m_left, m_right, padx))
405
+ add_text(sticker_text,
406
+ f"drawtext={drawtext_text_opts(sticker_text)}:{fo}:fontsize={fs}:fontcolor={ff_color(bg)}:"
371
407
  f"x={xe}:y='{ye}':box=1:boxcolor={ff_color(primary, 0.95)}:boxborderw={drawtext_boxborderw(pady, padx)}:"
372
408
  f"alpha='{alpha}':{en}",
373
409
  size=fs, color=bg, font=ass_font_family(), align=align,
@@ -391,8 +427,9 @@ def main() -> int:
391
427
  band_h = int(base * 0.30)
392
428
  y0 = (H - band_h) // 2
393
429
  filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
394
- add_text(args.title,
395
- f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
430
+ hook_title = wrapped(args.title, h1)
431
+ add_text(hook_title,
432
+ f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:"
396
433
  f"x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
397
434
  size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
398
435
  outline=max(1.0, h1 / 20.0), outline_color="000000", start=s, end=he)
@@ -401,7 +438,8 @@ def main() -> int:
401
438
 
402
439
  elif args.template == "meme":
403
440
  # The classic layout: heavy white upper-case lines with a black outline, top and bottom,
404
- # sized so a short line fills the frame's width without wrapping (drawtext never wraps).
441
+ # sized so a short line fills the frame's width; a longer one is broken by the same
442
+ # phrase-aware wrap caption.py uses (1.16 -- drawtext itself still never wraps).
405
443
  if not (args.top or args.bottom):
406
444
  die("meme needs --top and/or --bottom")
407
445
  fs = int(base * 0.09)
@@ -410,8 +448,9 @@ def main() -> int:
410
448
  for text, y in ((args.top, f"{m_top}"), (args.bottom, f"h-text_h-{m_bottom}")):
411
449
  if not text:
412
450
  continue
413
- add_text(text.upper(),
414
- f"drawtext={drawtext_text_opts(text.upper())}:{fo}:fontsize={fs}:fontcolor={white}:"
451
+ meme_line = wrapped(text.upper(), fs)
452
+ add_text(meme_line,
453
+ f"drawtext={drawtext_text_opts(meme_line)}:{fo}:fontsize={fs}:fontcolor={white}:"
415
454
  f"borderw={bw}:bordercolor={black}:x=(w-text_w)/2:y={y}:{en}",
416
455
  size=fs, color="FFFFFF", font=ass_font_family(), bold=True,
417
456
  align=(8 if y == f"{m_top}" else 2), x=W / 2,
@@ -30,7 +30,9 @@ import tempfile
30
30
  from pathlib import Path
31
31
  from typing import Any, Dict, List, Optional
32
32
 
33
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, STATE, read_text_or_die
33
+ from _common import (add_common, apply_common, default_output, description_block, detect_scenes, detect_silences,
34
+ die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
35
+ STATE, read_text_or_die)
34
36
 
35
37
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
36
38
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -82,12 +84,119 @@ def write_ffmetadata(chapters: List[Dict[str, Any]], path: str) -> None:
82
84
  Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8")
83
85
 
84
86
 
87
+ def propose(args, meta: Dict[str, Any], duration: float,
88
+ notes: List[str]) -> "tuple":
89
+ """--auto-chapters: measure the structure, then hand it to the pure proposer.
90
+
91
+ The detectors are the ones silence.py and scenes.py run (they live in _common/probe.py since
92
+ 1.16 so that no tool has to import another tool); the decision is propose_chapters(), which
93
+ is pure and never looks at content.
94
+ """
95
+ source = args.detect_from
96
+ if not meta.get("audio") and source in ("silence", "both"):
97
+ if source == "silence":
98
+ die("--from silence needs an input with an audio stream; use --from scenes", kind="input")
99
+ source = "scenes"
100
+ notes.append("no audio stream: the markers come from scene changes alone (--from scenes)")
101
+ if not meta.get("video") and source in ("scenes", "both"):
102
+ if source == "scenes":
103
+ die("--from scenes needs an input with a video stream; use --from silence", kind="input")
104
+ source = "silence"
105
+ notes.append("no video stream: the markers come from the pauses alone (--from silence)")
106
+ silences = detect_silences(args.input, args.silence_threshold, args.silence_min) \
107
+ if source in ("silence", "both") else []
108
+ cuts = detect_scenes(args.input, args.scene_threshold, 1.0, duration) \
109
+ if source in ("scenes", "both") else []
110
+ if source == "both":
111
+ notes.append("measured in 2 passes (silencedetect and scdet each decode the file once); "
112
+ "--from silence is the cheap path")
113
+ proposed = propose_chapters(duration, silences, cuts, min_chapter=args.min_chapter,
114
+ max_chapters=args.max_chapters, source=source)
115
+ if len(proposed) == 1:
116
+ # every suggestion has to be a value the caller is not already using, or the hint reads
117
+ # "try --min-chapter 1" to someone who passed --min-chapter 1
118
+ tries = []
119
+ shorter_silence = round(args.silence_min / 2.0, 2)
120
+ if shorter_silence >= 0.1 and shorter_silence < args.silence_min:
121
+ tries.append(f"--silence-min {shorter_silence:g}")
122
+ shorter_chapter = max(1.0, round(args.min_chapter / 2.0, 2))
123
+ if shorter_chapter < args.min_chapter:
124
+ tries.append(f"--min-chapter {shorter_chapter:g}")
125
+ if args.detect_from != "both" and meta.get("audio") and meta.get("video"):
126
+ tries.append("--from both")
127
+ notes.append(f"no pause longer than {args.silence_min:g}s and no scene change far enough apart "
128
+ "to start a chapter: the file gets one marker at 0:00"
129
+ + (". Try " + " or ".join(tries) if tries else
130
+ ", and the thresholds are already as low as this tool will suggest"))
131
+ total = len([1 for _s in silences]) + len([c for c in cuts if c > 0])
132
+ auto = {
133
+ "source": {"silence": "silence", "scenes": "scenes", "both": "silence+scenes"}[source],
134
+ "min_chapter": float(args.min_chapter),
135
+ "max_chapters": int(args.max_chapters),
136
+ "proposed": total,
137
+ "kept": len(proposed),
138
+ "titles": "placeholder",
139
+ "chapters": proposed,
140
+ "description_block": description_block(proposed),
141
+ }
142
+ entries = [{"start": c["at"], "title": c["title"]} for c in proposed]
143
+ for i, e in enumerate(entries):
144
+ e["end"] = entries[i + 1]["start"] if i + 1 < len(entries) else duration
145
+ info(f"proposed {len(entries)} chapters from {auto['source']} "
146
+ f"(titles are placeholders: Chapter 1..{len(entries)})")
147
+ return entries, auto
148
+
149
+
150
+ def write_proposal(args, chapters: List[Dict[str, Any]]) -> Dict[str, Any]:
151
+ """--chapters-out / --description-out, the two side files a caller edits and feeds back."""
152
+ files: Dict[str, Any] = {"chapters": None, "description": None}
153
+ if args.chapters_out:
154
+ body = "\n".join(f"{fmt_chapter_time(c['at'])} {c['title']}" for c in chapters) + "\n"
155
+ if STATE.dry_run:
156
+ info(f"[dry-run] would write {args.chapters_out} ({len(chapters)} chapters)")
157
+ else:
158
+ Path(args.chapters_out).write_text(body, encoding="utf-8")
159
+ info(f"wrote {args.chapters_out} ({len(chapters)} chapters)")
160
+ files["chapters"] = args.chapters_out
161
+ if args.description_out:
162
+ body = description_block(chapters) + "\n"
163
+ if STATE.dry_run:
164
+ info(f"[dry-run] would write {args.description_out}")
165
+ else:
166
+ Path(args.description_out).write_text(body, encoding="utf-8")
167
+ info(f"wrote {args.description_out}")
168
+ files["description"] = args.description_out
169
+ return files
170
+
171
+
85
172
  def main() -> int:
86
173
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
87
174
  ap.add_argument("input")
88
175
  ap.add_argument("-o", "--output", help="output file (default: <name>_meta.<ext>)")
89
176
  ap.add_argument("--chapters", help="text file, one chapter per line: `TIME TITLE` (cut.py time syntax)")
90
177
  ap.add_argument("--clear-chapters", action="store_true", help="remove every chapter marker the input carries")
178
+ auto = ap.add_argument_group("proposed chapters (1.16)",
179
+ "Chapter timestamps measured from the file's own structure instead of read from a "
180
+ "file. The markers are proposed, the titles are not: every one is `Chapter N` and "
181
+ "renaming them is the caller's job -- knowing what is said in a chapter is not "
182
+ "something this skill can measure. Two detectors mean two full decodes of the "
183
+ "input; --from silence is the cheap path.")
184
+ auto.add_argument("--auto-chapters", action="store_true",
185
+ help="propose the markers from measured pauses and scene changes instead of reading --chapters FILE")
186
+ auto.add_argument("--min-chapter", type=float, default=60.0,
187
+ help="shortest chapter in seconds (default 60, a long-form default: a 12-minute episode gets at most 12)")
188
+ auto.add_argument("--max-chapters", type=int, default=0,
189
+ help="cap the number of markers (default 0 = no cap); the weakest evidence is dropped first")
190
+ auto.add_argument("--from", dest="detect_from", choices=["silence", "scenes", "both"], default="both",
191
+ help="which detector(s) propose the markers (default both)")
192
+ auto.add_argument("--silence-threshold", type=float, default=-35.0,
193
+ help="silence level in dBFS for --auto-chapters (default -35)")
194
+ auto.add_argument("--silence-min", type=float, default=1.5,
195
+ help="a pause must last this long to propose a chapter (default 1.5 -- deliberately longer than silence.py's 0.6: a chapter break is a long pause)")
196
+ auto.add_argument("--scene-threshold", type=float, default=8.0,
197
+ help="minimum scdet score for a scene cut with --auto-chapters (default 8, as scenes.py)")
198
+ auto.add_argument("--chapters-out", help="write the proposal as a `TIME TITLE` file in this tool's own --chapters format, so the titles can be edited and fed back")
199
+ auto.add_argument("--description-out", help="write the YouTube description block (`00:00 Chapter 1` per line)")
91
200
  for key in TAG_KEYS:
92
201
  ap.add_argument(f"--{key}", help=f"set the container's {key} tag (empty string clears it)")
93
202
  add_common(ap)
@@ -96,9 +205,15 @@ def main() -> int:
96
205
 
97
206
  if args.chapters and args.clear_chapters:
98
207
  die("--chapters and --clear-chapters exclude each other")
208
+ if args.auto_chapters and (args.chapters or args.clear_chapters):
209
+ die("--auto-chapters proposes the markers; it excludes --chapters FILE and --clear-chapters")
210
+ if args.min_chapter <= 0:
211
+ die(f"--min-chapter must be > 0, got {args.min_chapter:g}")
212
+ if args.max_chapters < 0:
213
+ die(f"--max-chapters must be >= 0 (0 = no cap), got {args.max_chapters}")
99
214
  tags = {k: getattr(args, k) for k in TAG_KEYS if getattr(args, k) is not None}
100
- if not args.chapters and not args.clear_chapters and not tags:
101
- die("nothing to write: give --chapters FILE, --clear-chapters and/or --title/--artist/...")
215
+ if not args.chapters and not args.clear_chapters and not args.auto_chapters and not tags:
216
+ die("nothing to write: give --chapters FILE, --auto-chapters, --clear-chapters and/or --title/--artist/...")
102
217
  if args.chapters and not os.path.exists(args.chapters):
103
218
  die(f"chapters file not found: {args.chapters}")
104
219
 
@@ -107,12 +222,16 @@ def main() -> int:
107
222
  if os.path.abspath(output) == os.path.abspath(args.input):
108
223
  die("output must differ from the input (metadata.py never rewrites a file in place)")
109
224
  out_ext = Path(output).suffix.lower()
110
- if (args.chapters or args.clear_chapters) and out_ext not in CHAPTER_CONTAINERS:
225
+ if (args.chapters or args.clear_chapters or args.auto_chapters) and out_ext not in CHAPTER_CONTAINERS:
111
226
  die(f"{out_ext or 'this'} container cannot hold chapter markers; write to one of "
112
227
  f"{', '.join(sorted(CHAPTER_CONTAINERS))} (the streams are copied, so choose the matching family: .mp4/.mov/.m4a for MPEG-4, .mkv/.mka/.webm for Matroska)")
113
228
 
114
229
  duration = meta.get("duration") or 0.0
115
230
  chapters: Optional[List[Dict[str, Any]]] = parse_chapters(args.chapters, duration) if args.chapters else None
231
+ auto: Optional[Dict[str, Any]] = None
232
+ notes: List[str] = []
233
+ if args.auto_chapters:
234
+ chapters, auto = propose(args, meta, duration, notes)
116
235
 
117
236
  cmd = ffmpeg_base() + ["-i", args.input]
118
237
  tmpdir = None
@@ -139,7 +258,13 @@ def main() -> int:
139
258
  if args.clear_chapters and not STATE.dry_run and written:
140
259
  die(f"{len(written)} chapters survived --clear-chapters", kind="output")
141
260
  info(f"wrote {output} ({len(written)} chapters, tags: {', '.join(sorted(tags)) or 'unchanged'}, streams copied)")
142
- emit(output, chapters=written, tags=result.get("tags") or {}, streams_copied=True)
261
+ extra: Dict[str, Any] = {}
262
+ if auto is not None:
263
+ auto["files"] = write_proposal(args, auto["chapters"])
264
+ extra["auto_chapters"] = auto
265
+ if notes:
266
+ extra["notes"] = notes
267
+ emit(output, chapters=written, tags=result.get("tags") or {}, streams_copied=True, **extra)
143
268
  return 0
144
269
 
145
270
 
package/scripts/render.py CHANGED
@@ -67,7 +67,7 @@ HERE = Path(__file__).resolve().parent
67
67
  TEMPLATE_DIR = HERE.parent / "templates"
68
68
  # The placeholders a delivery template carries; a block whose placeholder has no value
69
69
  # (no --logo, no --title, no cues) is dropped from the filled project rather than rendered empty.
70
- PLACEHOLDERS = ("$INPUT", "$OUTPUT", "$CUES", "$SRT", "$LOGO", "$TITLE", "$BRAND", "$CHAPTERS")
70
+ PLACEHOLDERS = ("$INPUT", "$OUTPUT", "$CUES", "$SRT", "$LOGO", "$TITLE", "$BRAND", "$CHAPTERS", "$IMAGE")
71
71
  # Intermediates follow the delivery's own media kind: an audio-only project (the podcast
72
72
  # template) must not carry its stages through .mp4 containers.
73
73
  AUDIO_EXT = frozenset({".wav", ".m4a", ".mp3", ".flac", ".aac", ".ogg", ".opus"})
@@ -97,11 +97,14 @@ TEMPLATE = {
97
97
  OBJECT_KEYS: Dict[str, frozenset] = {
98
98
  "project": frozenset({"output", "frame", "clips", "transition", "silence", "brand", "captions",
99
99
  "graphics", "overlays", "audio", "loudness", "fit", "export", "check", "chapters",
100
- "template"}),
100
+ "audiogram", "template"}),
101
101
  "clips[]": frozenset({"src", "in", "out", "speed"}),
102
102
  "frame": frozenset({"aspect", "width", "height", "fps", "fit"}),
103
103
  "transition": frozenset({"type", "duration"}),
104
104
  "silence": frozenset({"threshold", "min_silence", "margin"}),
105
+ # 1.16: the picture an audio-only source gets before the rest of the chain can work on it
106
+ "audiogram": frozenset({"image", "image_fit", "style", "position", "vis_height", "opacity",
107
+ "platform", "title", "color", "background", "width", "height", "fps"}),
105
108
  "captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
106
109
  "animate", "highlight_color", "outline", "karaoke", "bold", "box",
107
110
  "lang", "offset", "max_lines", "min_duration"}),
@@ -202,9 +205,10 @@ def template_project(name: str, args) -> Dict[str, Any]:
202
205
  "$BRAND": os.path.abspath(args.brand) if args.brand else None,
203
206
  "$CHAPTERS": os.path.abspath(args.chapters) if args.chapters else None,
204
207
  "$TITLE": args.title,
208
+ "$IMAGE": os.path.abspath(args.image) if args.image else None,
205
209
  }
206
210
  for flag, path in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
207
- ("--brand", args.brand), ("--chapters", args.chapters)):
211
+ ("--brand", args.brand), ("--chapters", args.chapters), ("--image", args.image)):
208
212
  if path and not os.path.isfile(path):
209
213
  die(f"{flag}: file not found: {path}")
210
214
  proj, _ = fill_template(tpl, values)
@@ -286,7 +290,7 @@ def render_pack(names: List[str], args) -> int:
286
290
  argv = [str(HERE / "render.py"), args.input, "--template", name, "-o", dest_out]
287
291
  for flag, value in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
288
292
  ("--title", args.title), ("--brand", args.brand), ("--fit", args.fit),
289
- ("--chapters", args.chapters)):
293
+ ("--chapters", args.chapters), ("--image", args.image)):
290
294
  if value:
291
295
  argv += [flag, str(value)]
292
296
  info(f"→ pack: {name}")
@@ -355,7 +359,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
355
359
 
356
360
  def validate_project(proj: Dict[str, Any]) -> None:
357
361
  check_keys(proj, "project", "project")
358
- for name in ("frame", "transition", "silence", "captions", "audio", "loudness", "fit", "export", "check"):
362
+ for name in ("frame", "transition", "silence", "audiogram", "captions", "audio", "loudness", "fit", "export", "check"):
359
363
  check_keys(proj.get(name), name, name)
360
364
  check_keys((proj.get("audio") or {}).get("stems"), "audio.stems", "audio.stems")
361
365
  if isinstance(proj.get("chapters"), list):
@@ -508,6 +512,7 @@ def main() -> int:
508
512
  tpl.add_argument("--title", help="title text for the template's opening card / lower third")
509
513
  tpl.add_argument("--brand", help="brand.json the template's captions, graphics and overlays use")
510
514
  tpl.add_argument("--chapters", help="chapter file (podcast template)")
515
+ tpl.add_argument("--image", help="still image behind the visualisation (audiogram template); a local file, nothing is fetched")
511
516
  tpl.add_argument("--fit", choices=["crop", "pad", "blur"], help="override how the template reaches its aspect")
512
517
  tpl.add_argument("-o", "--output", help="output file (default: next to the input, <input>_<template>.mp4, "
513
518
  "or .m4a for an audio-only destination); for a list of templates "
@@ -544,7 +549,7 @@ def main() -> int:
544
549
  else:
545
550
  for flag, value in (("--cues", args.cues), ("--srt", args.srt), ("--logo", args.logo),
546
551
  ("--title", args.title), ("--brand", args.brand), ("--chapters", args.chapters),
547
- ("--fit", args.fit), ("--write-project", args.write_project)):
552
+ ("--image", args.image), ("--fit", args.fit), ("--write-project", args.write_project)):
548
553
  if value:
549
554
  die(f"{flag} belongs to --template NAME INPUT; a project.json states it in the project itself")
550
555
  try:
@@ -645,6 +650,28 @@ def main() -> int:
645
650
  parts.append(part)
646
651
  stages_done.append("clips")
647
652
  current = parts[0]
653
+
654
+ # ---- audiogram: the picture an audio-only source needs before anything else can work on it
655
+ ag = proj.get("audiogram")
656
+ if ag:
657
+ if not ag.get("image") and not ag.get("background"):
658
+ die('audiogram: give an "image" (a local file) or a "background" colour -- this skill '
659
+ "never fetches a picture and never invents cover art", kind="input")
660
+ nxt = str(work / f"audiogram{mid}")
661
+ argv = [current, "-o", nxt]
662
+ for key, flag in (("image", "--image"), ("image_fit", "--image-fit"), ("style", "--style"),
663
+ ("position", "--position"), ("vis_height", "--vis-height"),
664
+ ("opacity", "--opacity"), ("platform", "--platform"), ("title", "--title"),
665
+ ("color", "--color"), ("background", "--background"),
666
+ ("width", "--width"), ("height", "--height"), ("fps", "--fps")):
667
+ if ag.get(key) is not None:
668
+ argv += [flag, str(ag[key])]
669
+ argv += brand_args
670
+ sh("waveform.py", *argv)
671
+ current = nxt
672
+ parts = [current]
673
+ stages_done.append("audiogram")
674
+
648
675
  if args.stop_after == "clips":
649
676
  emit(current, stages=stages_done)
650
677
  return 0
package/scripts/scenes.py CHANGED
@@ -19,70 +19,12 @@ Examples:
19
19
  """
20
20
  import argparse
21
21
  import math
22
- import os
23
- import re
24
22
  import sys
25
23
  from typing import Dict, List, Tuple
26
24
 
27
- from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
28
-
29
- SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
30
-
31
-
32
- def detect_scenes(path: str, threshold: float, min_len: float, duration: float, ratio: float = 3.0) -> List[float]:
33
- """Scene cuts = frames whose scdet score is above `threshold` AND stands out from its
34
- neighbourhood (score > ratio x median of the surrounding +-12 frames). Sustained motion,
35
- flashes and fast pans raise the score on many consecutive frames and are rejected;
36
- a real cut is a one-frame spike. On real footage this roughly doubles precision at
37
- equal recall compared with the raw scdet threshold."""
38
- ffmpeg = require_tool("ffmpeg")
39
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
40
- "scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
41
- # No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
42
- # score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
43
- # card, colour bars) is dropped before metadata=print and the frame numbers are re-counted
44
- # without them. The +-12-frame neighbourhood around a real cut then fills with the moving
45
- # segment's scores instead of the still one's zeros, the cut fails the ratio test, and a
46
- # 4 s smptebars scene made the cuts on both sides of it disappear (found by the 5.1.1 CI
47
- # job, #146). 6.1+ passes every frame either way. Scores are still indexed by frame number
48
- # and any frame the filter did not report counts as 0, so a build that drops frames again
49
- # cannot shift the neighbourhood.
50
- by_frame: Dict[int, Tuple[float, float]] = {}
51
- cur = None
52
- for line in proc.stdout.splitlines():
53
- m = SCORE_RE.match(line)
54
- if m:
55
- cur = (int(m.group(1)), float(m.group(2)))
56
- continue
57
- if line.startswith("lavfi.scd.score=") and cur is not None:
58
- try:
59
- by_frame[cur[0]] = (cur[1], float(line.split("=", 1)[1]))
60
- except ValueError:
61
- pass
62
- cuts = [0.0]
63
- if not by_frame:
64
- return cuts
65
- n_frames = max(by_frame) + 1
66
- times: List[float] = [by_frame[i][0] if i in by_frame else -1.0 for i in range(n_frames)]
67
- scores: List[float] = [by_frame[i][1] if i in by_frame else 0.0 for i in range(n_frames)]
68
- w = 12
69
- for i, sc in enumerate(scores):
70
- if sc < threshold:
71
- continue
72
- lo, hi = max(0, i - w), min(len(scores), i + w + 1)
73
- neigh = sorted(scores[lo:i] + scores[i + 1:hi])
74
- med = neigh[len(neigh) // 2] if neigh else 0.0
75
- if sc < ratio * max(med, 0.5):
76
- continue
77
- # keep only the local maximum inside +-2 frames
78
- if any(scores[j] > sc for j in range(max(0, i - 2), min(len(scores), i + 3)) if j != i):
79
- continue
80
- t = times[i]
81
- if t - cuts[-1] >= min_len:
82
- cuts.append(t)
83
- if duration - cuts[-1] < min_len and len(cuts) > 1:
84
- cuts.pop()
85
- return cuts
25
+ # `detect_scenes` moved into _common/probe.py in 1.16.0 (see silence.py); the body is unchanged.
26
+ from _common import detect_scenes, STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, run, decode_pcm_mono, rms_envelope
27
+
86
28
 
87
29
 
88
30
  def audio_envelope(path: str, step_s: float) -> List[float]:
@@ -13,35 +13,13 @@ Examples:
13
13
  """
14
14
  import argparse
15
15
  import os
16
- import re
17
16
  import sys
18
17
  from typing import List, Tuple
19
18
 
20
- from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending, fmt_secs
19
+ # `detect` moved into _common/probe.py in 1.16.0 so metadata.py --auto-chapters can measure the
20
+ # same silences without importing this tool; the body is unchanged and the name still lives here.
21
+ from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
21
22
 
22
- SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
23
-
24
-
25
- def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
26
- if dry_run_input_pending(path):
27
- return []
28
- ffmpeg = require_tool("ffmpeg")
29
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
30
- f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
31
- proc = run_analysis(cmd, check=False, record=True)
32
- if proc.returncode != 0:
33
- die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
34
- silences: List[Tuple[float, float]] = []
35
- start = None
36
- for kind, val in SIL_RE.findall(proc.stderr):
37
- if kind == "start":
38
- start = float(val)
39
- elif start is not None:
40
- silences.append((start, float(val)))
41
- start = None
42
- if start is not None: # silence runs to the end
43
- silences.append((start, float("inf")))
44
- return silences
45
23
 
46
24
 
47
25
  def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]: