ffmpeg-skill 3.6.6 → 3.7.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 +11 -9
- package/SKILL.md +4 -2
- package/bin/install.js +79 -5
- package/docs/contract.md +22 -6
- package/package.json +1 -1
- package/references/scripts.md +34 -5
- package/scripts/_common/alpha.py +7 -4
- package/scripts/_common/color.py +30 -0
- package/scripts/_common/probe.py +13 -0
- package/scripts/_common/runner.py +88 -3
- package/scripts/_contract.py +23 -12
- package/scripts/_smartcut.py +7 -5
- package/scripts/audio.py +186 -11
- package/scripts/caption.py +29 -3
- package/scripts/check.py +52 -7
- package/scripts/export.py +20 -7
- package/scripts/graphics.py +6 -0
- package/scripts/loudness.py +76 -14
- package/scripts/multicam.py +21 -4
- package/scripts/overlay.py +6 -0
- package/scripts/pad.py +6 -0
- package/scripts/render.py +30 -3
- package/scripts/verify.py +9 -2
package/scripts/caption.py
CHANGED
|
@@ -1229,7 +1229,9 @@ def main() -> int:
|
|
|
1229
1229
|
args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
|
|
1230
1230
|
fit_unmeasurable = not (play_w and play_h)
|
|
1231
1231
|
fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
|
|
1232
|
-
|
|
1232
|
+
# A76: null until a fit (or a stated size) decides it; a --transcribe
|
|
1233
|
+
# dry run has no cues, so nothing is fitted and nothing is claimed
|
|
1234
|
+
"size_used": args.size if (args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit)) else None,
|
|
1233
1235
|
"size_floor": args._fit_floor,
|
|
1234
1236
|
"size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
|
|
1235
1237
|
"shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
|
|
@@ -1569,14 +1571,23 @@ def main() -> int:
|
|
|
1569
1571
|
cmd += [f"-c:s:{i}", existing_out[i]]
|
|
1570
1572
|
tracks: List[Dict[str, Any]] = []
|
|
1571
1573
|
converted: List[str] = []
|
|
1574
|
+
# A60: a kept track is reported with the disposition the input gives it (the muxer copies
|
|
1575
|
+
# it), not False whatever the file says; when --default-track makes a new track the
|
|
1576
|
+
# default, the kept ones are cleared so the file does not carry two defaults
|
|
1577
|
+
new_default = bool(args.default_track and any(
|
|
1578
|
+
lang and lang.lower() == args.default_track.lower() for _p, lang in added))
|
|
1572
1579
|
for i in range(existing_subs):
|
|
1573
1580
|
detail = existing_details[i] if i < len(existing_details) else {}
|
|
1574
1581
|
written = detail.get("codec") if existing_out[i] == "copy" else existing_out[i]
|
|
1575
1582
|
if existing_out[i] != "copy":
|
|
1576
1583
|
converted.append(f"{detail.get('codec')} -> {existing_out[i]}")
|
|
1584
|
+
kept_default = bool(detail.get("default")) and not new_default
|
|
1585
|
+
if new_default and detail.get("default"):
|
|
1586
|
+
# "-default" takes that one flag off; "0" would clear forced and the rest with it
|
|
1587
|
+
cmd += [f"-disposition:s:{i}", "-default"]
|
|
1577
1588
|
tracks.append({"index": i, "file": None, "language": detail.get("language"),
|
|
1578
1589
|
"title": detail.get("title"), "codec": written,
|
|
1579
|
-
"default":
|
|
1590
|
+
"default": kept_default, "cues": None, "kept_from_input": True})
|
|
1580
1591
|
for n, (path, lang) in enumerate(added):
|
|
1581
1592
|
idx = existing_subs + n
|
|
1582
1593
|
cmd += [f"-c:s:{idx}", codec]
|
|
@@ -1608,7 +1619,9 @@ def main() -> int:
|
|
|
1608
1619
|
tracks.append({"index": idx, "file": path, "language": stored, "title": title,
|
|
1609
1620
|
"codec": codec, "default": is_default, "cues": cues_n,
|
|
1610
1621
|
"kept_from_input": False})
|
|
1611
|
-
|
|
1622
|
+
# asked of the tracks this run adds: a default the input already carried does not make
|
|
1623
|
+
# --default-track fr true when no --srt was tagged fr
|
|
1624
|
+
if args.default_track and not new_default:
|
|
1612
1625
|
die(f"--default-track {args.default_track}: no --srt was tagged with that language",
|
|
1613
1626
|
kind="input")
|
|
1614
1627
|
# MPEG-4 has no way to say "no default subtitle track": the muxer sets the track-header
|
|
@@ -1726,6 +1739,19 @@ def main() -> int:
|
|
|
1726
1739
|
generated_ass = True
|
|
1727
1740
|
else:
|
|
1728
1741
|
generated_ass = False
|
|
1742
|
+
if not generated_ass and caption_stats.get("fit_scope") == "cue":
|
|
1743
|
+
# A67: one size per cue is a {\fsN} override in the generated ASS; the plain SRT burn
|
|
1744
|
+
# draws every cue at one FontSize, so the scope that was applied is the file's, and
|
|
1745
|
+
# saying "cue" described sizes nobody drew. A caller's own --ass sets its sizes itself
|
|
1746
|
+
# (styles, per-cue overrides): this tool decided none of them, so it claims no scope.
|
|
1747
|
+
if args.ass:
|
|
1748
|
+
caption_stats["fit_scope"] = None
|
|
1749
|
+
side_notes.append("--fit-size-scope cue was not applied: the --ass file sets its own sizes, "
|
|
1750
|
+
"which this tool does not read or change")
|
|
1751
|
+
else:
|
|
1752
|
+
caption_stats["fit_scope"] = "file"
|
|
1753
|
+
side_notes.append("--fit-size-scope cue applies on the generated-ASS route (--animate, --karaoke, emoji "
|
|
1754
|
+
"overlays); this burn draws every cue at one size (size_used), so the fit is file-scoped")
|
|
1729
1755
|
|
|
1730
1756
|
if args.ass:
|
|
1731
1757
|
if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
|
package/scripts/check.py
CHANGED
|
@@ -48,6 +48,7 @@ from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_
|
|
|
48
48
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
49
49
|
from _common.emit import file_identity
|
|
50
50
|
from _common.probe import PROBED_AUDIO
|
|
51
|
+
from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
|
|
51
52
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
52
53
|
|
|
53
54
|
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
@@ -459,8 +460,17 @@ def main() -> int:
|
|
|
459
460
|
"reason": reason if status != "PASS" else "",
|
|
460
461
|
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
461
462
|
|
|
462
|
-
|
|
463
|
-
|
|
463
|
+
# A113: a length ffprobe did not report is not a length of 0 s that fits under every limit.
|
|
464
|
+
# The row says it was not measured (FAIL under a length limit, WARN without one), and the
|
|
465
|
+
# --content rows that are shares of the running time say so too instead of disappearing.
|
|
466
|
+
dur_read = meta.get("duration")
|
|
467
|
+
dur = dur_read or 0.0
|
|
468
|
+
if dur_read is None:
|
|
469
|
+
row("duration", "FAIL" if spec["max_duration"] else "WARN", "could not measure",
|
|
470
|
+
f"<= {spec['max_duration']:g}s" if spec["max_duration"] else "any",
|
|
471
|
+
"remux the file (export.py --preset copy) so the container states its length, then check again",
|
|
472
|
+
reason="the file does not state its length, so whether it is within the limit is unknown")
|
|
473
|
+
elif spec["max_duration"]:
|
|
464
474
|
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
|
|
465
475
|
else:
|
|
466
476
|
row("duration", "PASS", f"{dur:.2f}s", "any")
|
|
@@ -469,7 +479,9 @@ def main() -> int:
|
|
|
469
479
|
w, h = v["width"], v["height"]
|
|
470
480
|
if v.get("rotation") in (90, -90, 270, -270):
|
|
471
481
|
w, h = h, w
|
|
472
|
-
|
|
482
|
+
# A114: the aspect a viewer sees is the displayed frame's (rotation and SAR applied), not
|
|
483
|
+
# the stored grid's: 1440x1080 at SAR 4:3 is a 16:9 picture
|
|
484
|
+
asp = aspect_name(*shown_size(v)) if all(shown_size(v)) else aspect_name(w, h)
|
|
473
485
|
if spec["aspects"]:
|
|
474
486
|
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
|
|
475
487
|
else:
|
|
@@ -478,7 +490,11 @@ def main() -> int:
|
|
|
478
490
|
if spec["min_height"]:
|
|
479
491
|
row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
|
|
480
492
|
fps = v.get("fps") or 0
|
|
481
|
-
if spec["fps_max"]:
|
|
493
|
+
if spec["fps_max"] and not fps:
|
|
494
|
+
# a rate ffprobe did not report is not 0 fps under every limit (the class of A113)
|
|
495
|
+
row("fps", "WARN", "could not measure", f"<= {spec['fps_max']}", "re-encode at a fixed rate (fit.py --fps N)",
|
|
496
|
+
reason="the stream states no frame rate, so whether it is within the limit is unknown")
|
|
497
|
+
elif spec["fps_max"]:
|
|
482
498
|
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
|
|
483
499
|
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
484
500
|
if spec["codecs"]:
|
|
@@ -517,7 +533,10 @@ def main() -> int:
|
|
|
517
533
|
row("video", "FAIL", "none", "video stream", "")
|
|
518
534
|
|
|
519
535
|
size = meta.get("size_bytes") or 0
|
|
520
|
-
if spec["max_bytes"]:
|
|
536
|
+
if spec["max_bytes"] and meta.get("size_bytes") is None:
|
|
537
|
+
row("file size", "FAIL", "could not measure", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "",
|
|
538
|
+
reason="the file's size could not be read, so whether it is within the limit is unknown")
|
|
539
|
+
elif spec["max_bytes"]:
|
|
521
540
|
row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
|
|
522
541
|
|
|
523
542
|
if a:
|
|
@@ -569,12 +588,30 @@ def main() -> int:
|
|
|
569
588
|
loudness_ran = False
|
|
570
589
|
if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
|
|
571
590
|
row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
|
|
572
|
-
|
|
591
|
+
# A117: --tp alone (custom has no loudness target) asks for the true-peak row, which
|
|
592
|
+
# needs the same measurement pass; without it the run reported 0 failed having measured nothing
|
|
593
|
+
tp_only = spec["lufs"] is None and args.tp is not None
|
|
594
|
+
if not args.no_loudness and (spec["lufs"] is not None or tp_only):
|
|
573
595
|
lm = measure_loudness(args.input)
|
|
574
596
|
loudness_ran = bool(lm)
|
|
575
597
|
if lm and lm["tp"] <= SILENT_PEAK_DBFS:
|
|
576
598
|
silent_audio(lm["tp"])
|
|
577
|
-
if lm:
|
|
599
|
+
if lm and tp_only:
|
|
600
|
+
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
|
|
601
|
+
elif not lm and tp_only:
|
|
602
|
+
row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
|
|
603
|
+
reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
|
|
604
|
+
elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
|
|
605
|
+
and shorter_than_a_loudness_block(a.get("duration") or a.get("tagged_duration") or dur_read)):
|
|
606
|
+
# A257: shorter than one 400 ms gating block: a true peak but no integrated
|
|
607
|
+
# loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
|
|
608
|
+
# the sound's length: a longer track whose every block is under the gate reads
|
|
609
|
+
# -inf too, and that one still FAILs the target below
|
|
610
|
+
row("loudness", "WARN", "could not measure (shorter than one 400 ms loudness block)",
|
|
611
|
+
f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", "",
|
|
612
|
+
reason="integrated loudness is gated over 400 ms blocks; a clip this short has none")
|
|
613
|
+
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + TP_TOLERANCE_DB else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP (+{TP_TOLERANCE_DB:g} dB tolerance)", f"loudness.py --tp {spec['tp']:g}")
|
|
614
|
+
elif lm:
|
|
578
615
|
diff = abs(lm["lufs"] - spec["lufs"])
|
|
579
616
|
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so",
|
|
580
617
|
reason="the platform will auto-normalise it to its own target anyway, which can pump or duck the mix in ways you did not choose")
|
|
@@ -600,6 +637,14 @@ def main() -> int:
|
|
|
600
637
|
if args.content:
|
|
601
638
|
cd = detect_content(args.input, dur, video=bool(v), audio=bool(a))
|
|
602
639
|
span = dur if dur > 0 else 0.0
|
|
640
|
+
if not span and not dry_run_input_pending(args.input):
|
|
641
|
+
# A113: black, frozen and silence are shares of the running time; with no length
|
|
642
|
+
# read there is nothing to divide by, and dropping the rows read as nothing to report
|
|
643
|
+
for name, stream in (("black", v), ("frozen", v), ("silence", a)):
|
|
644
|
+
if stream:
|
|
645
|
+
row(name, "WARN", "could not measure", "a share of the running time",
|
|
646
|
+
"remux the file (export.py --preset copy) so the container states its length, then check again",
|
|
647
|
+
reason="the file does not state its length, so this share of it could not be worked out")
|
|
603
648
|
if "black" in cd and span:
|
|
604
649
|
share = _span_total(cd["black"]) / span
|
|
605
650
|
row("black", "FAIL" if share >= BLACK_FAIL else ("WARN" if share > BLACK_WARN else "PASS"),
|
package/scripts/export.py
CHANGED
|
@@ -44,7 +44,7 @@ import os
|
|
|
44
44
|
import sys
|
|
45
45
|
import tempfile
|
|
46
46
|
from pathlib import Path
|
|
47
|
-
from typing import Dict, List
|
|
47
|
+
from typing import Dict, List, Optional
|
|
48
48
|
|
|
49
49
|
from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
|
|
50
50
|
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
@@ -54,6 +54,7 @@ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels
|
|
|
54
54
|
from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, replace_checked, replace_checked_together, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
|
|
55
55
|
from _common.emit import file_identity
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
|
+
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
57
58
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
58
59
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
59
60
|
# from a literal restated here: before 1.14 they were typed twice and the facebook preset had
|
|
@@ -288,17 +289,22 @@ def _luma_stats(path: str) -> "Dict[str, float] | None":
|
|
|
288
289
|
return _frame_means([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"], path, "format=gray")
|
|
289
290
|
|
|
290
291
|
|
|
291
|
-
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False
|
|
292
|
-
|
|
292
|
+
def _loudness_unmeasured(extra: Dict[str, object], notes: List[str], platform: str, spec: Dict, normalized: bool = False,
|
|
293
|
+
tp: Optional[float] = None) -> None:
|
|
294
|
+
"""The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse, or,
|
|
295
|
+
with `tp`, the audio is shorter than one 400 ms gating block and has only a true peak): the
|
|
293
296
|
platform's loudness was not verified, and the document says so -- a `loudness` block with null
|
|
294
297
|
values and a reason, a failing `loudness` verification step, a warning -- rather than leaving
|
|
295
298
|
the block out, which read as "nothing to check". check.py reports the same case as a WARN row."""
|
|
296
299
|
reason = "not_measured"
|
|
297
|
-
extra["loudness"] = {"lufs": None, "tp":
|
|
298
|
-
"measured": False, "reason": reason, **({"
|
|
300
|
+
extra["loudness"] = {"lufs": None, "tp": tp, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
|
|
301
|
+
"measured": False, "reason": reason, **({"too_short": True} if tp is not None else {}),
|
|
302
|
+
**({"normalized": True} if normalized else {})}
|
|
299
303
|
extra["verification"] = [{"step": "loudness", "ok": False, "platform": platform, "reason": reason}]
|
|
304
|
+
why = ("the audio is shorter than one 400 ms loudness block, so it has a true peak but no integrated loudness"
|
|
305
|
+
if tp is not None else "ffmpeg's loudnorm output did not parse")
|
|
300
306
|
msg = (f"the written file's loudness could not be measured{' after --normalize' if normalized else ''} "
|
|
301
|
-
f"(
|
|
307
|
+
f"({why}), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
|
|
302
308
|
"verified; run check.py on the file")
|
|
303
309
|
notes.append(msg)
|
|
304
310
|
warn(msg, "loudness_not_measured", platform=platform, normalized=normalized)
|
|
@@ -722,7 +728,14 @@ def main() -> int:
|
|
|
722
728
|
# written file here so the result names the gap and the caller plans one pass, not two.
|
|
723
729
|
spec = PLATFORMS[platform]
|
|
724
730
|
m = measure_loudness(output)
|
|
725
|
-
|
|
731
|
+
written_audio = probe(output, role="output").get("audio") or {}
|
|
732
|
+
if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
|
|
733
|
+
and shorter_than_a_loudness_block(written_audio.get("duration") or written_audio.get("tagged_duration"))):
|
|
734
|
+
# A257: too short for a gating block is not silent (it has a measured peak); a longer
|
|
735
|
+
# track under the gate is not "too short" and takes the path it took before
|
|
736
|
+
_loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
|
|
737
|
+
m = {}
|
|
738
|
+
elif not m:
|
|
726
739
|
_loudness_unmeasured(extra, notes, platform, spec)
|
|
727
740
|
if m:
|
|
728
741
|
ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"] + TP_TOLERANCE_DB
|
package/scripts/graphics.py
CHANGED
|
@@ -202,6 +202,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
202
202
|
fps = meta["video"].get("fps")
|
|
203
203
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
204
204
|
e = time_arg(args.end, "--end", fps) if args.end else dur
|
|
205
|
+
# the picture's own length first: sound that runs on keeps the container open past the last frame
|
|
206
|
+
picture_len = meta["video"].get("duration") or dur
|
|
207
|
+
if picture_len and s >= picture_len:
|
|
208
|
+
# A172: a graphic that starts after the picture ends draws nothing (verified: true before)
|
|
209
|
+
die(f"--start {s:g} is at or after the end of the video ({picture_len:.3f} s): nothing would be drawn",
|
|
210
|
+
hint="give a --start inside the clip")
|
|
205
211
|
if e <= s:
|
|
206
212
|
die("--end must be after --start")
|
|
207
213
|
en = f"enable='between(t,{s:.3f},{e:.3f})'"
|
package/scripts/loudness.py
CHANGED
|
@@ -23,7 +23,8 @@ from _platforms import TP_TOLERANCE_DB
|
|
|
23
23
|
|
|
24
24
|
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
|
-
from _common.probe import PROBED_AUDIO, probed_maps
|
|
26
|
+
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
|
+
from _common.emit import refuse
|
|
27
28
|
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
|
|
28
29
|
|
|
29
30
|
|
|
@@ -78,14 +79,37 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
78
79
|
if proc.returncode != 0 or not m:
|
|
79
80
|
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
80
81
|
data = json.loads(m.group(0))
|
|
82
|
+
data["too_short"] = False
|
|
81
83
|
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
|
82
84
|
if data.get(k) in (None, "-inf", "inf", "nan"):
|
|
83
|
-
|
|
85
|
+
# A257: a clip shorter than one 400 ms gating block has no integrated loudness but a
|
|
86
|
+
# true peak; that is not silence, and telling the caller to replace the audio was wrong.
|
|
87
|
+
# The length decides it, not the peak: a long track under the absolute gate has a
|
|
88
|
+
# finite peak and no integrated loudness as well, and stays "silent" as before.
|
|
89
|
+
data["too_short"] = (data.get("input_tp") not in (None, "-inf", "inf", "nan")
|
|
90
|
+
and shorter_than_a_loudness_block(audio_length(probe(path))))
|
|
91
|
+
data["silent"] = not data["too_short"]
|
|
84
92
|
return data
|
|
85
93
|
data["silent"] = False
|
|
86
94
|
return data
|
|
87
95
|
|
|
88
96
|
|
|
97
|
+
def loudnorm_mode(stats: dict, lufs: float, tp: float, lra: float) -> str:
|
|
98
|
+
"""The mode loudnorm runs pass 2 in, by its own rule (af_loudnorm.c init): linear only when the
|
|
99
|
+
measured values are all usable and the linear gain keeps the true peak under the ceiling and
|
|
100
|
+
the range within --lra; otherwise it falls back to dynamic, which compresses the range and can
|
|
101
|
+
land away from the target on its own (A111). The values are the strings pass 1 printed, which
|
|
102
|
+
are the ones pass 2 is given, so the comparison is ffmpeg's own. Checked against loudnorm's
|
|
103
|
+
print_format=json normalization_type by test_loudnorm_mode_matches_what_ffmpeg_reports."""
|
|
104
|
+
try:
|
|
105
|
+
mi, mtp, mlra, mth = (float(stats[k]) for k in ("input_i", "input_tp", "input_lra", "input_thresh"))
|
|
106
|
+
except (KeyError, TypeError, ValueError):
|
|
107
|
+
return "dynamic"
|
|
108
|
+
if mtp == 99 or mth == -70 or mlra == 0 or mi == 0:
|
|
109
|
+
return "dynamic"
|
|
110
|
+
return "linear" if mtp + (lufs - mi) <= tp and mlra <= lra else "dynamic"
|
|
111
|
+
|
|
112
|
+
|
|
89
113
|
def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
|
|
90
114
|
"""One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
|
|
91
115
|
af = (
|
|
@@ -130,7 +154,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
130
154
|
encode(tp_max, bitrate)
|
|
131
155
|
if STATE.dry_run:
|
|
132
156
|
# pass 1 measured the input for real; there is no output to measure
|
|
133
|
-
return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
|
|
157
|
+
return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
|
|
158
|
+
"loudnorm_mode": loudnorm_mode(stats, lufs, tp_max, args.lra)}
|
|
134
159
|
after = measure(output, lufs, tp_max, args.lra)
|
|
135
160
|
# loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
|
|
136
161
|
# its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
|
|
@@ -162,22 +187,43 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
162
187
|
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
163
188
|
result["audio_bitrate_used"] = bitrate
|
|
164
189
|
result["encodes"] = rounds + 1
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
190
|
+
# the mode of the last encode: its ceiling is the one the file was made with
|
|
191
|
+
result["loudnorm_mode"] = loudnorm_mode(stats, lufs, ceiling, args.lra)
|
|
192
|
+
if not after.get("silent") and not after.get("too_short") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
|
|
193
|
+
if result["loudnorm_mode"] == "dynamic":
|
|
194
|
+
# A111: the miss is loudnorm's dynamic mode, not an encoder overshoot (it happens on a
|
|
195
|
+
# lossless output too): a linear gain to the target would have put the true peak over
|
|
196
|
+
# the ceiling, or the range is wider than --lra
|
|
197
|
+
result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because "
|
|
198
|
+
f"loudnorm could not apply one linear gain (the input's true peak {float(stats['input_tp']):.1f} dBTP "
|
|
199
|
+
f"plus the gain would pass the {ceiling:.2f} dBTP ceiling, or its range "
|
|
200
|
+
f"{float(stats['input_lra']):.1f} LU is outside 0 < LRA <= --lra {args.lra:g}) and fell back to "
|
|
201
|
+
f"dynamic mode, which compresses the range; a lower -I, a higher --tp or a wider --lra keeps it linear")
|
|
202
|
+
else:
|
|
203
|
+
result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
|
|
204
|
+
f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
|
|
205
|
+
f"higher --audio-bitrate keeps both")
|
|
169
206
|
if not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB:
|
|
170
207
|
die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {tp_max:g}); "
|
|
171
208
|
f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
|
|
172
209
|
kind="verification", output=output, result=result,
|
|
173
210
|
hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
211
|
+
if after.get("silent") or after.get("too_short"):
|
|
212
|
+
# an output with no integrated loudness was not measured against the target: through
|
|
213
|
+
# 3.6 a silent one passed this step (ok: true) with -inf in its numbers
|
|
214
|
+
verification = [{"step": "loudness", "ok": False, "target_lufs": lufs, "target_tp": tp_max,
|
|
215
|
+
"property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
|
|
216
|
+
"reason": "not_measured: the output has no integrated loudness ("
|
|
217
|
+
+ ("silent" if after.get("silent") else "shorter than one 400 ms block") + ")",
|
|
218
|
+
"provenance": "user_specified"}]
|
|
219
|
+
else:
|
|
220
|
+
verification = [{"step": "loudness",
|
|
221
|
+
"ok": abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB,
|
|
222
|
+
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
|
|
223
|
+
"property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
|
|
224
|
+
"measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
|
|
225
|
+
"delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
|
|
226
|
+
"tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
|
|
181
227
|
written = probe(output, role="output")
|
|
182
228
|
out_audio = audio_length(written)
|
|
183
229
|
if in_audio and out_audio:
|
|
@@ -204,6 +250,10 @@ def _versions(args, meta: dict, plan: list) -> int:
|
|
|
204
250
|
for label, lufs, tp in plan:
|
|
205
251
|
out = default_output(args.input, f"loudnorm_{label}")
|
|
206
252
|
stats = measure(args.input, lufs, tp, args.lra)
|
|
253
|
+
if stats.get("too_short"):
|
|
254
|
+
refuse("input", "audio_too_short",
|
|
255
|
+
f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
|
|
256
|
+
"no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
|
|
207
257
|
if stats.get("silent"):
|
|
208
258
|
die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
|
|
209
259
|
doc = _normalize(args, meta, stats, lufs, tp, out)
|
|
@@ -250,6 +300,18 @@ def main() -> int:
|
|
|
250
300
|
return _versions(args, meta, plan)
|
|
251
301
|
|
|
252
302
|
stats = measure(args.input, args.lufs, args.tp, args.lra)
|
|
303
|
+
if stats.get("too_short"):
|
|
304
|
+
info(f"audio is shorter than one 400 ms loudness block: no integrated loudness (true peak {stats.get('input_tp')} dBTP)")
|
|
305
|
+
if args.measure_only:
|
|
306
|
+
measured = {"silent": False, "too_short": True, "input_i": None, "input_tp": stats.get("input_tp")}
|
|
307
|
+
if STATE.json:
|
|
308
|
+
emit(None, measured=measured)
|
|
309
|
+
else:
|
|
310
|
+
print(json.dumps(measured, indent=2))
|
|
311
|
+
return 0
|
|
312
|
+
refuse("input", "audio_too_short",
|
|
313
|
+
f"input audio peaks at {stats.get('input_tp')} dBTP but is shorter than one 400 ms loudness block, so it has "
|
|
314
|
+
"no integrated loudness to normalise from (this is not silence)", input_tp=stats.get("input_tp"))
|
|
253
315
|
if stats.get("silent"):
|
|
254
316
|
info("audio is silent (integrated loudness -inf); nothing to normalise")
|
|
255
317
|
if args.measure_only:
|
package/scripts/multicam.py
CHANGED
|
@@ -33,7 +33,7 @@ import sys
|
|
|
33
33
|
from typing import Dict, List, Tuple
|
|
34
34
|
|
|
35
35
|
from _common import guard_sidecar, label_chain, matrix_chain, range_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
|
|
36
|
-
from _common.emit import note, record_fallback, warn
|
|
36
|
+
from _common.emit import note, record_fallback, record_skip, warn
|
|
37
37
|
from _common.decision import av_trim, rebase_project_paths
|
|
38
38
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
39
39
|
from sync import measure_offset
|
|
@@ -179,9 +179,13 @@ def main() -> int:
|
|
|
179
179
|
offsets = [0.0]
|
|
180
180
|
ratios = [1.0]
|
|
181
181
|
conf = [1.0]
|
|
182
|
+
# A131: per camera, why --fix-drift measured no drift (too_short / low_confidence, as sync.py
|
|
183
|
+
# says it); None when it was measured, and for the reference, which is the clock (0 by definition)
|
|
184
|
+
drift_skipped: List[object] = [None]
|
|
182
185
|
for p in args.inputs[1:]:
|
|
183
186
|
off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
|
|
184
187
|
ratio = 1.0
|
|
188
|
+
skipped_reason = None
|
|
185
189
|
if args.fix_drift:
|
|
186
190
|
ref_dur = metas[0]["duration"] or 0.0
|
|
187
191
|
sec_dur = probe(p)["duration"] or 0.0
|
|
@@ -189,7 +193,11 @@ def main() -> int:
|
|
|
189
193
|
window = 60.0
|
|
190
194
|
head_len = min(args.analyze_seconds, overlap_end)
|
|
191
195
|
tail_start = overlap_end - window
|
|
192
|
-
if tail_start
|
|
196
|
+
if tail_start <= head_len / 2 + 5:
|
|
197
|
+
skipped_reason = "too_short"
|
|
198
|
+
record_skip(f"{p} too short to measure drift reliably; skipping drift correction", "drift_correction_skipped",
|
|
199
|
+
path=p, reason=skipped_reason, overlap_seconds=round(overlap_end, 3), drift_window=window)
|
|
200
|
+
else:
|
|
193
201
|
ref_start, sec_start = tail_start, tail_start - off
|
|
194
202
|
if sec_start < 0:
|
|
195
203
|
ref_start -= sec_start
|
|
@@ -204,10 +212,18 @@ def main() -> int:
|
|
|
204
212
|
if elapsed > 0 and sc > 0.1:
|
|
205
213
|
ratio = 1.0 - residual / elapsed
|
|
206
214
|
off = off + (ratio - 1.0) * (head_len / 2)
|
|
215
|
+
else:
|
|
216
|
+
skipped_reason = "low_confidence"
|
|
217
|
+
record_skip(f"could not measure drift with confidence for {p}; skipping drift correction",
|
|
218
|
+
"drift_correction_skipped", path=p, reason=skipped_reason,
|
|
219
|
+
confidence=round(max(0.0, sc), 3), measured_over_seconds=round(elapsed, 2))
|
|
220
|
+
drift_skipped.append(skipped_reason)
|
|
207
221
|
offsets.append(off)
|
|
208
222
|
ratios.append(ratio)
|
|
209
223
|
conf.append(score)
|
|
210
|
-
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})"
|
|
224
|
+
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})"
|
|
225
|
+
+ (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift and not skipped_reason else
|
|
226
|
+
f", drift not measured ({skipped_reason})" if args.fix_drift else ""))
|
|
211
227
|
if score < 0.1:
|
|
212
228
|
warn(f"{p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before "
|
|
213
229
|
"trusting this offset", "sync_low_confidence", path=p, confidence=round(score, 3))
|
|
@@ -215,7 +231,8 @@ def main() -> int:
|
|
|
215
231
|
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
216
232
|
"confidence": [round(c, 3) for c in conf]}
|
|
217
233
|
if args.fix_drift:
|
|
218
|
-
report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
|
|
234
|
+
report["drift_ppm"] = [None if why else round((r - 1) * 1e6, 1) for r, why in zip(ratios, drift_skipped)]
|
|
235
|
+
report["drift_skipped_reason"] = drift_skipped
|
|
219
236
|
if args.offsets_only:
|
|
220
237
|
emit(None, **report)
|
|
221
238
|
if not args.json:
|
package/scripts/overlay.py
CHANGED
|
@@ -388,6 +388,12 @@ def main() -> int:
|
|
|
388
388
|
end = time_arg(args.end, "--end", fps) if args.end else None
|
|
389
389
|
if start is not None and end is not None and end <= start:
|
|
390
390
|
die("--end must be after --start")
|
|
391
|
+
main_len = (meta.get("video") or {}).get("duration") or meta.get("duration")
|
|
392
|
+
if start is not None and main_len and start >= main_len:
|
|
393
|
+
# A172: an overlay that starts after the picture ends draws nothing, and the run said
|
|
394
|
+
# verified: true; --place already refuses the same case
|
|
395
|
+
die(f"--start {start:g} is at or after the end of the video ({main_len:.3f} s): nothing would be drawn",
|
|
396
|
+
hint="give a --start inside the clip")
|
|
391
397
|
if not 0 <= args.opacity <= 1:
|
|
392
398
|
die("--opacity must be within 0..1")
|
|
393
399
|
if args.chromakey and not args.video:
|
package/scripts/pad.py
CHANGED
|
@@ -16,6 +16,7 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
+
from _common.emit import record_fallback
|
|
19
20
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
20
21
|
|
|
21
22
|
|
|
@@ -58,6 +59,11 @@ def main() -> int:
|
|
|
58
59
|
# streams runs on 5.1-8.1 alike. run() only adds an even scale to a -vf, so add it here.
|
|
59
60
|
v = meta["video"]
|
|
60
61
|
even = EVEN_SCALE + "," if (v.get("width") or 0) % 2 or (v.get("height") or 0) % 2 else ""
|
|
62
|
+
if even:
|
|
63
|
+
# A239: the runner's retry records this when it adds the scale; here it is added up
|
|
64
|
+
# front (one -filter_complex), so the record is made here, as broll and stabilize do
|
|
65
|
+
record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
|
|
66
|
+
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
61
67
|
af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
|
|
62
68
|
cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
|
|
63
69
|
else:
|
package/scripts/render.py
CHANGED
|
@@ -391,7 +391,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
391
391
|
size = os.path.getsize(out) if os.path.exists(out) else 0
|
|
392
392
|
rows.append({"platform": name, "file": os.path.basename(out), "path": out,
|
|
393
393
|
"size_bytes": size, "duration": probe_doc.get("duration"),
|
|
394
|
-
|
|
394
|
+
# a destination whose check did not run is "not checked", not "pass" (the class of A113)
|
|
395
|
+
"check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("not checked" if ok else "failed")),
|
|
395
396
|
"ok": bool(ok)})
|
|
396
397
|
outputs.append(out)
|
|
397
398
|
if not ok:
|
|
@@ -421,7 +422,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
421
422
|
code=stopped[0]["exit_code"] if stopped else 1, kind=stopped[0]["kind"] if stopped else "verification",
|
|
422
423
|
output=pack, dry_run=STATE.dry_run, pack=rows, outputs=outputs)
|
|
423
424
|
emit(pack, pack=rows, outputs=outputs, stages=["pack"],
|
|
424
|
-
|
|
425
|
+
# a destination whose check did not run has no check step: "not checked" is not ok: true
|
|
426
|
+
verification=[{"step": "check", "ok": True, "platform": r["platform"]} for r in rows if r["check"] == "pass"])
|
|
425
427
|
return 0
|
|
426
428
|
|
|
427
429
|
|
|
@@ -1186,6 +1188,31 @@ def _has_sound(current: str, proj: Dict[str, Any], rel: Any) -> bool:
|
|
|
1186
1188
|
return False
|
|
1187
1189
|
|
|
1188
1190
|
|
|
1191
|
+
def planned_clip_length(clip: Dict[str, Any], src: str, index: int) -> float:
|
|
1192
|
+
"""A dry run's length of clip `index` before its speed stage: the source's measured length,
|
|
1193
|
+
or its in..out range of it (the cut a real run measures does not exist yet). Only when neither
|
|
1194
|
+
can be read is the speed planned on a stand-in 10 s, and that is recorded (A97): a plan that
|
|
1195
|
+
gave no sign its fit.py --duration came from an invented number read as a measured one."""
|
|
1196
|
+
from _common.decision import parse_time
|
|
1197
|
+
length = fps = None
|
|
1198
|
+
if os.path.isfile(src):
|
|
1199
|
+
meta = probe(src)
|
|
1200
|
+
length = meta.get("duration") or None
|
|
1201
|
+
# hh:mm:ss:ff needs the source's rate, which the real cut reads from the same place
|
|
1202
|
+
fps = (meta.get("video") or {}).get("fps") or None
|
|
1203
|
+
try:
|
|
1204
|
+
start = parse_time(str(clip["in"]), fps) if clip.get("in") is not None else 0.0
|
|
1205
|
+
end = parse_time(str(clip["out"]), fps) if clip.get("out") is not None else length
|
|
1206
|
+
except ValueError:
|
|
1207
|
+
start, end = 0.0, None
|
|
1208
|
+
if end is not None and end - start > 0:
|
|
1209
|
+
return end - start
|
|
1210
|
+
record_skip(f"[dry-run] clip {index}: its length could not be read before the cut exists, so the speed stage is "
|
|
1211
|
+
"planned on a stand-in 10 s; a real run measures the cut", "speed_length_placeholder",
|
|
1212
|
+
stage="clips", clip=index, path=src, placeholder_seconds=10.0)
|
|
1213
|
+
return 10.0
|
|
1214
|
+
|
|
1215
|
+
|
|
1189
1216
|
def main() -> int:
|
|
1190
1217
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1191
1218
|
ap.add_argument("project", nargs="?", help="project.json, or a plan.json written by <tool> --plan")
|
|
@@ -1474,7 +1501,7 @@ def main() -> int:
|
|
|
1474
1501
|
if not (spd > 0) or spd != spd or spd == float("inf"):
|
|
1475
1502
|
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
1476
1503
|
if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
|
|
1477
|
-
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else
|
|
1504
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else planned_clip_length(c, src, i)
|
|
1478
1505
|
fitted = str(work / f"clip{i:02d}_speed{mid}")
|
|
1479
1506
|
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted, stage="clips")
|
|
1480
1507
|
part = fitted
|
package/scripts/verify.py
CHANGED
|
@@ -156,6 +156,12 @@ def main() -> int:
|
|
|
156
156
|
apply_common(args)
|
|
157
157
|
if STATE.plan:
|
|
158
158
|
die("verify.py does not support --plan: its steps run for real (it ignores --dry-run too)")
|
|
159
|
+
ignored_dry_run = STATE.dry_run
|
|
160
|
+
if ignored_dry_run:
|
|
161
|
+
# A240: the flag is accepted and ignored (docs/design-decisions.md "Dry run"); the steps
|
|
162
|
+
# run and write, so the document says dry_run: false and a note says the flag was ignored
|
|
163
|
+
STATE.dry_run = False
|
|
164
|
+
info("--dry-run is ignored by verify.py: its steps run and write their outputs")
|
|
159
165
|
|
|
160
166
|
files = collect(args.paths)
|
|
161
167
|
tmp = None
|
|
@@ -267,11 +273,12 @@ def main() -> int:
|
|
|
267
273
|
print(report)
|
|
268
274
|
if tmp and not args.keep:
|
|
269
275
|
tmp.cleanup()
|
|
276
|
+
notes = (["--dry-run is ignored by verify.py: its steps ran and wrote their outputs"] if ignored_dry_run else [])
|
|
270
277
|
if failed:
|
|
271
278
|
die(f"{failed} of {total} verification steps failed", kind="verification", output=None, dry_run=STATE.dry_run,
|
|
272
|
-
report=args.report, files=results, failed=failed, total=total)
|
|
279
|
+
report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
|
|
273
280
|
if args.json:
|
|
274
|
-
emit(None, report=args.report, files=results, failed=failed, total=total)
|
|
281
|
+
emit(None, report=args.report, files=results, failed=failed, total=total, **({"notes": notes} if notes else {}))
|
|
275
282
|
return 0
|
|
276
283
|
|
|
277
284
|
|