ffmpeg-skill 1.15.1 → 1.16.1
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 -3
- package/SKILL.md +8 -7
- package/docs/contract.md +24 -9
- package/package.json +1 -1
- package/references/scripts.md +128 -11
- package/scripts/_common/__init__.py +21 -4
- package/scripts/_common/decision.py +106 -1
- package/scripts/_common/probe.py +92 -2
- package/scripts/_common/text.py +552 -1
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +260 -200
- package/scripts/check.py +19 -0
- package/scripts/graphics.py +47 -8
- package/scripts/metadata.py +130 -5
- package/scripts/render.py +33 -6
- package/scripts/scenes.py +3 -61
- package/scripts/silence.py +3 -25
- package/scripts/waveform.py +199 -12
- package/templates/audiogram.json +31 -0
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")
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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
|
|
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
|
-
|
|
414
|
-
|
|
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,
|
package/scripts/metadata.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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]:
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
-
|
|
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]]:
|