ffmpeg-skill 3.6.5 → 3.6.7

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/scripts/export.py CHANGED
@@ -44,16 +44,17 @@ 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
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
- from _common.probe import square_pixels, square_pixels_note
53
+ from _common.probe import PROBED_AUDIO, PROBED_VIDEO, probed_maps, square_pixels, square_pixels_note
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
@@ -204,8 +205,8 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
204
205
  key_chain = ",".join(vf + ["alphaextract", "format=yuv422p10le"])
205
206
  tc = ((meta.get("timecode") or {}).get("value"))
206
207
  tc_args = ["-timecode", tc] if tc else []
207
- cmd = cmd + ["-filter_complex", f"[0:v:0]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
208
- cmd += ["-map", "[fill]"] + (["-map", "0:a:0"] if has_audio else []) + p["video"] + tc_args + cfr_args(meta)
208
+ cmd = cmd + ["-filter_complex", f"[0:{PROBED_VIDEO}]split[f][k];[f]{fill_chain}[fill];[k]{key_chain}[key]"]
209
+ cmd += ["-map", "[fill]"] + probed_maps(meta, video=False, audio=has_audio) + p["video"] + tc_args + cfr_args(meta)
209
210
  # the fill is always written to a hidden sibling and moved into place only after the encode
210
211
  # succeeds: run() stages and cleans up only ffmpeg's last output (the key), so a failed encode
211
212
  # left a partial new fill (the retry was refused as output_exists) or truncated an existing one
@@ -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)
@@ -410,7 +416,9 @@ def media_damage(path: str) -> Dict[str, int]:
410
416
  PTS) and every other error line, counted. What --repair is measured by, before and after."""
411
417
  # repeat+: every occurrence on its own line, not "Last message repeated N times", which hid
412
418
  # N timestamp warnings and was itself counted as a decode error
413
- proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path, "-f", "null", "-"],
419
+ # the streams a copy export writes (probed_maps), not the ones default selection would decode
420
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-nostdin", "-v", "repeat+warning", "-i", path,
421
+ "-map", f"0:{PROBED_VIDEO}?", "-map", f"0:{PROBED_AUDIO}?", "-f", "null", "-"],
414
422
  check=False, record=True)
415
423
  lines = [ln for ln in (proc.stderr or "").splitlines() if ln.strip()]
416
424
  # "Packet corrupt (stream = 0, dts = ...)" names a dts but is damage, not a timestamp problem
@@ -585,16 +593,19 @@ def main() -> int:
585
593
  # Subtitle and data streams (a soft-subtitle track muxed upstream, a camera's timecode) are
586
594
  # kept where the container can hold them: ffmpeg's default stream selection takes one video
587
595
  # and one audio stream only, and through 2.22.1 every preset dropped the rest without a word.
588
- # Mapping them means mapping the picture and the sound too, the same streams probe() describes.
596
+ # The picture and the sound are mapped on every export, the same streams probe() describes:
597
+ # until A118 they were mapped only beside a subtitle or data stream, and without one ffmpeg's
598
+ # default selection took the larger picture and the track with the default disposition or the
599
+ # most channels, so which audio track an export kept depended on whether it had subtitles.
589
600
  subs = meta.get("subtitle_stream_details") or []
590
601
  data = _data_streams(args.input) if meta.get("data_streams") else []
591
602
  sub_codecs = [_subtitle_codec(s.get("codec") or "", out_ext) for s in subs]
592
- if args.preset != "gif" and not p.get("keyfill") and (subs or data):
593
- cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
603
+ if args.preset != "gif" and not p.get("keyfill"):
604
+ cmd += probed_maps(meta, audio=has_audio)
594
605
 
595
606
  if args.preset == "gif":
596
607
  chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
597
- fc = f"[0:v]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
608
+ fc = f"[0:{PROBED_VIDEO}]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
598
609
  cmd += ["-filter_complex", fc, "-loop", "0", output]
599
610
  run(cmd)
600
611
  info(f"wrote {output}")
@@ -717,7 +728,14 @@ def main() -> int:
717
728
  # written file here so the result names the gap and the caller plans one pass, not two.
718
729
  spec = PLATFORMS[platform]
719
730
  m = measure_loudness(output)
720
- 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:
721
739
  _loudness_unmeasured(extra, notes, platform, spec)
722
740
  if m:
723
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})'"
package/scripts/look.py CHANGED
@@ -24,7 +24,7 @@ from _common import drawtext_fontfile # noqa: E402
24
24
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
26
26
  from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
27
- from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
27
+ from _common.probe import PROBED_VIDEO, display_size, square_pixels, square_pixels_note # noqa: E402
28
28
 
29
29
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
30
30
  # Action and title safe are SMPTE ST 2046-1's 93 % and 90 % of a 16:9 picture; the 4:3 centre cut
@@ -337,7 +337,7 @@ def main() -> int:
337
337
  conv = "," + conv if conv else ""
338
338
  return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
339
339
  f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
340
- fc = (f"[0:v]{lab}{sq}{fit(tm)}[a];[1:v]{lab2}{csq}{fit(tm2)}[b];"
340
+ fc = (f"[0:{PROBED_VIDEO}]{lab}{sq}{fit(tm)}[a];[1:{PROBED_VIDEO}]{lab2}{csq}{fit(tm2)}[b];"
341
341
  f"[a][b]hstack=inputs=2[out]")
342
342
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
343
343
  "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
@@ -354,7 +354,7 @@ def main() -> int:
354
354
  # several frames, or -o given as a stem/prefix without an image extension
355
355
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
356
356
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
357
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
357
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", f"{lab}{sq}scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
358
358
  run(cmd)
359
359
  outputs.append(out)
360
360
  else:
@@ -384,7 +384,7 @@ def main() -> int:
384
384
  tc_sheet = tc
385
385
  vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',{lab}{sq}scale={tile_w}:-2{tc_sheet},"
386
386
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
387
- cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
387
+ cmd = ffmpeg_base() + ["-copyts", "-start_at_zero", "-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-vf", vf, "-frames:v", "1", "-update", "1", out]
388
388
  run(cmd)
389
389
  outputs.append(out)
390
390
  info(f"contact sheet: {n} frames every {step:.2f}s")
@@ -23,6 +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, shorter_than_a_loudness_block
27
+ from _common.emit import refuse
26
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
27
29
 
28
30
 
@@ -65,7 +67,11 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
65
67
  if dry_run_input_pending(path):
66
68
  return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
67
69
  ffmpeg = require_tool("ffmpeg")
68
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
70
+ # the first audio track, the one pass 2 normalises and probe() describes: without a -map,
71
+ # FFmpeg measured the track with the default disposition or the most channels, and the gain
72
+ # for that track was applied to a:0 (A110)
73
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn",
74
+ "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
69
75
  # Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
70
76
  # the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
71
77
  proc = run_analysis(cmd, check=False, record=True)
@@ -73,14 +79,37 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
73
79
  if proc.returncode != 0 or not m:
74
80
  die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
75
81
  data = json.loads(m.group(0))
82
+ data["too_short"] = False
76
83
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
77
84
  if data.get(k) in (None, "-inf", "inf", "nan"):
78
- 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"]
79
92
  return data
80
93
  data["silent"] = False
81
94
  return data
82
95
 
83
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
+
84
113
  def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
85
114
  """One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
86
115
  af = (
@@ -112,9 +141,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
112
141
  af += f",atrim=duration={float(in_audio):.6f}"
113
142
  cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
114
143
  if ext in AUDIO_CODECS or not meta.get("video"):
115
- cmd += ["-vn"] + audio_codec_for(output, bitrate)
144
+ cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
116
145
  else:
117
- cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
146
+ cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
118
147
  if ext in (".mp4", ".mov", ".m4v"):
119
148
  cmd += ["-movflags", "+faststart"]
120
149
  dropped_streams = run_keeping_subtitles(cmd, output)
@@ -125,7 +154,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
125
154
  encode(tp_max, bitrate)
126
155
  if STATE.dry_run:
127
156
  # pass 1 measured the input for real; there is no output to measure
128
- 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)}
129
159
  after = measure(output, lufs, tp_max, args.lra)
130
160
  # loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
131
161
  # its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
@@ -157,22 +187,43 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
157
187
  if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
158
188
  result["audio_bitrate_used"] = bitrate
159
189
  result["encodes"] = rounds + 1
160
- if not after.get("silent") and abs(float(after["input_i"]) - lufs) > LUFS_TOLERANCE:
161
- result["note"] = (f"integrated loudness is {float(after['input_i']) - lufs:+.1f} LU from the target because the "
162
- f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
163
- 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")
164
206
  if not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB:
165
207
  die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {tp_max:g}); "
166
208
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
167
209
  kind="verification", output=output, result=result,
168
210
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
169
- verification = [{"step": "loudness",
170
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= tp_max + TP_TOLERANCE_DB),
171
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": lufs, "target_tp": tp_max,
172
- "property": "loudness", "expected": {"lufs": lufs, "tp_max": tp_max},
173
- "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
174
- "delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
175
- "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"}]
176
227
  written = probe(output, role="output")
177
228
  out_audio = audio_length(written)
178
229
  if in_audio and out_audio:
@@ -199,6 +250,10 @@ def _versions(args, meta: dict, plan: list) -> int:
199
250
  for label, lufs, tp in plan:
200
251
  out = default_output(args.input, f"loudnorm_{label}")
201
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"))
202
257
  if stats.get("silent"):
203
258
  die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
204
259
  doc = _normalize(args, meta, stats, lufs, tp, out)
@@ -245,6 +300,18 @@ def main() -> int:
245
300
  return _versions(args, meta, plan)
246
301
 
247
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"))
248
315
  if stats.get("silent"):
249
316
  info("audio is silent (integrated loudness -inf); nothing to normalise")
250
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/scenes.py CHANGED
@@ -30,7 +30,7 @@ from _common import drawtext_fontfile # noqa: E402
30
30
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
31
  from _common.emit import note, record_fallback # noqa: E402
32
32
  from _common.color import bt709_label_filter # noqa: E402
33
- from _common.probe import square_pixels # noqa: E402
33
+ from _common.probe import PROBED_VIDEO, square_pixels # noqa: E402
34
34
  from _common import dry_run_input_pending # noqa: E402
35
35
 
36
36
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
@@ -348,7 +348,7 @@ def main() -> int:
348
348
  vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
349
349
  + f"scale={tile_w}:-2{stamp},"
350
350
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
351
- run(ffmpeg_base() + ["-i", args.input, "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
351
+ run(ffmpeg_base() + ["-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
352
352
  info(f"wrote {args.sheet}")
353
353
  result["sheet"] = args.sheet
354
354
 
@@ -34,6 +34,7 @@ from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_inp
34
34
  from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
36
36
  from _common.decision import frame_rate, stream_starts
37
+ from _common.probe import PROBED_AUDIO
37
38
 
38
39
 
39
40
 
@@ -305,7 +306,8 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
305
306
  out = []
306
307
  with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
307
308
  raw = os.path.join(tmp, "pcm.s16le")
308
- proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn",
309
+ proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
310
+ "-map", f"0:{PROBED_AUDIO}", "-vn",
309
311
  "-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
310
312
  check=False, text=False)
311
313
  if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
@@ -27,6 +27,8 @@ import sys
27
27
  import tempfile
28
28
  from pathlib import Path
29
29
 
30
+ from _common.probe import PROBED_VIDEO
31
+ from _common.runner import default_selection_maps
30
32
  from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
31
33
 
32
34
 
@@ -79,8 +81,9 @@ def main() -> int:
79
81
  # A frame number, not a boolean: frame 1 is the standard reference for "lock to
80
82
  # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
81
83
  detect_vf += ":tripod=1"
84
+ # both passes read the picture probe() describes (its size chose the even scale above)
82
85
  detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
83
- "-vf", detect_vf, "-f", "null", "-"]
86
+ "-map", f"0:{PROBED_VIDEO}", "-vf", detect_vf, "-f", "null", "-"]
84
87
  # Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
85
88
  # as well; only pass 2, the write, is skipped there.
86
89
  if not dry_run_input_pending(args.input):
@@ -94,7 +97,7 @@ def main() -> int:
94
97
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
95
98
  # camera path makes no sense once every frame is locked to one fixed reference.
96
99
  transform_vf += ":tripod=1"
97
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
100
+ cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
98
101
  cmd += video_args(meta, args.crf, args.preset)
99
102
  cmd += cfr_args(meta)
100
103
  if has_audio:
package/scripts/sync.py CHANGED
@@ -37,6 +37,7 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
37
37
  from _common.emit import record_skip, warn
38
38
  from _common.decision import SEEK_MARGIN, frame_rate, grid_at_or_after, stream_starts
39
39
  from _common.timeline import output_timecode_args
40
+ from _common.runner import default_selection_maps
40
41
 
41
42
  SR = 8000 # decode sample rate
42
43
 
@@ -392,14 +393,15 @@ def main() -> int:
392
393
  # Even a keyframe on the trim point moved the picture by its B-frame delay (+69 ms).
393
394
  # The re-encode below starts on the frame itself; a sound-only file is still copied.
394
395
  if head_trim > 0 and not drift_af and not has_video:
395
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy"]
396
+ # the second file's first audio track: the one decode_pcm_mono() measured
397
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + ["-c", "copy"]
396
398
  if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
397
399
  cmd += ["-movflags", "+faststart"]
398
400
  cmd += ["-avoid_negative_ts", "make_zero", output]
399
401
  proc = run(cmd, check=False)
400
402
  fail_if_classified(proc)
401
403
  if proc.returncode != 0:
402
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
404
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + default_selection_maps(second_meta, output) + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
403
405
  run(cmd)
404
406
  else:
405
407
  # Seek SEEK_MARGIN early and drop the margin on the output side, as cut.py's
@@ -415,7 +417,9 @@ def main() -> int:
415
417
  cmd += ["-ss", f"{head_trim - m:.6f}"]
416
418
  if m > 0:
417
419
  out_seek = ["-ss", f"{m / (drift_ratio if drift_af else 1.0):.6f}"]
418
- cmd += ["-i", args.second] + out_seek
420
+ # -map after the output-side -ss: both are output options, so the order is the
421
+ # same command, and the seek stays next to the -i it drops the margin from
422
+ cmd += ["-i", args.second] + out_seek + default_selection_maps(second_meta, output)
419
423
  af_parts = on_clock + drift_af
420
424
  if delay_ms > 0:
421
425
  af_parts.append(f"adelay={delay_ms}:all=1")