ffmpeg-skill 3.8.8 → 3.8.10
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 +1 -1
- package/docs/contract.md +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +7 -4
- package/references/scripts.md +35 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +33 -6
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +260 -72
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
package/scripts/cropdetect.py
CHANGED
|
@@ -43,12 +43,30 @@ MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
|
43
43
|
CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def round_rect(rect: Tuple[int, int, int, int], round_to: int, full: Tuple[int, int]) -> Tuple[int, int, int, int]:
|
|
47
|
+
"""One measured rectangle (w, h, x, y) as it is reported: the whole frame when the picture
|
|
48
|
+
reaches every edge (cropdetect's own round=2 reading of an odd side is one pixel short of it),
|
|
49
|
+
else rounded down to a multiple of `round_to` and re-centred the way cropdetect's round option
|
|
50
|
+
does it. Rounding inside the filter made a bar-free 1920x1080 read 1920x1072 at the default 16
|
|
51
|
+
(#403): 1080 is not a multiple of 16, and the rounded size was then compared with the source's."""
|
|
52
|
+
w, h, x, y = rect
|
|
53
|
+
fw, fh = full
|
|
54
|
+
if fw and fh and x == 0 and y == 0 and w >= fw - fw % 2 and h >= fh - fh % 2:
|
|
55
|
+
return fw, fh, 0, 0
|
|
56
|
+
# the filter's own reading of its round option: 1 is 16, an odd value is doubled
|
|
57
|
+
round_to = 16 if round_to <= 1 else round_to * 2 if round_to % 2 else round_to
|
|
58
|
+
shrink_w, shrink_h = w % round_to, h % round_to
|
|
59
|
+
return w - shrink_w, h - shrink_h, x + ((shrink_w // 2 + 1) & ~1), y + ((shrink_h // 2 + 1) & ~1)
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
|
|
47
|
-
failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
|
|
63
|
+
failed: "Optional[List[Dict]]" = None, square: str = "", full: Tuple[int, int] = (0, 0)) -> List[Tuple[int, int, int, int]]:
|
|
48
64
|
"""Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
|
|
49
65
|
is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
|
|
50
66
|
so the result can say how many of the readings it stands on. `square` is the source's
|
|
51
|
-
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411).
|
|
67
|
+
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411).
|
|
68
|
+
`full` is that frame's size: the filter measures at round=2 and round_rect() rounds each
|
|
69
|
+
reading, so a picture that fills the frame is reported as the frame."""
|
|
52
70
|
ffmpeg = require_tool("ffmpeg")
|
|
53
71
|
per_window = max(0.5, seconds / max(1, samples))
|
|
54
72
|
rects: List[Tuple[int, int, int, int]] = []
|
|
@@ -57,7 +75,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
57
75
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
58
76
|
start = max(0.0, start)
|
|
59
77
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
60
|
-
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round=
|
|
78
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round=2:reset=1", "-f", "null", "-"]
|
|
61
79
|
proc = run_analysis(cmd, check=False)
|
|
62
80
|
if proc.returncode != 0:
|
|
63
81
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
@@ -67,7 +85,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
67
85
|
failed.append({"start": round(start, 3), "seconds": round(per_window, 3), "error": failures[-1][0][:300]})
|
|
68
86
|
continue
|
|
69
87
|
for m in CROP_RE.finditer(proc.stderr):
|
|
70
|
-
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
88
|
+
rects.append(round_rect((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))), round_to, full))
|
|
71
89
|
if failures and len(failures) == samples:
|
|
72
90
|
die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
|
|
73
91
|
return rects
|
|
@@ -125,7 +143,7 @@ def main() -> int:
|
|
|
125
143
|
ap.add_argument("--seconds", type=float, default=10.0, help="total seconds of footage to sample across the file (default 10)")
|
|
126
144
|
ap.add_argument("--samples", type=int, default=5, help="number of windows spread across the file (default 5)")
|
|
127
145
|
ap.add_argument("--limit", type=float, default=0.0941176, help="black-pixel threshold, 0..1 (default ~0.094, cropdetect's own default)")
|
|
128
|
-
ap.add_argument("--round", type=int, default=16, dest="round_to", help="
|
|
146
|
+
ap.add_argument("--round", type=int, default=16, dest="round_to", help="a detected crop's width/height are rounded down to a multiple of this (default 16); a picture that reaches every edge is reported as the whole frame, unrounded")
|
|
129
147
|
ap.add_argument("--motion-centre", action="store_true",
|
|
130
148
|
help="report the motion centroid per second, sampled the same way as the crop "
|
|
131
149
|
"detection above (report only -- this tool never picks a reframe, it hands "
|
|
@@ -162,7 +180,7 @@ def main() -> int:
|
|
|
162
180
|
# as the top code of the source's bit depth (255 for 8 bits, 1023 for 10).
|
|
163
181
|
limit = args.limit if args.limit < 1 else float((1 << int(meta["video"].get("bit_depth") or 8)) - 1)
|
|
164
182
|
rects = detect(args.input, args.seconds, args.samples, limit, args.round_to, duration, failed,
|
|
165
|
-
square_pixels(meta["video"]))
|
|
183
|
+
square_pixels(meta["video"]), (sw, sh))
|
|
166
184
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
167
185
|
# crop measured on one window of five read as well-founded as one measured on all five
|
|
168
186
|
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
|
package/scripts/cut.py
CHANGED
|
@@ -58,7 +58,7 @@ import tempfile
|
|
|
58
58
|
import zlib
|
|
59
59
|
from typing import List, Tuple
|
|
60
60
|
|
|
61
|
-
from _common.runner import JsonTooDeep, fail_if_classified, read_json
|
|
61
|
+
from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
64
|
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
|
|
@@ -950,7 +950,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
950
950
|
fail_if_classified(proc) # a full disk fails the re-encode too: its error, not a retry (#415)
|
|
951
951
|
info("stream copy failed, falling back to re-encode")
|
|
952
952
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
|
|
953
|
-
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
953
|
+
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
954
954
|
out_meta = probe(dst) if not reencode and not STATE.dry_run and copy_t is None else None
|
|
955
955
|
if out_meta is not None:
|
|
956
956
|
# #367: a copy from MPEG-TS whose window holds no video keyframe writes no video at all
|
|
@@ -1222,7 +1222,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1222
1222
|
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
1223
1223
|
|
|
1224
1224
|
|
|
1225
|
-
def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
1225
|
+
def _grid_from_source(path: str, min_confidence: float, named: bool = False) -> "dict":
|
|
1226
1226
|
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
1227
1227
|
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
1228
1228
|
try:
|
|
@@ -1260,7 +1260,15 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
|
1260
1260
|
if isinstance(doc, dict):
|
|
1261
1261
|
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
1262
1262
|
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
1263
|
+
if doc is not None:
|
|
1264
|
+
die(f"--snap-source {path}: this JSON is not a scenes.py document (it is a {type(doc).__name__}, "
|
|
1265
|
+
"not an object with beat_grid) -- produce one with `scenes.py MUSIC --beats --json`", kind="input")
|
|
1263
1266
|
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
1267
|
+
if not samples and named:
|
|
1268
|
+
# not "no reliable beat grid in this audio": nothing was decoded, so nothing was measured (#404)
|
|
1269
|
+
die(f"--snap-source {path}: no audio could be decoded from it, and it is not a scenes.py --beats --json "
|
|
1270
|
+
"document, so no beat grid was measured", kind="input",
|
|
1271
|
+
hint="pass the music bed (a media file with sound) or the JSON `scenes.py MUSIC --beats --json` prints")
|
|
1264
1272
|
block = max(1, int(round(BEAT_RATE * 0.01)))
|
|
1265
1273
|
env = rms_envelope(samples, block)
|
|
1266
1274
|
# the envelope's real step is block / rate (220 / 22050 = 9.977 ms), not the 10 ms asked for:
|
|
@@ -1295,7 +1303,7 @@ def snap_segments(args, segments, meta, total):
|
|
|
1295
1303
|
die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
|
|
1296
1304
|
"one measured from noise, which is exactly what --snap beats must not cut to. Use "
|
|
1297
1305
|
"--snap none if you do not want the points moved at all.", kind="input")
|
|
1298
|
-
grid = _grid_from_source(source, args.min_confidence)
|
|
1306
|
+
grid = _grid_from_source(source, args.min_confidence, named=bool(args.snap_source))
|
|
1299
1307
|
confidence = float(grid.get("confidence") or 0.0)
|
|
1300
1308
|
tempo = grid.get("tempo_bpm")
|
|
1301
1309
|
if confidence < args.min_confidence or not grid.get("beats"):
|
|
@@ -1880,7 +1888,7 @@ def main() -> int:
|
|
|
1880
1888
|
requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
|
|
1881
1889
|
requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
|
|
1882
1890
|
requested_segments=[[round(s, 6), round(e, 6)] for s, e in requested] if len(requested) > 1 else None,
|
|
1883
|
-
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
1891
|
+
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None and not STATE.dry_run else None,
|
|
1884
1892
|
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
1885
1893
|
mode=mode, keyframe_snapped=report["keyframe_snapped"], start_snapped=report["start_snapped"],
|
|
1886
1894
|
reencode_reason=reencode_reason, least_exact_precision=report["least_exact_precision"],
|
package/scripts/export.py
CHANGED
|
@@ -256,8 +256,9 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
256
256
|
# than the picture, and the key has none
|
|
257
257
|
pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
|
|
258
258
|
same = all(a == b and a not in (None, 0) for a, b in pair.values())
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
# the two written files against each other: no expected value, so no provenance (#403: it said
|
|
260
|
+
# "output_measurement", which is not one of the contract's values)
|
|
261
|
+
steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair}]
|
|
261
262
|
key_level = _luma_stats(key)
|
|
262
263
|
level = _alpha_compare(key_level, source_alpha)
|
|
263
264
|
steps.append({"step": "key_level", "property": "key_mean_vs_source_alpha", "ok": level["ok"], "comparison": level,
|
package/scripts/graphics.py
CHANGED
|
@@ -34,6 +34,8 @@ import sys
|
|
|
34
34
|
from typing import List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
|
+
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
38
|
+
from _common.drawtext import shaping_refusal
|
|
37
39
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
38
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
39
41
|
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
@@ -288,8 +290,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
288
290
|
f"({support['detail']}), so they would come out as empty boxes -- pass --emoji-assets DIR "
|
|
289
291
|
"for colour, or --emoji none", kind="input")
|
|
290
292
|
if args.text_render == "drawtext" and shaping:
|
|
291
|
-
die(f"{LANGUAGE_NAMES.get(_script, _script)} text
|
|
292
|
-
"build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
|
|
293
|
+
die(f"{LANGUAGE_NAMES.get(_script, _script)} text {shaping_refusal(_script)}: drop "
|
|
293
294
|
"--text-render drawtext to render it through libass, or draw it with caption.py",
|
|
294
295
|
kind="input")
|
|
295
296
|
route = "ass" if (args.text_render == "ass" or
|
|
@@ -301,14 +302,26 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
301
302
|
# mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
|
|
302
303
|
# fallback chain, so an auto run routes there instead; a run that pinned --text-render
|
|
303
304
|
# drawtext degrades to "none" (strip) and says so rather than drawing tofu.
|
|
304
|
-
|
|
305
|
+
# "color" as much as "mono" (#403): an auto run on a build whose libass draws colour reported
|
|
306
|
+
# mode color from the drawtext route, which drew the empty box.
|
|
307
|
+
emoji_count = len(emoji_clusters(all_text))
|
|
308
|
+
emoji_names = sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)})
|
|
309
|
+
not_drawn = None
|
|
310
|
+
mono_in_colour = emoji_mode == "mono" and args.emoji == "mono" and bool(support.get("libass_color"))
|
|
311
|
+
if mono_in_colour:
|
|
312
|
+
emoji_mode = "color"
|
|
313
|
+
if emoji_mode in ("mono", "color") and route == "drawtext":
|
|
305
314
|
if args.text_render == "auto":
|
|
306
315
|
route = "ass"
|
|
307
316
|
else:
|
|
308
317
|
emoji_mode = "none"
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
318
|
+
not_drawn = "--text-render drawtext has no font fallback chain, so each would be an empty box (--text-render auto draws them through libass)"
|
|
319
|
+
elif emoji_mode == "none" and args.emoji == "auto":
|
|
320
|
+
not_drawn = ("libass draws no emoji glyph on this build (an empty box at best)"
|
|
321
|
+
if support.get("libass_glyph") is False else "no face here draws them")
|
|
322
|
+
if mono_in_colour and emoji_mode == "color":
|
|
323
|
+
# said once the route is final: a pinned drawtext route strips them instead (mode none)
|
|
324
|
+
warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
|
|
312
325
|
if emoji_mode == "mono":
|
|
313
326
|
warn("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
314
327
|
"--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
|
|
@@ -329,7 +342,13 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
329
342
|
setattr(args, _attr, _strip_emoji(getattr(args, _attr, None)))
|
|
330
343
|
all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text,
|
|
331
344
|
args.top, args.bottom) if t)
|
|
332
|
-
|
|
345
|
+
if not_drawn:
|
|
346
|
+
# an auto or rerouted run did not get --emoji none: it says what it left out and why,
|
|
347
|
+
# as caption.py does (emoji_not_drawn), and counts what it left out
|
|
348
|
+
warn(f"emoji: {emoji_count} left out of the drawn text: {not_drawn} -- pass --emoji-assets DIR to draw them as PNGs",
|
|
349
|
+
"emoji_not_drawn", count=emoji_count, reason=support.get("detail"))
|
|
350
|
+
else:
|
|
351
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
333
352
|
elements: List[dict] = []
|
|
334
353
|
|
|
335
354
|
def ass_font_family() -> "Optional[str]":
|
|
@@ -712,10 +731,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
712
731
|
f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
|
|
713
732
|
"per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
|
|
714
733
|
if missing:
|
|
715
|
-
warn(
|
|
734
|
+
warn(emoji_missing_asset_note(list(missing), support), "emoji_assets_missing", missing=list(missing))
|
|
716
735
|
if emoji_mode:
|
|
717
|
-
emoji_result = {"mode": emoji_mode, "count":
|
|
718
|
-
"clusters": sorted(seen) or
|
|
736
|
+
emoji_result = {"mode": emoji_mode, "count": emoji_count,
|
|
737
|
+
"clusters": sorted(seen) or emoji_names,
|
|
719
738
|
"assets": emoji_assets, "missing": missing,
|
|
720
739
|
"overlays": len(emoji_overlays)}
|
|
721
740
|
ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
|
|
@@ -730,8 +749,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
730
749
|
text_overlay_ass(elements, play_w=W, play_h=H, path=ass_path, fonts_dir=ass_fonts_dir())
|
|
731
750
|
info(f"wrote {ass_path} ({len(elements)} text elements, rendered through libass)")
|
|
732
751
|
elif emoji_mode:
|
|
733
|
-
emoji_result = {"mode": emoji_mode, "count":
|
|
734
|
-
"clusters":
|
|
752
|
+
emoji_result = {"mode": emoji_mode, "count": emoji_count,
|
|
753
|
+
"clusters": emoji_names,
|
|
735
754
|
"assets": emoji_assets, "missing": [], "overlays": 0}
|
|
736
755
|
|
|
737
756
|
# The boxes with chroma (layers) go under the text, and the picture they and a converted colour
|
package/scripts/join.py
CHANGED
|
@@ -278,10 +278,17 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
278
278
|
return 0
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
# ffmpeg concat-demuxer directives other than `file`: they change what a `file` line plays
|
|
282
|
+
CONCAT_DIRECTIVES = frozenset({"inpoint", "outpoint", "duration", "stream", "exact_stream_id", "file_packet_metadata",
|
|
283
|
+
"file_packet_meta", "option", "stream_meta", "stream_codec", "stream_extradata", "chapter"})
|
|
284
|
+
|
|
285
|
+
|
|
281
286
|
def read_list(path: str) -> List[str]:
|
|
282
287
|
"""--list FILE: one segment per line, in order, relative to the list file's own folder.
|
|
283
288
|
Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
|
|
284
|
-
(`file 'part 01.wav'`) is accepted, so a list written for
|
|
289
|
+
(`file 'part 01.wav'`, the `ffconcat version` header) is accepted, so a list written for
|
|
290
|
+
`ffmpeg -f concat` works as is unless it trims: `inpoint`, `outpoint`, `duration` and the other
|
|
291
|
+
directives are refused by name, since nothing here applies them."""
|
|
285
292
|
# the shared reader: a BOM, CRLF and a file that is not UTF-8 are read or refused as every
|
|
286
293
|
# other text input is, and a line ends at "\n" only
|
|
287
294
|
text = read_text_or_die(path, "--list")
|
|
@@ -295,6 +302,17 @@ def read_list(path: str) -> List[str]:
|
|
|
295
302
|
line = line[5:].strip()
|
|
296
303
|
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"":
|
|
297
304
|
line = line[1:-1].replace("'\\''", "'")
|
|
305
|
+
elif line.split()[0] == "ffconcat" and line.split()[1:2] == ["version"]:
|
|
306
|
+
continue # the concat demuxer's header line names no file
|
|
307
|
+
elif line.split()[0] in CONCAT_DIRECTIVES and not os.path.exists(os.path.join(base, line)):
|
|
308
|
+
# only `file` lines are read: a directive was taken for a path, reported as a missing
|
|
309
|
+
# (or, under --dry-run, pending) input, and with --on-missing skip its trim was lost.
|
|
310
|
+
# `stream` takes no argument (FFmpeg 6.1's concat demuxer); a line naming a file that is
|
|
311
|
+
# there is a path, whatever its first word
|
|
312
|
+
die(f"--list {path}: `{line}` is an ffmpeg concat directive, which join.py does not apply: the list is "
|
|
313
|
+
"read for its `file` lines only", kind="input",
|
|
314
|
+
hint="trim a clip with cut.py first and list the cut file (inpoint/outpoint/duration), "
|
|
315
|
+
"or remove the line")
|
|
298
316
|
entries.append(line if os.path.isabs(line) else os.path.join(base, line))
|
|
299
317
|
if not entries:
|
|
300
318
|
# the failure this exists for: an upstream step produced nothing, and the join must
|
|
@@ -585,7 +603,7 @@ def main() -> int:
|
|
|
585
603
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
586
604
|
ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
|
|
587
605
|
ap.add_argument("--list", metavar="FILE", help="read the clips from FILE, one per line in order (paths relative to FILE; "
|
|
588
|
-
"ffmpeg concat lines `file 'x.wav'` also accepted); an empty list is refused")
|
|
606
|
+
"ffmpeg concat lines `file 'x.wav'` also accepted, other concat directives refused); an empty list is refused")
|
|
589
607
|
ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
|
|
590
608
|
help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
|
|
591
609
|
"(join the rest; each skipped input is reported under `skipped`)")
|
package/scripts/loudness.py
CHANGED
|
@@ -25,6 +25,7 @@ AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
|
25
25
|
LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
|
|
26
26
|
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
27
|
from _common.emit import refuse
|
|
28
|
+
from _common.runner import ffmpeg_failure_extra
|
|
28
29
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
29
30
|
|
|
30
31
|
|
|
@@ -77,7 +78,7 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
77
78
|
proc = run_analysis(cmd, check=False, record=True)
|
|
78
79
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
79
80
|
if proc.returncode != 0 or not m:
|
|
80
|
-
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
81
|
+
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
81
82
|
data = json.loads(m.group(0))
|
|
82
83
|
data["too_short"] = False
|
|
83
84
|
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
package/scripts/metadata.py
CHANGED
|
@@ -24,6 +24,7 @@ chapters.txt:
|
|
|
24
24
|
1:03:00 Outro
|
|
25
25
|
"""
|
|
26
26
|
import argparse
|
|
27
|
+
import re
|
|
27
28
|
import os
|
|
28
29
|
import sys
|
|
29
30
|
import tempfile
|
|
@@ -49,9 +50,18 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
49
50
|
if not line or line.startswith("#"):
|
|
50
51
|
continue
|
|
51
52
|
parts = line.split(None, 1)
|
|
53
|
+
vtt_header = not entries and line.upper().startswith("WEBVTT")
|
|
54
|
+
if vtt_header or (len(parts) > 1 and parts[1].startswith("-->")):
|
|
55
|
+
# a WebVTT header or an SRT/WebVTT timing line: read as chapters, its cue numbers and
|
|
56
|
+
# times became chapter starts and the run failed on their order, never naming the file (#404)
|
|
57
|
+
what = "a WebVTT header" if vtt_header else "a subtitle timing line"
|
|
58
|
+
die(f"{path}:{n}: `{line[:60]}` is {what}, so this is an SRT/WebVTT subtitle file, "
|
|
59
|
+
"not a chapter list (one chapter per line: `0:00 Intro`)", kind="input")
|
|
52
60
|
# chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
|
|
53
61
|
start = time_arg(parts[0], f"{path}:{n}")
|
|
54
|
-
|
|
62
|
+
# `0:00 - Intro` (the form YouTube descriptions use): the separator is not part of the title
|
|
63
|
+
title = re.sub(r"^[-\u2013\u2014|:]\s+", "", parts[1].strip()) if len(parts) > 1 else ""
|
|
64
|
+
title = title or f"Chapter {len(entries) + 1}"
|
|
55
65
|
if "\x00" in title:
|
|
56
66
|
# FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
|
|
57
67
|
# sibling of A260 in render.py's inline chapters)
|
package/scripts/overlay.py
CHANGED
|
@@ -41,7 +41,8 @@ from _common.color import is_rgb
|
|
|
41
41
|
from _common.decision import length_output_t
|
|
42
42
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
43
43
|
has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
44
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
44
|
+
from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
|
|
45
|
+
from _common.drawtext import shaping_refusal
|
|
45
46
|
from _common.wrap import wrap_text
|
|
46
47
|
from _common import text_width_em
|
|
47
48
|
from _common.fonts import font_choice, record_font_choice
|
|
@@ -323,8 +324,7 @@ def main() -> int:
|
|
|
323
324
|
# refuse and name the two tools that render the script correctly through libass.
|
|
324
325
|
_sc = detect_script(args.text, getattr(args, "lang", None))
|
|
325
326
|
if needs_shaping(_sc):
|
|
326
|
-
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text
|
|
327
|
-
"(the marks are reordered by harfbuzz, which drawtext does not use): draw it with "
|
|
327
|
+
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text {shaping_refusal(_sc)}: draw it with "
|
|
328
328
|
"caption.py (--text cues, burned through libass) or graphics.py (--template with "
|
|
329
329
|
"--text-render ass) instead", kind="input")
|
|
330
330
|
if has_emoji(args.text):
|
|
@@ -337,8 +337,12 @@ def main() -> int:
|
|
|
337
337
|
args.text = args.text[:_i] + args.text[_i + len(_cl):]
|
|
338
338
|
warn("emoji: stripped from the drawn text (--emoji none)", "emoji_stripped", mode="none")
|
|
339
339
|
else:
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
# not "drawn by the text font": drawtext loads that one font and has no fallback, so
|
|
341
|
+
# a font without the glyph (DejaVu Sans, every script font) draws an empty box (#403)
|
|
342
|
+
warn("emoji are left to the text font here: drawtext loads that one font and has no fallback, so "
|
|
343
|
+
"a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
|
|
344
|
+
"leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
|
|
345
|
+
"emoji_monochrome")
|
|
342
346
|
script_file = script_family = None
|
|
343
347
|
if args.text and not args.font_file:
|
|
344
348
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
@@ -440,7 +444,8 @@ def main() -> int:
|
|
|
440
444
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
441
445
|
|
|
442
446
|
if args.image:
|
|
443
|
-
probe(args.image)
|
|
447
|
+
refuse_not_a_still(args.image, probe(args.image), "--image",
|
|
448
|
+
"--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
|
|
444
449
|
chain: List[str] = ["format=rgba"]
|
|
445
450
|
if args.scale_percent:
|
|
446
451
|
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-1")
|
package/scripts/probe.py
CHANGED
|
@@ -73,6 +73,14 @@ def main() -> int:
|
|
|
73
73
|
for r in results:
|
|
74
74
|
if r.get("video"):
|
|
75
75
|
r["levels"] = analyze_levels(r["file"])
|
|
76
|
+
transfer = r["video"].get("color_transfer")
|
|
77
|
+
if r["video"].get("hdr") and "error" not in r["levels"]:
|
|
78
|
+
# the guess is "flat and desaturated, yet tagged SDR": a PQ/HLG stream is not
|
|
79
|
+
# tagged SDR, and its code values were read as if they were (#403)
|
|
80
|
+
r["levels"]["looks_like_log"] = None
|
|
81
|
+
r["levels"]["reason"] = "not_measured: the stream is tagged HDR (%s), and the Log guess is made for an SDR-tagged picture" % (transfer or r["video"].get("hdr_format") or "HDR")
|
|
82
|
+
r["levels"]["note"] = ("the stream is tagged HDR (%s): these are its code values on an 8-bit scale, not display-referred SDR levels, "
|
|
83
|
+
"and whether it looks like Log was not judged" % (transfer or r["video"].get("hdr_format") or "HDR"))
|
|
76
84
|
|
|
77
85
|
if args.field:
|
|
78
86
|
for r in results:
|
package/scripts/render.py
CHANGED
|
@@ -1090,6 +1090,18 @@ def _from_plan_cwd(output: Any) -> Any:
|
|
|
1090
1090
|
return os.path.abspath(str(output)) if isinstance(output, str) and output else output
|
|
1091
1091
|
|
|
1092
1092
|
|
|
1093
|
+
def check_not_run(check_result: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
1094
|
+
"""Why check.py graded nothing, or None when it did: a document with no FAIL count that is
|
|
1095
|
+
failed or carries an error is a check that could not run (an unparsed document, check.py's own
|
|
1096
|
+
refusal), not a check the delivery failed (#403: both were reported as "check failed")."""
|
|
1097
|
+
if not check_result or check_result.get("failed"):
|
|
1098
|
+
return None
|
|
1099
|
+
err = check_result.get("error")
|
|
1100
|
+
if not err and check_result.get("status") != "failed":
|
|
1101
|
+
return None
|
|
1102
|
+
return str((err.get("message") if isinstance(err, dict) else err) or "check.py reported status failed with no rows")
|
|
1103
|
+
|
|
1104
|
+
|
|
1093
1105
|
def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
1094
1106
|
"""Run a plan written by `<tool> --plan FILE`: refuse if any fingerprinted input changed since
|
|
1095
1107
|
the plan was made (the plan's commands would then describe a different edit), run the tool
|
|
@@ -1189,7 +1201,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1189
1201
|
# same command run by hand)
|
|
1190
1202
|
if exit_code:
|
|
1191
1203
|
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
1192
|
-
|
|
1204
|
+
unrun = check_not_run(check_result)
|
|
1205
|
+
info(f"plan done: {output}, but the {check_result.get('platform') or 'content'} check "
|
|
1206
|
+
+ (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")))
|
|
1193
1207
|
else:
|
|
1194
1208
|
advised = guidance_warns(check_result)
|
|
1195
1209
|
info(f"plan done: {output}" + (" — upload-limit guidance, the uploader checks the current limit: "
|
|
@@ -1197,7 +1211,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1197
1211
|
# the tool's own steps (export's loudness, ...) come in through absorb_stage() above, tagged
|
|
1198
1212
|
# with `stage`, so `verified` is theirs and the check's together
|
|
1199
1213
|
emit(output, plan=path, plan_sha256=digest, unbound_inputs=unbound, tool=tool, stages=[tool] + (["check"] if check_result else []), check=check_result, tool_result=doc,
|
|
1200
|
-
verification=([{"step": "check", "ok": not exit_code, "platform": check_result.get("platform")}
|
|
1214
|
+
verification=([dict({"step": "check", "ok": not exit_code, "platform": check_result.get("platform")},
|
|
1215
|
+
**({"reason": "not_measured: " + check_not_run(check_result)} if check_not_run(check_result) else {}))]
|
|
1216
|
+
if check_result else []))
|
|
1201
1217
|
return 0
|
|
1202
1218
|
|
|
1203
1219
|
|
|
@@ -2084,7 +2100,9 @@ def main() -> int:
|
|
|
2084
2100
|
# The deliverable is written and verified, but it does not meet the requested platform
|
|
2085
2101
|
# spec (or the check itself could not run): a failed delivery, reported as one.
|
|
2086
2102
|
failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
|
|
2087
|
-
|
|
2103
|
+
unrun = check_not_run(check_result)
|
|
2104
|
+
die(f"rendered {output} but the {check_label} check "
|
|
2105
|
+
+ (f"could not run, so nothing was checked: {unrun}" if unrun else "failed" + (f": {', '.join(failed_rows)}" if failed_rows else "")),
|
|
2088
2106
|
kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
|
|
2089
2107
|
probe=probe(output, role="output"))
|
|
2090
2108
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
package/scripts/silence.py
CHANGED
|
@@ -121,7 +121,7 @@ def sentence_words(args) -> "Tuple[list, str]":
|
|
|
121
121
|
raw = parse_json(read_text_or_die(args.words, "--words"))
|
|
122
122
|
except ValueError as exc:
|
|
123
123
|
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
124
|
-
words, _had_segments, _untimed =
|
|
124
|
+
words, _had_segments, _untimed = _words_from_words_file(args.words, raw)
|
|
125
125
|
if not words:
|
|
126
126
|
die(f"no word timings in {args.words}: the sentence flags need "
|
|
127
127
|
'{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside "segments").',
|
|
@@ -356,7 +356,15 @@ def resolve_filler(args, meta):
|
|
|
356
356
|
except ValueError as exc:
|
|
357
357
|
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
358
358
|
source = f"whisper-json:{args.words}"
|
|
359
|
-
words, had_segments, untimed =
|
|
359
|
+
words, had_segments, untimed = _words_from_words_file(args.words, raw)
|
|
360
|
+
length = float(meta.get("duration") or 0.0)
|
|
361
|
+
late = [w for w in words if w["start"] >= length] if length > 0 else []
|
|
362
|
+
if late:
|
|
363
|
+
# nothing said it: the cuts were planned on times the input does not have (#404)
|
|
364
|
+
warn(f"--words {args.words}: {len(late)} of {len(words)} word timings start at or after the end of the input "
|
|
365
|
+
f"({length:.3f}s; the last at {max(w['start'] for w in late):g}s) -- a transcript of another file, or "
|
|
366
|
+
"times in milliseconds (a whisper JSON's start/end are seconds); no word is cut there",
|
|
367
|
+
"words_outside_input", count=len(late), words=len(words), duration=round(length, 3))
|
|
360
368
|
if not words:
|
|
361
369
|
if had_segments:
|
|
362
370
|
die(f"the transcript in {args.words} has segment timings but no word timings; "
|
|
@@ -423,7 +431,11 @@ def resolve_filler(args, meta):
|
|
|
423
431
|
if primary in names:
|
|
424
432
|
return names[primary]
|
|
425
433
|
return primary[:2] if len(primary) > 3 else primary
|
|
426
|
-
|
|
434
|
+
# openai-whisper's top-level `language`, or whisper.cpp's `result.language` (its
|
|
435
|
+
# --output-json-full document is read for its words too: _words_from_words_file())
|
|
436
|
+
result = raw.get("result") if isinstance(raw, dict) else None
|
|
437
|
+
spoken = _code(raw.get("language") or (result.get("language") if isinstance(result, dict) else None)) \
|
|
438
|
+
if isinstance(raw, dict) else ""
|
|
427
439
|
spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
|
|
428
440
|
if spoken and spoken not in FILLER_WORDS and not args.filler_words:
|
|
429
441
|
die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
|
|
@@ -482,6 +494,18 @@ def resolve_filler(args, meta):
|
|
|
482
494
|
return block, [(s["start"], s["end"]) for s in spans]
|
|
483
495
|
|
|
484
496
|
|
|
497
|
+
def _words_from_words_file(path, data):
|
|
498
|
+
"""_words_from_transcript() for a --words file, plus whisper.cpp's own shape: its
|
|
499
|
+
--output-json-full document (transcription[].tokens[], offsets in milliseconds) is what the
|
|
500
|
+
refusals here tell a caller to make, and it was then refused for having no word timings (#404).
|
|
501
|
+
Read as --transcribe reads the same file."""
|
|
502
|
+
words, had_segments, untimed = _words_from_transcript(data)
|
|
503
|
+
if not words and isinstance(data, dict) and isinstance(data.get("transcription"), list):
|
|
504
|
+
words = _asr._words_from_whisper_cpp_json(path)
|
|
505
|
+
had_segments = had_segments or bool(data["transcription"])
|
|
506
|
+
return words, had_segments, untimed
|
|
507
|
+
|
|
508
|
+
|
|
485
509
|
def _words_from_transcript(data):
|
|
486
510
|
"""([{word,start,end}], whether the document had segments at all, [{word}] of the words
|
|
487
511
|
that carried no usable timing) from a whisper JSON. The third list lets --filler count a
|
package/scripts/stabilize.py
CHANGED
|
@@ -28,7 +28,7 @@ import tempfile
|
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
30
|
from _common.probe import PROBED_VIDEO
|
|
31
|
-
from _common.runner import default_selection_maps
|
|
31
|
+
from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
|
|
32
32
|
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
33
33
|
|
|
34
34
|
|
|
@@ -57,6 +57,10 @@ def main() -> int:
|
|
|
57
57
|
meta = probe(args.input)
|
|
58
58
|
if not meta.get("video"):
|
|
59
59
|
die("input has no video stream")
|
|
60
|
+
# pass 1 is a measurement that runs under --dry-run too: refused here, with the reason a run
|
|
61
|
+
# that met "No such filter" gets, instead of as an unclassified pass-1 failure (#402)
|
|
62
|
+
for name in ("vidstabdetect", "vidstabtransform"):
|
|
63
|
+
require_filter(name, "stabilizing (vid.stab)")
|
|
60
64
|
has_audio = bool(meta.get("audio"))
|
|
61
65
|
output = args.output or default_output(args.input, "stab")
|
|
62
66
|
|
|
@@ -89,7 +93,7 @@ def main() -> int:
|
|
|
89
93
|
if not dry_run_input_pending(args.input):
|
|
90
94
|
proc = run_analysis(detect_cmd, check=False, record=True)
|
|
91
95
|
if proc.returncode != 0:
|
|
92
|
-
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
96
|
+
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
93
97
|
|
|
94
98
|
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
95
99
|
transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|
package/scripts/sync.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from typing import Dict, List
|
|
34
34
|
|
|
35
35
|
from _common.runner import fail_if_classified
|
|
36
|
-
from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
36
|
+
from _common import STATE, video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
|
|
37
37
|
from _common.emit import record_skip, warn
|
|
38
38
|
from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, length_output_t, silence_filter, stream_starts
|
|
39
39
|
from _common.timeline import output_timecode_args
|
|
@@ -259,8 +259,13 @@ def measure_one(reference: str, path: str, args) -> Dict:
|
|
|
259
259
|
offset = offset + (drift_ratio - 1.0) * head_mid
|
|
260
260
|
drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
|
|
261
261
|
"drift_ppm": round((drift_ratio - 1) * 1e6, 1),
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
# a measure-only run writes nothing (#403: it said "will be resampled")
|
|
263
|
+
"meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); %s" % (
|
|
264
|
+
abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed,
|
|
265
|
+
("it would be resampled to match in the file this run plans (--dry-run writes nothing)" if STATE.dry_run
|
|
266
|
+
else "it is resampled to match in the file this run writes")
|
|
267
|
+
if args.replace_audio or args.trim_second else
|
|
268
|
+
"nothing is resampled in this measure-only run (--replace-audio or --trim-second writes the corrected file)"),
|
|
264
269
|
"confidence": round(end_score, 3)}
|
|
265
270
|
else:
|
|
266
271
|
skipped_reason = "low_confidence"
|
package/scripts/waveform.py
CHANGED
|
@@ -36,7 +36,7 @@ import os
|
|
|
36
36
|
import sys
|
|
37
37
|
|
|
38
38
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
39
|
-
from _common.probe import SILENT_PEAK_DBFS, square_pixels, square_pixels_note
|
|
39
|
+
from _common.probe import SILENT_PEAK_DBFS, refuse_not_a_still, square_pixels, square_pixels_note
|
|
40
40
|
from _common import still_input_args # noqa: E402
|
|
41
41
|
from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
|
|
42
42
|
info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
|
|
@@ -119,10 +119,13 @@ def main() -> int:
|
|
|
119
119
|
"the image locally and pass its path", kind="input")
|
|
120
120
|
if not os.path.exists(args.image):
|
|
121
121
|
die(f"--image file not found: {args.image}", kind="input")
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
# read under --dry-run too (the file exists, checked above): a plate the real run refuses
|
|
123
|
+
# is refused by the plan (#404: a dry run completed on a video given as --image)
|
|
124
|
+
plate = probe(args.image, role="input")
|
|
125
|
+
if not (plate.get("video") or {}).get("width"):
|
|
126
|
+
die(f"--image is not an image ffmpeg can decode: {args.image}", kind="input")
|
|
127
|
+
refuse_not_a_still(args.image, plate, "--image",
|
|
128
|
+
"`look.py FILE --at T` writes one frame of a video as a PNG")
|
|
126
129
|
if args.background != "black":
|
|
127
130
|
die("--image and a non-default --background exclude each other: the plate is either "
|
|
128
131
|
"the picture or the colour", kind="input")
|
|
@@ -375,8 +378,12 @@ def _child(script_name: str, argv: "list") -> None:
|
|
|
375
378
|
extra = {"hint": err["hint"]} if err.get("hint") else {}
|
|
376
379
|
if doc.get("encoder"):
|
|
377
380
|
extra["encoder"] = doc["encoder"]
|
|
381
|
+
# the stage's own reason and details ride along (#402: a caller keyed on error.reason saw
|
|
382
|
+
# filter_missing from caption.py and graphics.py run by hand, and nothing from here)
|
|
383
|
+
why = {k: err[k] for k in ("reason", "details") if err.get(k) is not None}
|
|
378
384
|
die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
|
|
379
|
-
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name,
|
|
385
|
+
code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name,
|
|
386
|
+
error_extra=why or None, **extra)
|
|
380
387
|
|
|
381
388
|
if __name__ == "__main__":
|
|
382
389
|
sys.exit(main())
|