ffmpeg-skill 3.8.7 → 3.8.9
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 +15 -4
- package/package.json +1 -1
- package/references/gotchas.md +5 -3
- package/references/scripts.md +69 -17
- 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 +28 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +146 -5
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +19 -11
- package/scripts/batch.py +91 -8
- package/scripts/caption.py +39 -3
- 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 +15 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +232 -39
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- 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/templates/audiogram.json +9 -0
package/scripts/caption.py
CHANGED
|
@@ -46,6 +46,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
|
|
|
46
46
|
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
|
|
47
47
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
48
48
|
from _common.emit import refuse # noqa: E402
|
|
49
|
+
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
49
50
|
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
50
51
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
51
52
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
@@ -103,6 +104,32 @@ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
|
|
|
103
104
|
# an SRT timing line: the times alone, the cue text on the lines after it
|
|
104
105
|
DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
|
|
105
106
|
SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
|
|
107
|
+
# the same line as WebVTT may write it, without the hours: `00:01.000 --> 00:02.000`
|
|
108
|
+
TIMING_ONLY_RE = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*-->\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*$")
|
|
109
|
+
ASS_SECTIONS = ("[script info]", "[v4+ styles]", "[v4 styles]", "[events]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def refuse_not_a_cue_file(path: str, lines: List[str]) -> None:
|
|
113
|
+
"""--text given a file in another format (#404): read as a cue file, a WebVTT header, an ASS
|
|
114
|
+
section or a JSON document was burned in line by line, auto-timed 3 s apart. Named by what the
|
|
115
|
+
file starts with; the SRT timing-line check below catches a file with no such header."""
|
|
116
|
+
head = next((ln.strip() for ln in lines if ln.strip()), "")
|
|
117
|
+
cue_shape = "A cue file has one cue per line: `START-END text` (or text alone, timed --auto-seconds apart)"
|
|
118
|
+
if head.upper().startswith("WEBVTT"):
|
|
119
|
+
die(f"--text: {path} is a WebVTT subtitle file (it starts with {head[:20]!r}); pass it as --srt, which reads "
|
|
120
|
+
f"WebVTT (render.py: --srt, not --cues). {cue_shape}", kind="input")
|
|
121
|
+
if head.lower() in ASS_SECTIONS or head.startswith("Dialogue:"):
|
|
122
|
+
die(f"--text: {path} is an ASS/SSA subtitle file (it starts with {head[:20]!r}); pass it as --ass, which burns "
|
|
123
|
+
f"it with the styles inside it (in a render.py project, the captions stage's \"ass\" key). {cue_shape}",
|
|
124
|
+
kind="input")
|
|
125
|
+
if head[:1] in "{[":
|
|
126
|
+
try:
|
|
127
|
+
doc = json.loads("\n".join(lines))
|
|
128
|
+
except ValueError:
|
|
129
|
+
doc = None
|
|
130
|
+
if isinstance(doc, dict) or (isinstance(doc, list) and doc and isinstance(doc[0], (dict, list))):
|
|
131
|
+
die(f"--text: {path} is a JSON document, not a cue file. {cue_shape}. A whisper JSON is a transcript: "
|
|
132
|
+
"caption.py --transcribe writes the cues from the audio", kind="input")
|
|
106
133
|
|
|
107
134
|
|
|
108
135
|
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
@@ -111,7 +138,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
111
138
|
# split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
|
|
112
139
|
# made the rest of the line an auto-timed cue of its own
|
|
113
140
|
lines = read_text_or_die(path, "--text").split("\n")
|
|
141
|
+
refuse_not_a_cue_file(path, lines)
|
|
114
142
|
for n, raw in enumerate(lines, 1):
|
|
143
|
+
if TIMING_ONLY_RE.match(raw) and not SRT_TIMING_RE.match(raw):
|
|
144
|
+
# WebVTT's short form (no hours), in a file without the WEBVTT header
|
|
145
|
+
die(f"--text: {path} is a subtitle file, not a cue file (line {n} is a timing line with no text: "
|
|
146
|
+
f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
|
|
115
147
|
if SRT_TIMING_RE.match(raw):
|
|
116
148
|
# a cue file puts the text on the line with its times; this line has the times alone, so
|
|
117
149
|
# the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
|
|
@@ -257,6 +289,9 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
257
289
|
die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
|
|
258
290
|
f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
|
|
259
291
|
"or --emoji none to leave them out of the drawn text", kind="input")
|
|
292
|
+
if want == "mono" and support.get("libass_color"):
|
|
293
|
+
mode = "color"
|
|
294
|
+
warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
|
|
260
295
|
plan = {"mode": mode, "count": len(clusters_all),
|
|
261
296
|
"clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
|
|
262
297
|
"assets": assets, "missing": [], "overlays": []}
|
|
@@ -354,8 +389,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
354
389
|
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
355
390
|
"linearly -- split the job, or use --emoji none", kind="input")
|
|
356
391
|
if plan["missing"]:
|
|
357
|
-
warn("
|
|
358
|
-
" -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
|
|
392
|
+
warn(emoji_missing_asset_note(plan["missing"], support), "emoji_assets_missing", missing=list(plan["missing"]))
|
|
359
393
|
plan["box_px"] = int(round(box_px))
|
|
360
394
|
return out_cues, plan
|
|
361
395
|
|
|
@@ -1934,7 +1968,9 @@ def main() -> int:
|
|
|
1934
1968
|
notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
1935
1969
|
"--emoji-assets DIR for colour)")
|
|
1936
1970
|
if emoji_plan["mode"] == "none":
|
|
1937
|
-
|
|
1971
|
+
# --emoji none was asked for only when it was: an auto run that can draw none says that
|
|
1972
|
+
notes.append("emoji stripped from the drawn text (--emoji none)" if getattr(args, "emoji", "auto") == "none" else
|
|
1973
|
+
"emoji left out of the drawn text: nothing here draws them (--emoji auto; the emoji_not_drawn warning says why)")
|
|
1938
1974
|
if emoji_plan["missing"]:
|
|
1939
1975
|
notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
|
|
1940
1976
|
if notes:
|
package/scripts/color.py
CHANGED
|
@@ -420,6 +420,41 @@ def legalize_report(standard: str, output: str) -> "tuple":
|
|
|
420
420
|
"worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
|
|
421
421
|
|
|
422
422
|
|
|
423
|
+
def refuse_bad_cube(path: str) -> None:
|
|
424
|
+
"""--lut given a .cube that holds no complete 3D LUT (#404): lut3d failed on it with "Invalid
|
|
425
|
+
data found when processing input", which reads as an unreadable video and got that hint. Only
|
|
426
|
+
.cube is checked (lut3d's other formats are left to it), by what lut3d itself needs: a
|
|
427
|
+
LUT_3D_SIZE line and size^3 rows that start with three numbers."""
|
|
428
|
+
if os.path.splitext(path)[1].lower() != ".cube":
|
|
429
|
+
return
|
|
430
|
+
size = rows = 0
|
|
431
|
+
one_d = False
|
|
432
|
+
try:
|
|
433
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
434
|
+
for line in fh:
|
|
435
|
+
words = line.split()
|
|
436
|
+
if not words or words[0].startswith("#"):
|
|
437
|
+
continue
|
|
438
|
+
if words[0] == "LUT_3D_SIZE" and len(words) > 1 and words[1].isdigit():
|
|
439
|
+
size = int(words[1])
|
|
440
|
+
elif words[0] == "LUT_1D_SIZE":
|
|
441
|
+
one_d = True
|
|
442
|
+
elif len(words) >= 3:
|
|
443
|
+
try:
|
|
444
|
+
[float(w) for w in words[:3]]
|
|
445
|
+
rows += 1
|
|
446
|
+
except ValueError:
|
|
447
|
+
pass
|
|
448
|
+
except OSError as e:
|
|
449
|
+
die(f"--lut: cannot read {path}: {e.strerror}", kind="input")
|
|
450
|
+
if size < 2 or rows < size ** 3:
|
|
451
|
+
what = ("it is a 1D LUT (LUT_1D_SIZE), and --lut applies a 3D one" if one_d and size < 2
|
|
452
|
+
else "it has no LUT_3D_SIZE line" if size < 2
|
|
453
|
+
else f"it has {rows} rows of three numbers, and a LUT_3D_SIZE {size} table has {size ** 3}")
|
|
454
|
+
die(f"--lut {path} is not a complete 3D .cube LUT: {what}", kind="input",
|
|
455
|
+
hint="a .cube file is plain text with a LUT_3D_SIZE line and size^3 rows of three numbers; export it again from the grading tool")
|
|
456
|
+
|
|
457
|
+
|
|
423
458
|
def main() -> int:
|
|
424
459
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
425
460
|
ap.add_argument("input")
|
|
@@ -565,6 +600,7 @@ def main() -> int:
|
|
|
565
600
|
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
566
601
|
if not os.path.exists(args.lut):
|
|
567
602
|
die(f"LUT not found: {args.lut}")
|
|
603
|
+
refuse_bad_cube(args.lut)
|
|
568
604
|
if not (0.0 <= args.lut_strength <= 1.0):
|
|
569
605
|
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
570
606
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
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,22 @@ 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}"
|
|
65
|
+
if "\x00" in title:
|
|
66
|
+
# FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
|
|
67
|
+
# sibling of A260 in render.py's inline chapters)
|
|
68
|
+
die(f"{path}:{n}: the chapter title holds a NUL character, which a chapter title cannot carry; remove it")
|
|
55
69
|
if entries and start <= entries[-1]["start"]:
|
|
56
70
|
die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
|
|
57
71
|
if duration and start >= duration:
|
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:
|