ffmpeg-skill 2.10.11 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/contract.md +107 -11
- package/mcp/server.py +7 -1
- package/package.json +1 -1
- package/references/scripts.md +92 -20
- package/scripts/_common/__init__.py +6 -4
- package/scripts/_common/asr.py +2 -2
- package/scripts/_common/decision.py +57 -11
- package/scripts/_common/emit.py +198 -2
- package/scripts/_common/fonts.py +93 -28
- package/scripts/_common/runner.py +246 -68
- package/scripts/_common/timeline.py +22 -6
- package/scripts/_contract.py +168 -10
- package/scripts/audio.py +2 -1
- package/scripts/batch.py +240 -105
- package/scripts/caption.py +68 -18
- package/scripts/color.py +2 -1
- package/scripts/cropdetect.py +19 -4
- package/scripts/export.py +42 -10
- package/scripts/graphics.py +24 -18
- package/scripts/grid.py +7 -3
- package/scripts/join.py +5 -3
- package/scripts/loop.py +4 -2
- package/scripts/multicam.py +11 -3
- package/scripts/overlay.py +14 -10
- package/scripts/render.py +223 -74
- package/scripts/report.py +77 -23
- package/scripts/reverse.py +4 -2
- package/scripts/sequence.py +4 -2
- package/scripts/silence.py +19 -7
- package/scripts/sync.py +18 -7
- package/scripts/verify.py +98 -24
- package/scripts/waveform.py +3 -2
package/scripts/caption.py
CHANGED
|
@@ -51,6 +51,8 @@ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji
|
|
|
51
51
|
# caption.parse_srt / caption.transcribe / caption.whisper_word_timings before 1.17 still does.
|
|
52
52
|
from _common import asr as _asr
|
|
53
53
|
from _common.asr import ENGINE_CHOICES
|
|
54
|
+
from _common.emit import warn
|
|
55
|
+
from _common.fonts import font_choice, record_font_choice
|
|
54
56
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
55
57
|
write_srt)
|
|
56
58
|
from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
|
|
@@ -219,8 +221,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
219
221
|
return out, plan
|
|
220
222
|
if mode in ("color", "mono"):
|
|
221
223
|
if mode == "mono":
|
|
222
|
-
|
|
223
|
-
"--emoji-assets DIR for colour). " + support["detail"])
|
|
224
|
+
warn("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
225
|
+
"--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
|
|
224
226
|
return cues, plan
|
|
225
227
|
# --- the PNG overlay route -------------------------------------------------------------
|
|
226
228
|
if not play_w or not play_h:
|
|
@@ -295,8 +297,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
295
297
|
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
296
298
|
"linearly -- split the job, or use --emoji none", kind="input")
|
|
297
299
|
if plan["missing"]:
|
|
298
|
-
|
|
299
|
-
" -- those clusters are drawn by the text font instead")
|
|
300
|
+
warn("no PNG in the assets directory for " + ", ".join(plan["missing"]) +
|
|
301
|
+
" -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
|
|
300
302
|
plan["box_px"] = int(round(box_px))
|
|
301
303
|
return out_cues, plan
|
|
302
304
|
|
|
@@ -493,13 +495,16 @@ def parse_ass_dialogue(path: str) -> str:
|
|
|
493
495
|
return "\n".join(text)
|
|
494
496
|
|
|
495
497
|
|
|
496
|
-
def parse_ass_cues(path: str) -> List[Tuple[float, float, str]]:
|
|
497
|
-
"""(start, end, drawn text) of every Dialogue line of an ASS file, override blocks stripped.
|
|
498
|
+
def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float, float, str]]:
|
|
499
|
+
"""(start, end, drawn text) of every Dialogue line of an ASS file, override blocks stripped.
|
|
500
|
+
A Dialogue line with too few fields or an unreadable start/end is left out; `stats`, when
|
|
501
|
+
given, gets their number as `malformed` so the caller can say how many it could not count."""
|
|
498
502
|
def secs(stamp: str) -> float:
|
|
499
503
|
h, m, rest = stamp.strip().split(":")
|
|
500
504
|
return int(h) * 3600 + int(m) * 60 + float(rest)
|
|
501
505
|
|
|
502
506
|
cues: List[Tuple[float, float, str]] = []
|
|
507
|
+
malformed = 0
|
|
503
508
|
# the [Events] Format line names the columns; Text is always last (libass reads it the same
|
|
504
509
|
# way), and a file without one gets the standard ten
|
|
505
510
|
names = ["layer", "start", "end", "style", "name", "marginl", "marginr", "marginv", "effect", "text"]
|
|
@@ -511,13 +516,17 @@ def parse_ass_cues(path: str) -> List[Tuple[float, float, str]]:
|
|
|
511
516
|
continue
|
|
512
517
|
fields = line.partition(":")[2].split(",", len(names) - 1)
|
|
513
518
|
if len(fields) < len(names) or "start" not in names or "end" not in names:
|
|
519
|
+
malformed += 1
|
|
514
520
|
continue
|
|
515
521
|
try:
|
|
516
522
|
start, end = secs(fields[names.index("start")]), secs(fields[names.index("end")])
|
|
517
523
|
except (ValueError, IndexError):
|
|
524
|
+
malformed += 1
|
|
518
525
|
continue
|
|
519
526
|
text = re.sub(r"\{[^}]*\}", "", fields[-1]).replace("\\N", "\n").replace("\\n", "\n").replace("\\h", " ")
|
|
520
527
|
cues.append((start, end, text))
|
|
528
|
+
if stats is not None:
|
|
529
|
+
stats["malformed"] = malformed
|
|
521
530
|
return cues
|
|
522
531
|
|
|
523
532
|
|
|
@@ -540,8 +549,17 @@ def count_visible_cues(cues, duration) -> Tuple[int, int]:
|
|
|
540
549
|
return burned, outside
|
|
541
550
|
|
|
542
551
|
|
|
543
|
-
def shift_ass_file(src: str, dst: str, offset: float) -> int:
|
|
544
|
-
"""Copy an ASS file with every Dialogue start/end moved by `offset` seconds
|
|
552
|
+
def shift_ass_file(src: str, dst: str, offset: float) -> Tuple[int, int]:
|
|
553
|
+
"""Copy an ASS file with every Dialogue start/end moved by `offset` seconds; (lines shifted,
|
|
554
|
+
Dialogue lines copied unshifted because their timestamps could not be read)."""
|
|
555
|
+
text, n, unshifted = shift_ass_text(Path(src).read_text(encoding="utf-8-sig"), offset)
|
|
556
|
+
Path(dst).write_text(text, encoding="utf-8-sig")
|
|
557
|
+
return n, unshifted
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
|
|
561
|
+
"""shift_ass_file() on text: (shifted text, lines shifted, Dialogue lines left as they were).
|
|
562
|
+
Pure, so a dry run can report the counts without writing the copy."""
|
|
545
563
|
def shift(stamp: str) -> str:
|
|
546
564
|
h, m, rest = stamp.split(":")
|
|
547
565
|
secs = int(h) * 3600 + int(m) * 60 + float(rest) + offset
|
|
@@ -552,22 +570,25 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
|
|
|
552
570
|
ss, cc = divmod(rem, 100)
|
|
553
571
|
return f"{hh}:{mm:02d}:{ss:02d}.{cc:02d}"
|
|
554
572
|
|
|
555
|
-
n = 0
|
|
573
|
+
n = unshifted = 0
|
|
556
574
|
out = []
|
|
557
|
-
for line in
|
|
575
|
+
for line in source.splitlines():
|
|
558
576
|
if line.startswith("Dialogue:"):
|
|
559
577
|
head, sep, rest = line.partition(":")
|
|
560
578
|
fields = rest.split(",")
|
|
579
|
+
done = False
|
|
561
580
|
if len(fields) >= 3:
|
|
562
581
|
try:
|
|
563
582
|
fields[1], fields[2] = shift(fields[1].strip()), shift(fields[2].strip())
|
|
564
583
|
line = head + sep + ",".join(fields)
|
|
565
584
|
n += 1
|
|
585
|
+
done = True
|
|
566
586
|
except (ValueError, IndexError):
|
|
567
587
|
pass
|
|
588
|
+
if not done:
|
|
589
|
+
unshifted += 1 # copied as it was: counted and reported, not dropped unseen
|
|
568
590
|
out.append(line)
|
|
569
|
-
|
|
570
|
-
return n
|
|
591
|
+
return "\n".join(out) + "\n", n, unshifted
|
|
571
592
|
|
|
572
593
|
|
|
573
594
|
def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
|
|
@@ -1296,6 +1317,7 @@ def main() -> int:
|
|
|
1296
1317
|
planned_cues: Optional[List[Tuple[float, float, str]]] = None
|
|
1297
1318
|
# an English-only transcript made on an assumption says so first (asr.assumed_english_note)
|
|
1298
1319
|
side_notes: List[str] = list(getattr(args, "_asr_notes", None) or [])
|
|
1320
|
+
ass_counts: dict = {} # --ass: ass_shifted / ass_unshifted (with --offset) and ass_malformed, into `caption`
|
|
1299
1321
|
ass_sample_path = args.ass
|
|
1300
1322
|
if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
|
|
1301
1323
|
adjusted, changed = lay_out(parse_srt(srt_path))
|
|
@@ -1315,10 +1337,18 @@ def main() -> int:
|
|
|
1315
1337
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1316
1338
|
shifted = os.path.splitext(output)[0] + "_offset.ass"
|
|
1317
1339
|
if STATE.dry_run:
|
|
1318
|
-
|
|
1340
|
+
_text, n, unshifted = shift_ass_text(read_text_or_die(args.ass, "--ass").lstrip("\ufeff"), args.offset)
|
|
1341
|
+
info(f"[dry-run] would write {shifted} ({n} cues shifted by {args.offset:+g} s)")
|
|
1319
1342
|
else:
|
|
1320
|
-
n = shift_ass_file(args.ass, shifted, args.offset)
|
|
1343
|
+
n, unshifted = shift_ass_file(args.ass, shifted, args.offset)
|
|
1321
1344
|
info(f"wrote {shifted} ({n} cues shifted by {args.offset:+g} s)")
|
|
1345
|
+
# the counts reach the document (caption.ass_shifted / ass_unshifted), and a line
|
|
1346
|
+
# whose timestamps could not be read -- copied unshifted -- is a warning, not a silence
|
|
1347
|
+
ass_counts.update(ass_shifted=n, ass_unshifted=unshifted)
|
|
1348
|
+
if unshifted:
|
|
1349
|
+
warn(f"{unshifted} Dialogue line(s) in {args.ass} have timestamps that could not be read, so they were not "
|
|
1350
|
+
f"shifted by {args.offset:+g} s (copied as they were)", "ass_lines_unshifted",
|
|
1351
|
+
path=args.ass, count=unshifted, offset=args.offset)
|
|
1322
1352
|
args.ass = shifted
|
|
1323
1353
|
side_notes.append(f"the burned subtitles are {shifted}, the offset copy of "
|
|
1324
1354
|
f"{os.path.basename(ass_sample_path)} this run writes"
|
|
@@ -1449,6 +1479,19 @@ def main() -> int:
|
|
|
1449
1479
|
_script, font_file, font_family = script_font_for_text(
|
|
1450
1480
|
sample, lang=args.language, font=args.font, font_explicit=font_explicit,
|
|
1451
1481
|
fonts_dir=args.fonts_dir)
|
|
1482
|
+
# the font libass draws with, reported as `font_used`: a --fonts-dir (or a brand's font
|
|
1483
|
+
# file) is searched first for the family, by libass itself; otherwise fontconfig resolves it
|
|
1484
|
+
if args.ass:
|
|
1485
|
+
# the caller's own ASS names its fonts in its styles; this run does not choose them
|
|
1486
|
+
font_used = {"asked": None, "file": None, "family": None, "how": "ass_styles",
|
|
1487
|
+
"fonts_dir": args.fonts_dir or (os.path.dirname(font_file) if font_file else None), "substituted": False}
|
|
1488
|
+
elif font_file or not args.fonts_dir:
|
|
1489
|
+
# the family libass is given is the sanitised one (ass_font_name): that is what it resolves
|
|
1490
|
+
font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
|
|
1491
|
+
renderer="libass"))
|
|
1492
|
+
else:
|
|
1493
|
+
font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
|
|
1494
|
+
"fonts_dir": args.fonts_dir, "substituted": False}
|
|
1452
1495
|
if font_file:
|
|
1453
1496
|
args.font = font_family or args.font
|
|
1454
1497
|
if not args.fonts_dir:
|
|
@@ -1508,10 +1551,16 @@ def main() -> int:
|
|
|
1508
1551
|
# a dry run's pending --ass (an earlier step writes it): nothing to count yet
|
|
1509
1552
|
burn_cues = None
|
|
1510
1553
|
elif args.ass:
|
|
1554
|
+
parsed: dict = {}
|
|
1511
1555
|
if planned_ass: # dry run: the offset copy is not written yet, shift the caller's file
|
|
1512
|
-
burn_cues = [(s + args.offset, e + args.offset, t) for s, e, t in parse_ass_cues(ass_sample_path)]
|
|
1556
|
+
burn_cues = [(s + args.offset, e + args.offset, t) for s, e, t in parse_ass_cues(ass_sample_path, parsed)]
|
|
1513
1557
|
else:
|
|
1514
|
-
burn_cues = parse_ass_cues(args.ass)
|
|
1558
|
+
burn_cues = parse_ass_cues(args.ass, parsed)
|
|
1559
|
+
ass_counts["ass_malformed"] = parsed.get("malformed", 0)
|
|
1560
|
+
if ass_counts["ass_malformed"]:
|
|
1561
|
+
warn(f"{ass_counts['ass_malformed']} Dialogue line(s) in {ass_sample_path} could not be read (too few fields "
|
|
1562
|
+
"or an unreadable start/end) and are not counted as cues", "ass_lines_malformed",
|
|
1563
|
+
path=ass_sample_path, count=ass_counts["ass_malformed"])
|
|
1515
1564
|
if not burn_cues:
|
|
1516
1565
|
die(f"no Dialogue lines found in {ass_sample_path} -- nothing would be drawn", kind="input")
|
|
1517
1566
|
elif planned_cues is not None:
|
|
@@ -1533,7 +1582,7 @@ def main() -> int:
|
|
|
1533
1582
|
if outside:
|
|
1534
1583
|
msg = (f"{outside} cue(s) fall outside the video (0\u2013{float(meta['duration']):.1f} s) "
|
|
1535
1584
|
"and are not drawn")
|
|
1536
|
-
|
|
1585
|
+
warn(msg, "cues_outside_video", count=outside, duration=float(meta["duration"]))
|
|
1537
1586
|
side_notes.append(msg)
|
|
1538
1587
|
caption_stats["cues_burned"] = burned
|
|
1539
1588
|
caption_stats["cues_outside"] = outside
|
|
@@ -1606,7 +1655,8 @@ def main() -> int:
|
|
|
1606
1655
|
if caption_stats["text_unchanged"]:
|
|
1607
1656
|
info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
|
|
1608
1657
|
"and type size only)")
|
|
1609
|
-
extra["caption"] = dict(caption_stats)
|
|
1658
|
+
extra["caption"] = dict(caption_stats, **ass_counts)
|
|
1659
|
+
extra["font_used"] = font_used
|
|
1610
1660
|
if getattr(args, "_asr", None):
|
|
1611
1661
|
extra["transcription"] = args._asr
|
|
1612
1662
|
if emoji_plan:
|
package/scripts/color.py
CHANGED
|
@@ -24,6 +24,7 @@ import sys
|
|
|
24
24
|
from typing import List, Optional
|
|
25
25
|
|
|
26
26
|
from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, ffmpeg_exe, info, probe, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
27
|
+
from _common.emit import warn
|
|
27
28
|
|
|
28
29
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
29
30
|
|
|
@@ -388,7 +389,7 @@ def main() -> int:
|
|
|
388
389
|
if v.get("codec") != "hevc":
|
|
389
390
|
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
390
391
|
if not v.get("dolby_vision"):
|
|
391
|
-
|
|
392
|
+
warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
|
|
392
393
|
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
|
|
393
394
|
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
394
395
|
cmd += ["-movflags", "+faststart"]
|
package/scripts/cropdetect.py
CHANGED
|
@@ -31,9 +31,10 @@ import argparse
|
|
|
31
31
|
import re
|
|
32
32
|
import sys
|
|
33
33
|
from collections import Counter
|
|
34
|
-
from typing import Dict, List, Tuple
|
|
34
|
+
from typing import Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
|
|
37
|
+
from _common.emit import warn
|
|
37
38
|
|
|
38
39
|
MOTION_CENTRE_FPS = 2.0
|
|
39
40
|
MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
@@ -41,7 +42,11 @@ MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
|
41
42
|
CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float
|
|
45
|
+
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
|
|
46
|
+
failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
|
|
47
|
+
"""Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
|
|
48
|
+
is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
|
|
49
|
+
so the result can say how many of the readings it stands on."""
|
|
45
50
|
ffmpeg = require_tool("ffmpeg")
|
|
46
51
|
per_window = max(0.5, seconds / max(1, samples))
|
|
47
52
|
rects: List[Tuple[int, int, int, int]] = []
|
|
@@ -56,6 +61,8 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
56
61
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
57
62
|
# still measure. Only when every window fails is there nothing to report.
|
|
58
63
|
failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
|
|
64
|
+
if failed is not None:
|
|
65
|
+
failed.append({"start": round(start, 3), "seconds": round(per_window, 3), "error": failures[-1][0][:300]})
|
|
59
66
|
continue
|
|
60
67
|
for m in CROP_RE.finditer(proc.stderr):
|
|
61
68
|
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
@@ -145,8 +152,16 @@ def main() -> int:
|
|
|
145
152
|
sw, sh = sh, sw
|
|
146
153
|
duration = meta.get("duration") or 0.0
|
|
147
154
|
|
|
148
|
-
|
|
149
|
-
|
|
155
|
+
failed: List[Dict] = []
|
|
156
|
+
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed)
|
|
157
|
+
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
158
|
+
# crop measured on one window of five read as well-founded as one measured on all five
|
|
159
|
+
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
|
|
160
|
+
"windows": args.samples, "windows_failed": len(failed)}
|
|
161
|
+
if failed:
|
|
162
|
+
warn(f"cropdetect could not decode {len(failed)} of {args.samples} sampled windows; the crop rests on the other "
|
|
163
|
+
f"{args.samples - len(failed)}: {failed[-1]['error']}", "cropdetect_windows_failed",
|
|
164
|
+
failed=len(failed), windows=args.samples, starts=[f["start"] for f in failed])
|
|
150
165
|
if not rects:
|
|
151
166
|
result["crop"] = None
|
|
152
167
|
info("no crop bars detected (cropdetect produced no readings -- try --limit higher, or the source may already be full-frame)")
|
package/scripts/export.py
CHANGED
|
@@ -38,6 +38,7 @@ from typing import Dict, List
|
|
|
38
38
|
|
|
39
39
|
from _common import STATE, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
40
40
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
41
|
+
from _common.emit import note, record_skip, warn
|
|
41
42
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
42
43
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
|
|
43
44
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -108,6 +109,22 @@ PLATFORM_OF["youtube4k"] = PLATFORM_TABLE["youtube"]["check"]
|
|
|
108
109
|
PRESET_CHOICES: List[str] = sorted(set(PRESETS) | {a for a in _ALIASES if resolve_platform(a) in PRESETS})
|
|
109
110
|
|
|
110
111
|
|
|
112
|
+
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False) -> None:
|
|
113
|
+
"""The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
|
|
114
|
+
platform's loudness was not verified, and the document says so -- a `loudness` block with null
|
|
115
|
+
values and a reason, a failing `loudness` verification step, a warning -- rather than leaving
|
|
116
|
+
the block out, which read as "nothing to check". check.py reports the same case as a WARN row."""
|
|
117
|
+
reason = "not_measured"
|
|
118
|
+
extra["loudness"] = {"lufs": None, "tp": None, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
|
|
119
|
+
"measured": False, "reason": reason, **({"normalized": True} if normalized else {})}
|
|
120
|
+
extra["verification"] = [{"step": "loudness", "ok": False, "platform": platform, "reason": reason}]
|
|
121
|
+
msg = (f"the written file's loudness could not be measured{' after --normalize' if normalized else ''} "
|
|
122
|
+
f"(ffmpeg's loudnorm output did not parse), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
|
|
123
|
+
"verified; run check.py on the file")
|
|
124
|
+
notes.append(msg)
|
|
125
|
+
warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
|
|
126
|
+
|
|
127
|
+
|
|
111
128
|
def main() -> int:
|
|
112
129
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
113
130
|
ap.add_argument("input", nargs="?")
|
|
@@ -154,7 +171,7 @@ def main() -> int:
|
|
|
154
171
|
# not HDR but still needs converting before BT.709 tags describe it (eval 24 h1)
|
|
155
172
|
kind = "HDR" if meta["video"].get("hdr") else "wide-gamut SDR, not HDR"
|
|
156
173
|
notes.append("source is %s (%s). This preset outputs SDR BT.709 tags without converting it; run color.py --to-sdr first for correct colours." % (kind, meta["video"].get("hdr_format")))
|
|
157
|
-
|
|
174
|
+
warn(notes[-1], "colour_not_converted", preset=args.preset, hdr_format=meta["video"].get("hdr_format"))
|
|
158
175
|
has_audio = bool(meta.get("audio"))
|
|
159
176
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
160
177
|
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
@@ -227,15 +244,20 @@ def main() -> int:
|
|
|
227
244
|
if out_ext == "mp4":
|
|
228
245
|
cmd += ["-movflags", "+faststart"]
|
|
229
246
|
cmd += (p["audio"] if has_audio else ["-an"])
|
|
247
|
+
trimmed = None
|
|
230
248
|
if p["max"] and not args.allow_long and (meta.get("duration") or 0) > p["max"]:
|
|
231
|
-
|
|
249
|
+
# the preset's promise (the platform's maximum), but the caller loses the tail: say how much
|
|
250
|
+
# in the document (`trimmed`, `notes`), not only on stderr
|
|
251
|
+
trimmed = {"from": round(float(meta["duration"]), 3), "to": round(float(p["max"]), 3)}
|
|
252
|
+
note(f"trimmed from {trimmed['from']:.3f}s to the {args.preset} maximum of {p['max']:.0f}s "
|
|
253
|
+
"(use --allow-long to keep full length)")
|
|
232
254
|
cmd += ["-t", f"{p['max']:.3f}"]
|
|
233
255
|
cmd.append(output)
|
|
234
256
|
run(cmd)
|
|
235
257
|
result = probe(output, role="output")
|
|
236
258
|
v = result["video"]
|
|
237
259
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
|
|
238
|
-
extra: Dict[str, object] = {}
|
|
260
|
+
extra: Dict[str, object] = {"trimmed": trimmed} if trimmed else {}
|
|
239
261
|
platform = PLATFORM_OF.get(args.preset)
|
|
240
262
|
if platform and has_audio and not STATE.dry_run:
|
|
241
263
|
# Every eval run that exported for a platform then had to come back with loudness.py: the
|
|
@@ -243,6 +265,8 @@ def main() -> int:
|
|
|
243
265
|
# written file here so the result names the gap and the caller plans one pass, not two.
|
|
244
266
|
spec = PLATFORMS[platform]
|
|
245
267
|
m = measure_loudness(output)
|
|
268
|
+
if not m:
|
|
269
|
+
_loudness_unmeasured(extra, notes, platform, spec)
|
|
246
270
|
if m:
|
|
247
271
|
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
|
|
248
272
|
extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
|
|
@@ -253,7 +277,7 @@ def main() -> int:
|
|
|
253
277
|
extra["loudness"]["silent"] = True
|
|
254
278
|
notes.append(f"output audio is silent (integrated loudness -inf, {platform} expects {spec['lufs']:g} LUFS); "
|
|
255
279
|
"check the source's audio or give the export a real track (audio.py --replace)")
|
|
256
|
-
|
|
280
|
+
warn(notes[-1], "output_audio_silent", platform=platform)
|
|
257
281
|
elif not ok and args.normalize:
|
|
258
282
|
# Eval 7: every platform job ran export -> loudness.py -> export again (two video
|
|
259
283
|
# encodes). The levels pass only re-encodes audio, so do it here on the written
|
|
@@ -275,17 +299,25 @@ def main() -> int:
|
|
|
275
299
|
die(f"--normalize: loudness.py failed: {err.get('message') or proc.stderr.strip()[-300:]}",
|
|
276
300
|
kind=err.get("kind") or "ffmpeg", output=output, hint=err.get("hint"))
|
|
277
301
|
os.replace(tmp, output)
|
|
278
|
-
m = measure_loudness(output)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
302
|
+
m = measure_loudness(output)
|
|
303
|
+
if not m:
|
|
304
|
+
# the file changed: the measurement before --normalize no longer describes it
|
|
305
|
+
_loudness_unmeasured(extra, notes, platform, spec, normalized=True)
|
|
306
|
+
ok = True # nothing more to say about a level that was not measured
|
|
307
|
+
else:
|
|
308
|
+
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
|
|
309
|
+
extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok, "normalized": True}
|
|
310
|
+
extra["verification"] = [{"step": "loudness", "ok": ok, "platform": platform}]
|
|
282
311
|
if not ok:
|
|
283
312
|
notes.append(f"loudness is still {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP after loudness.py (target {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP): {child.get('result', {}).get('note') or 'the encoder overshoots the ceiling'}")
|
|
284
|
-
|
|
313
|
+
warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=True)
|
|
285
314
|
elif not ok:
|
|
286
315
|
notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
|
|
287
316
|
f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file, or export with --normalize")
|
|
288
|
-
|
|
317
|
+
warn(notes[-1], "loudness_outside_spec", platform=platform, lufs=m["lufs"], tp=m["tp"], normalized=False)
|
|
318
|
+
elif platform and not has_audio and not STATE.dry_run:
|
|
319
|
+
record_skip(f"no loudness check against {platform}'s spec: the source has no audio, so the export has none",
|
|
320
|
+
"loudness_no_audio", platform=platform)
|
|
289
321
|
elif platform and has_audio and args.normalize:
|
|
290
322
|
spec = PLATFORMS[platform]
|
|
291
323
|
notes.append(f"[dry-run] --normalize: loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} would run on the written file if it misses {platform}'s spec")
|
package/scripts/graphics.py
CHANGED
|
@@ -29,17 +29,19 @@ import argparse
|
|
|
29
29
|
import os
|
|
30
30
|
import re
|
|
31
31
|
import sys
|
|
32
|
-
from typing import List, Optional
|
|
32
|
+
from typing import List, Optional, Tuple
|
|
33
33
|
|
|
34
34
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
35
35
|
from _common import (aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
36
|
-
color_hex,
|
|
36
|
+
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
37
37
|
ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
38
38
|
drawtext_boxborderw, drawtext_center_align, 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
41
|
EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
|
|
42
42
|
SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
|
|
43
|
+
from _common.emit import warn
|
|
44
|
+
from _common.fonts import font_choice, record_font_choice
|
|
43
45
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
44
46
|
|
|
45
47
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
@@ -52,15 +54,16 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
|
52
54
|
return f"0x{color_hex(hex_rgb)}@{alpha:g}"
|
|
53
55
|
|
|
54
56
|
|
|
55
|
-
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None,
|
|
58
|
+
script_family: Optional[str] = None, renderer: str = "drawtext") -> "Tuple[str, dict]":
|
|
59
|
+
"""(the drawtext font option, the choice behind it -- the result's `font_used`): the caller's
|
|
60
|
+
file, a font picked by the script of the text itself (1.12), the asked family resolved to a
|
|
61
|
+
file, or the family by name when nothing resolves."""
|
|
62
|
+
choice = font_choice(font or brand.get("font", "DejaVu Sans"), font_file=font_file or brand.get("font_file"),
|
|
63
|
+
script_file=script_file, script_family=script_family, renderer=renderer)
|
|
64
|
+
if choice["file"]:
|
|
65
|
+
return f"fontfile={escape_filter_path(choice['file'])}", choice
|
|
66
|
+
return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'", choice
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
def main(force_route: Optional[str] = None) -> int:
|
|
@@ -156,7 +159,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
156
159
|
_script, script_file, _family = script_font_for_text(
|
|
157
160
|
" ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
|
|
158
161
|
lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
|
|
159
|
-
fo = font_opts(brand, args.font, args.font_file, script_file)
|
|
162
|
+
fo, font_used = font_opts(brand, args.font, args.font_file, script_file, _family)
|
|
160
163
|
|
|
161
164
|
meta = probe(args.input)
|
|
162
165
|
if not meta.get("video"):
|
|
@@ -263,8 +266,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
263
266
|
"be drawn as an empty box -- stripped from the text instead "
|
|
264
267
|
"(--text-render auto renders it monochrome through libass)")
|
|
265
268
|
if emoji_mode == "mono":
|
|
266
|
-
|
|
267
|
-
"--emoji-assets DIR for colour). " + support["detail"])
|
|
269
|
+
warn("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
270
|
+
"--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
|
|
268
271
|
if emoji_mode == "none":
|
|
269
272
|
if not "".join(ch for ch in all_text if char_script(ch) != "emoji").strip():
|
|
270
273
|
die("the template's text is nothing but emoji and this machine can draw none of them "
|
|
@@ -565,9 +568,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
565
568
|
"(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1), so it is rendered through libass")
|
|
566
569
|
_DRAWTEXT_PENDING.clear()
|
|
567
570
|
return main(force_route="ass")
|
|
568
|
-
|
|
571
|
+
warn("this ffmpeg's drawtext draws a box at each line break of a multi-line label "
|
|
569
572
|
"(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1): drop --text-render drawtext to render it "
|
|
570
|
-
"through libass, or upgrade FFmpeg")
|
|
573
|
+
"through libass, or upgrade FFmpeg", "drawtext_newline_glyph", text_render="drawtext")
|
|
571
574
|
|
|
572
575
|
output = args.output or default_output(args.input, "gfx")
|
|
573
576
|
ass_path = None
|
|
@@ -631,7 +634,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
631
634
|
f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
|
|
632
635
|
"per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
|
|
633
636
|
if missing:
|
|
634
|
-
|
|
637
|
+
warn("no PNG in the assets directory for " + ", ".join(missing), "emoji_assets_missing", missing=list(missing))
|
|
635
638
|
if emoji_mode:
|
|
636
639
|
emoji_result = {"mode": emoji_mode, "count": len(emoji_clusters(all_text)),
|
|
637
640
|
"clusters": sorted(seen) or sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)}),
|
|
@@ -708,8 +711,11 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
708
711
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
709
712
|
r = probe(output, role="output")
|
|
710
713
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
|
|
714
|
+
if route == "ass":
|
|
715
|
+
# libass looks the family up itself (fontconfig): the same choice, asked for that renderer
|
|
716
|
+
font_used = font_opts(brand, args.font, args.font_file, script_file, _family, renderer="libass")[1]
|
|
711
717
|
extra = {"template": args.template, "dropped_non_av_streams": dropped_streams,
|
|
712
|
-
"text_renderer": route, "script": _script}
|
|
718
|
+
"text_renderer": route, "script": _script, "font_used": record_font_choice(font_used)}
|
|
713
719
|
if ass_path:
|
|
714
720
|
extra["ass"] = ass_path
|
|
715
721
|
if emoji_result:
|
package/scripts/grid.py
CHANGED
|
@@ -25,8 +25,9 @@ import argparse
|
|
|
25
25
|
import os
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
|
-
from _common import add_common, apply_common, aac_args, cfr_args,
|
|
28
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
29
|
draws_chroma, drawn_chain, escape_drawtext, escape_filter_path, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
30
|
+
from _common.fonts import font_choice, record_font_choice
|
|
30
31
|
|
|
31
32
|
LABEL_MARGIN = 10
|
|
32
33
|
|
|
@@ -84,7 +85,10 @@ def main() -> int:
|
|
|
84
85
|
durations = [m.get("duration") or 0.0 for m in metas]
|
|
85
86
|
target_duration = max(durations) if args.pad else min(durations)
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
font_used = font_choice(args.font) # the label font, reported as `font_used` when labels are drawn
|
|
89
|
+
font_file = font_used["file"]
|
|
90
|
+
if args.label == "auto":
|
|
91
|
+
record_font_choice(font_used)
|
|
88
92
|
output = args.output or default_output(args.inputs[0], "grid")
|
|
89
93
|
|
|
90
94
|
cmd = ffmpeg_base()
|
|
@@ -157,7 +161,7 @@ def main() -> int:
|
|
|
157
161
|
v = result["video"]
|
|
158
162
|
info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
|
|
159
163
|
f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
|
|
160
|
-
emit(output, cols=args.cols, rows=args.rows, clips=n)
|
|
164
|
+
emit(output, cols=args.cols, rows=args.rows, clips=n, **({"font_used": font_used} if args.label == "auto" else {}))
|
|
161
165
|
return 0
|
|
162
166
|
|
|
163
167
|
|
package/scripts/join.py
CHANGED
|
@@ -40,6 +40,7 @@ import sys
|
|
|
40
40
|
from typing import Any, Dict, List, Optional, Tuple
|
|
41
41
|
|
|
42
42
|
from _common import STATE, AUDIO_MEDIA_EXT, draws_chroma, drawn_chain, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, dry_run_input_pending, emit, ffmpeg_base, info, measured_level_dbfs, is_audio_output, probe, require_tool, run, validate_color, X264_PRESETS, fmt_secs
|
|
43
|
+
from _common.emit import warn
|
|
43
44
|
|
|
44
45
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
45
46
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -113,7 +114,7 @@ def note_short_segments(paths: List[str], metas: List[dict], minimum: float, uni
|
|
|
113
114
|
STATE_SHORT.append({"index": k, "path": p, "duration": round(dur, 4)})
|
|
114
115
|
if STATE_SHORT:
|
|
115
116
|
listing = ", ".join(f"#{s['index'] + 1} {s['path']} ({s['duration']:g}s)" for s in STATE_SHORT)
|
|
116
|
-
|
|
117
|
+
warn(f"{len(STATE_SHORT)} input(s) shorter than {unit}: {listing}", "short_inputs", count=len(STATE_SHORT))
|
|
117
118
|
STATE_NOTES.append(f"{len(STATE_SHORT)} input(s) are shorter than {unit} and were joined anyway: {listing}")
|
|
118
119
|
|
|
119
120
|
|
|
@@ -361,7 +362,8 @@ def main() -> int:
|
|
|
361
362
|
STATE_SILENT[:] = sorted(intended + (silent if args.on_silent == "warn" else []), key=lambda s: s["index"])
|
|
362
363
|
if silent and args.on_silent == "warn":
|
|
363
364
|
listing = ", ".join(f"#{s['index'] + 1} {s['path']} (peak {s['peak_db']:.1f} dBFS)" for s in silent)
|
|
364
|
-
|
|
365
|
+
warn(f"{len(silent)} input(s) silent at or below {args.silence_threshold:g} dBFS, joined anyway: {listing}",
|
|
366
|
+
"silent_inputs", count=len(silent))
|
|
365
367
|
STATE_NOTES.append(f"{len(silent)} input(s) have no audible sound (peak at or below {args.silence_threshold:g} dBFS) "
|
|
366
368
|
f"and were joined anyway: {listing}; pass --on-silent fail or skip to refuse or drop them")
|
|
367
369
|
STATE_SKIPPED[:] = skipped
|
|
@@ -373,7 +375,7 @@ def main() -> int:
|
|
|
373
375
|
STATE_DUPES[:] = dupes
|
|
374
376
|
if dupes:
|
|
375
377
|
listing = "; ".join(f"{dd['path']} at #" + ", #".join(str(i + 1) for i in dd["indices"]) for dd in dupes)
|
|
376
|
-
|
|
378
|
+
warn(f"input(s) listed more than once: {listing}", "duplicate_inputs", count=len(dupes))
|
|
377
379
|
STATE_NOTES.append(f"{len(dupes)} input(s) are listed more than once and joined each time: {listing}; "
|
|
378
380
|
"fine if the repeat is intended")
|
|
379
381
|
STATE_PENDING[:] = pending
|
package/scripts/loop.py
CHANGED
|
@@ -25,6 +25,7 @@ import math
|
|
|
25
25
|
import sys
|
|
26
26
|
|
|
27
27
|
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
|
|
28
|
+
from _common.emit import warn
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
@@ -51,8 +52,9 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
|
|
|
51
52
|
bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
|
|
52
53
|
held = bytes_per_frame * frames
|
|
53
54
|
if held > 2 * 1024 ** 3:
|
|
54
|
-
|
|
55
|
-
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight"
|
|
55
|
+
warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
|
|
56
|
+
f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
|
|
57
|
+
"memory_heavy", bytes=int(held))
|
|
56
58
|
repeats = -1 if times is None else times - 1
|
|
57
59
|
# Retimed by frame index at the source's nominal rate: a VFR source's reversed half carries
|
|
58
60
|
# its gaps mirrored, and a CFR conform after the graph duplicated a frame against them (a
|
package/scripts/multicam.py
CHANGED
|
@@ -32,6 +32,7 @@ import sys
|
|
|
32
32
|
from typing import Dict, List, Tuple
|
|
33
33
|
|
|
34
34
|
from _common import label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
|
|
35
|
+
from _common.emit import record_fallback, warn
|
|
35
36
|
from sync import measure_offset
|
|
36
37
|
|
|
37
38
|
ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
|
|
@@ -199,7 +200,8 @@ def main() -> int:
|
|
|
199
200
|
conf.append(score)
|
|
200
201
|
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
|
|
201
202
|
if score < 0.1:
|
|
202
|
-
|
|
203
|
+
warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
|
|
204
|
+
"trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
|
|
203
205
|
|
|
204
206
|
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
205
207
|
"confidence": [round(c, 3) for c in conf]}
|
|
@@ -275,13 +277,19 @@ def main() -> int:
|
|
|
275
277
|
convert = [matrix_chain(m, metas[0]) for m in metas]
|
|
276
278
|
if any(convert):
|
|
277
279
|
convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
|
|
280
|
+
# what is actually rendered: a range whose camera has not started yet plays camera 0, and the
|
|
281
|
+
# result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
|
|
282
|
+
rendered: List[Tuple[float, float, int]] = []
|
|
278
283
|
for i, (s, e, c) in enumerate(filled):
|
|
279
284
|
# reference time t maps to source time (t - offset_c) * ratio_c
|
|
280
285
|
src_s = (s - offsets[c]) * ratios[c]
|
|
281
286
|
src_e = (e - offsets[c]) * ratios[c]
|
|
282
287
|
if src_s < 0:
|
|
283
|
-
|
|
288
|
+
record_fallback(f"camera {c} has not started at reference {s:.2f}s; using camera 0 for that range "
|
|
289
|
+
f"({s:.3f}-{e:.3f}s)", "camera_not_started", start=round(s, 3), end=round(e, 3),
|
|
290
|
+
requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
|
|
284
291
|
c, src_s, src_e = 0, s, e
|
|
292
|
+
rendered.append((s, e, c))
|
|
285
293
|
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
|
|
286
294
|
labels.append(f"[v{i}]")
|
|
287
295
|
project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
|
|
@@ -321,7 +329,7 @@ def main() -> int:
|
|
|
321
329
|
r = probe(output, role="output")
|
|
322
330
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
|
|
323
331
|
extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
|
|
324
|
-
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in
|
|
332
|
+
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in rendered], **report, **extra)
|
|
325
333
|
return 0
|
|
326
334
|
|
|
327
335
|
|