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.
@@ -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
- "size_used": None if fit_unmeasurable else args.size,
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": False, "cues": None, "kept_from_input": True})
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
- if args.default_track and not any(t["default"] for t in tracks):
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
- dur = meta.get("duration") or 0.0
463
- if spec["max_duration"]:
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
- asp = aspect_name(w, h)
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
- if not args.no_loudness and spec["lufs"] is not None:
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) -> None:
292
- """The written file's loudness could not be read (ffmpeg's loudnorm JSON did not parse): the
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": None, "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": None,
298
- "measured": False, "reason": reason, **({"normalized": True} if normalized else {})}
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"(ffmpeg's loudnorm output did not parse), so {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP was not "
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
- if not m:
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
@@ -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})'"
@@ -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
- data["silent"] = True
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
- if not after.get("silent") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
166
- result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
167
- f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
168
- f"higher --audio-bitrate keeps both")
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
- verification = [{"step": "loudness",
175
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB),
176
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
177
- "property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
178
- "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
179
- "delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
180
- "tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
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:
@@ -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 > head_len / 2 + 5:
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})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
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:
@@ -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
- "check": ("pass" if chk.get("ok") else ("%d FAIL" % chk["failed"]) if chk.get("failed") else ("pass" if ok else "failed")),
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
- verification=[{"step": "check", "ok": r["ok"], "platform": r["platform"]} for r in rows])
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 10.0
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