ffmpeg-skill 3.1.8 → 3.2.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/scripts/loop.py CHANGED
@@ -4,11 +4,15 @@
4
4
  For a background loop, an ambient bed, or filling a fixed slot length with
5
5
  a short clip. --times repeats the whole clip that many times back to back;
6
6
  --duration instead loops (and, on the last repeat, trims) to hit an exact
7
- target length. Audio loops along with the video when present. This tool
8
- does not smooth the loop point (no crossfade at the seam) -- a clip that
9
- doesn't already loop cleanly will show a visible cut/pop at each repeat;
10
- that's a judgement call about the source material, not something a --times
11
- or --duration flag can fix. --boomerang is the exception: it plays the clip
7
+ target length. Audio loops along with the video when present. Without
8
+ --crossfade the loop point is not smoothed: a clip that doesn't already loop
9
+ cleanly shows a visible cut/pop at each repeat. --crossfade S makes the clip
10
+ loop first: its last S seconds fade into its first S (picture and sound), so
11
+ one repeat is S shorter than the clip and every seam continues the motion;
12
+ --times 1 then writes a single seamless loop file. Every run measures its
13
+ seam (the picture step and the sound level step where a repeat meets the
14
+ next) and reports it next to the steady frame-to-frame step, so a caller can
15
+ see whether the seam still jumps. --boomerang is the other way: it plays the clip
12
16
  forward, then backward, so every repeat meets frames that match. Each
13
17
  turnaround frame is shown once (frames 0..N-1, then N-2..1), so the motion
14
18
  does not stall at either end. A boomerang is silent, because reversed sound
@@ -19,6 +23,7 @@ Examples:
19
23
  python3 loop.py bg_loop.mp4 --times 3
20
24
  python3 loop.py texture.mp4 --duration 30
21
25
  python3 loop.py wave.mp4 --boomerang --times 4
26
+ python3 loop.py smoke.mp4 --crossfade 1 --duration 60
22
27
  """
23
28
  import argparse
24
29
  import json
@@ -27,9 +32,10 @@ import os
27
32
  import sys
28
33
  import tempfile
29
34
 
30
- from _common import concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
35
+ from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
31
36
  from _common.emit import warn
32
37
  from _common.decision import av_trim
38
+ from _common.probe import decode_gray_frames, decode_pcm_mono
33
39
 
34
40
 
35
41
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -114,6 +120,113 @@ def concat_first_frame(listfile: str) -> float:
114
120
  return 0.0
115
121
 
116
122
 
123
+ def _frame_count(v, rate: float, src_dur: float) -> int:
124
+ return int(v.get("nb_frames") or round((v.get("duration") or src_dur) * rate))
125
+
126
+
127
+ def _conformed_frames(v, rate: float, src_dur: float) -> int:
128
+ """The frames the crossfade's graph sees: the picture after fps=RATE, which fills a gap in a
129
+ variable-rate stream and drops crowded frames, so its count follows the stream's length, not
130
+ the frames stored (299 stored frames over 10 s at 30 fps come out 300). The unit's trims and
131
+ its length are counted in these, or a repeat is a frame or more off the length planned."""
132
+ # the video stream's length, else Matroska's tag, else the container's (main() refuses a source
133
+ # without one): never the stored frame count, which is what fps= does not keep
134
+ d = v.get("duration") or v.get("tagged_duration") or src_dur
135
+ return int(round(float(d) * rate))
136
+
137
+
138
+ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_frames: int, has_audio: bool) -> list:
139
+ """One repeat that loops on itself: the clip's last `fade_frames` fade into its first ones.
140
+
141
+ The tail (frames N-F..N-1) is laid over the head (frames 0..F-1) with xfade, so the repeat
142
+ starts on frame N-F, the frame the clip's own frame N-F-1 leads into: the repeat's end runs
143
+ straight on into its start. Frames F..N-F-1 follow unchanged; the repeat is N-F frames. The
144
+ sound does the same on the picture's clock (padded with silence to the picture's length
145
+ first, so a short or late sound cannot shorten the fade). Written lossless
146
+ (FFV1 + FLAC) to a temporary file the concat list then repeats, so the repeats cost one
147
+ lossy encode, as without --crossfade."""
148
+ fps = _fraction(rate) or 30.0
149
+ fade = fade_frames / fps
150
+ cut = (frames - fade_frames) / fps
151
+ length = frames / fps
152
+ # each branch re-states its rate after setpts: FFmpeg 7.x's setpts marks the frame rate
153
+ # unknown (1/0) and xfade refuses anything but a constant one (6.1 kept it)
154
+ graph = (f"[0:v:0]setpts=PTS-STARTPTS,fps={rate},split[vt][vb];"
155
+ f"[vt]trim=start_frame={frames - fade_frames}:end_frame={frames},setpts=PTS-STARTPTS,fps={rate}[t];"
156
+ f"[vb]trim=end_frame={frames - fade_frames},setpts=PTS-STARTPTS,fps={rate}[b];"
157
+ f"[t][b]xfade=transition=fade:duration={fade:.6f}:offset=0[v]")
158
+ maps = ["-map", "[v]"]
159
+ if has_audio:
160
+ # acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
161
+ # 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
162
+ graph += (f";[0:a:0]aresample=async=1:first_pts=0,apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
163
+ f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
164
+ f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
165
+ f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
166
+ f"[ab]atrim=start={fade:.6f}:end={cut:.6f},asetpts=PTS-STARTPTS[ba];"
167
+ f"[xa][ba]concat=n=2:v=0:a=1[a]")
168
+ maps += ["-map", "[a]", "-c:a", "flac"]
169
+ else:
170
+ maps += ["-an"]
171
+ return ffmpeg_base() + ["-i", src, "-filter_complex", graph] + maps + ["-c:v", "ffv1", "-fps_mode", "passthrough", unit]
172
+
173
+
174
+ # Frames are compared at this size: enough to see a cut or a jump, a few KB per frame.
175
+ SEAM_W, SEAM_H = 64, 36
176
+
177
+
178
+ def _mean_step(a: bytes, b: bytes) -> float:
179
+ return sum(abs(x - y) for x, y in zip(a, b)) / max(1, len(a))
180
+
181
+
182
+ def _rms_db(block) -> "float | None":
183
+ if not block:
184
+ return None
185
+ rms = math.sqrt(sum(x * x for x in block) / len(block))
186
+ return 20 * math.log10(rms) if rms > 1e-6 else -120.0
187
+
188
+
189
+ def _measure_seam(output: str, unit: float, out_dur: float, fps: float, has_audio: bool) -> dict:
190
+ """The first seam of the written file, where repeat 1 meets repeat 2 (or, for a single repeat,
191
+ the file's last frame against its first, as a player looping it would show them).
192
+
193
+ video_step: the mean absolute luma difference (0-255, on a 64x36 grey copy) across the seam;
194
+ video_typical_step: the median of the same difference between the neighbouring frames, so a
195
+ caller can tell a seam that jumps (step far above typical) from a clip that is busy anyway.
196
+ audio_level_step_db: the RMS level 20 ms after the seam minus 20 ms before it."""
197
+ half = min(0.5, unit / 2)
198
+ if out_dur - unit > 1.5 / fps:
199
+ frames = decode_gray_frames(output, fps, SEAM_W, SEAM_H, start=unit - half, seconds=2 * half, check=False)
200
+ at = round(half * fps)
201
+ pair = (frames[at - 1], frames[at]) if 0 < at < len(frames) else None
202
+ where = "between_repeats"
203
+ else:
204
+ head = decode_gray_frames(output, fps, SEAM_W, SEAM_H, seconds=half, check=False)
205
+ tail = decode_gray_frames(output, fps, SEAM_W, SEAM_H, start=max(0.0, out_dur - half), check=False)
206
+ frames = tail + head
207
+ at = len(tail)
208
+ pair = (tail[-1], head[0]) if tail and head else None
209
+ where = "end_to_start"
210
+ seam = {"at": where, "time": round(unit, 3) if where == "between_repeats" else None}
211
+ if pair:
212
+ steps = [_mean_step(frames[i - 1], frames[i]) for i in range(1, len(frames)) if i != at]
213
+ seam["video_step"] = round(_mean_step(*pair), 2)
214
+ seam["video_typical_step"] = round(sorted(steps)[len(steps) // 2], 2) if steps else None
215
+ else:
216
+ seam["video_step"] = seam["video_typical_step"] = None
217
+ if has_audio:
218
+ rate, win = 48000, 0.02
219
+ if where == "between_repeats":
220
+ pcm = decode_pcm_mono(output, rate, seconds=2 * win, start=unit - win, check=False)
221
+ before, after = pcm[: int(rate * win)], pcm[int(rate * win):]
222
+ else:
223
+ after = decode_pcm_mono(output, rate, seconds=win, check=False)
224
+ before = decode_pcm_mono(output, rate, start=max(0.0, out_dur - win), check=False)
225
+ b, a = _rms_db(before), _rms_db(after)
226
+ seam["audio_level_step_db"] = round(a - b, 1) if a is not None and b is not None else None
227
+ return seam
228
+
229
+
117
230
  def main() -> int:
118
231
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
119
232
  ap.add_argument("input")
@@ -123,6 +236,9 @@ def main() -> int:
123
236
  group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
124
237
  ap.add_argument("--boomerang", action="store_true",
125
238
  help="play forward then backward (--times counts round trips; 1 is allowed); drops audio")
239
+ ap.add_argument("--crossfade", metavar="S",
240
+ help="fade the clip's last S seconds into its first S (picture and sound) so every seam is smooth; "
241
+ "one repeat is then S shorter than the clip, and --times 1 writes one seamless loop")
126
242
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
127
243
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
128
244
  add_common(ap)
@@ -141,17 +257,39 @@ def main() -> int:
141
257
  # the concat list is the only -i, so run() never sees the source next to -o (#416 review)
142
258
  refuse_output_is_input(output, args.input)
143
259
 
260
+ v = meta["video"]
261
+ rate = _nominal_rate(v)
262
+ fps = _fraction(rate) or 30.0
263
+ unit = src_dur # the length one repeat adds
264
+ fade_frames = 0
265
+ if args.crossfade is not None:
266
+ if args.boomerang:
267
+ die("--crossfade and --boomerang cannot be combined: a boomerang's turnarounds already meet matching frames")
268
+ frames = _conformed_frames(v, fps, src_dur)
269
+ require_filter("xfade", "--crossfade")
270
+ fade = time_arg(args.crossfade, "--crossfade", v.get("fps"))
271
+ if not math.isfinite(fade):
272
+ die(f"--crossfade must be a finite number of seconds, got {args.crossfade!r}")
273
+ fade_frames = round(fade * fps)
274
+ if fade_frames < 1:
275
+ die(f"--crossfade {args.crossfade!r} is shorter than one frame ({1 / fps:.3f}s at {fps:g} fps)")
276
+ if 2 * fade_frames > frames:
277
+ die(f"--crossfade ({fade_frames / fps:.3f}s) must be at most half the clip ({frames / fps:.3f}s): "
278
+ f"the fade lays the clip's end over its start, and the two must not overlap")
279
+ unit = (frames - fade_frames) / fps
280
+
144
281
  if args.times is not None:
145
- if args.times < (1 if args.boomerang else 2):
146
- die(f"--times must be >= 1 with --boomerang, got {args.times}" if args.boomerang else
147
- f"--times must be >= 2 (1 is just the original clip), got {args.times}")
282
+ least = 1 if (args.boomerang or fade_frames) else 2
283
+ if args.times < least:
284
+ die(f"--times must be >= 1, got {args.times}" if least == 1 else
285
+ f"--times must be >= 2 (1 is just the original clip; with --crossfade, 1 writes one seamless loop), got {args.times}")
148
286
  target = None
149
287
  stream_loop = args.times - 1
150
288
  else:
151
- target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
152
- if target <= src_dur:
153
- die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
154
- stream_loop = math.ceil(target / src_dur) - 1
289
+ target = time_arg(args.duration, "--duration", v.get("fps"))
290
+ if target <= unit:
291
+ die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
292
+ stream_loop = math.ceil(target / unit) - 1
155
293
 
156
294
  if args.boomerang:
157
295
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
@@ -167,8 +305,12 @@ def main() -> int:
167
305
  # late sound leaves at each repeat's start is filled with silence, at any length.
168
306
  workdir = tempfile.TemporaryDirectory(prefix="ffskill_loop_")
169
307
  listfile = os.path.join(workdir.name, "loop.txt")
308
+ repeat = os.path.abspath(args.input)
309
+ if fade_frames:
310
+ repeat = os.path.join(workdir.name, "unit.mkv")
311
+ run(_seamless_unit_cmd(meta, args.input, repeat, rate, _conformed_frames(v, fps, src_dur), fade_frames, has_audio))
170
312
  with open(listfile, "w", encoding="utf-8") as fh:
171
- fh.write((concat_list_line(os.path.abspath(args.input)) + "\n") * (stream_loop + 1))
313
+ fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
172
314
  vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
173
315
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
174
316
  if has_audio:
@@ -179,7 +321,9 @@ def main() -> int:
179
321
  if target is not None:
180
322
  cmd += ["-t", f"{target:.3f}"]
181
323
  cmd += video_args(meta, args.crf, args.preset)
182
- cmd += ["-fps_mode", "passthrough"] if args.boomerang else cfr_args(meta)
324
+ # a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
325
+ # not a variable-rate source's average, which re-timed every repeat by a frame or two
326
+ cmd += ["-fps_mode", "passthrough"] if args.boomerang else cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
183
327
  if has_audio:
184
328
  cmd += aac_args()
185
329
  else:
@@ -191,13 +335,33 @@ def main() -> int:
191
335
  if not args.boomerang:
192
336
  workdir.cleanup()
193
337
 
338
+ extra = {"boomerang": args.boomerang}
339
+ if fade_frames:
340
+ extra["crossfade"] = round(fade_frames / fps, 6)
341
+ requested = target if target is not None else (
342
+ args.times * (2 * _frame_count(v, fps, src_dur) - 2) / fps if args.boomerang else args.times * unit)
343
+ if STATE.dry_run:
344
+ emit(output, expected_duration=round(requested, 3), **extra, **({"notes": notes} if notes else {}))
345
+ return 0
194
346
  result = probe(output, role="output")
195
- v = result["video"]
196
- info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, source {src_dur:.3f}s "
347
+ rv = result["video"]
348
+ out_dur = result.get("duration") or 0.0
349
+ # the length asked for (repeats x one repeat, or --duration), against the file: within a frame
350
+ # and a half, the slack the last repeat's trim and the encoder's final frame leave
351
+ tolerance = 1.5 / fps
352
+ verification = [{"step": "duration", "requested": round(requested, 3), "measured": round(out_dur, 3),
353
+ "tolerance": round(tolerance, 3), "ok": abs(out_dur - requested) <= tolerance}]
354
+ if not args.boomerang:
355
+ extra["seam"] = _measure_seam(output, unit, out_dur, fps, has_audio)
356
+ info(f"wrote {output} ({fmt_secs(result['duration'])}, {rv['width']}x{rv['height']}, source {src_dur:.3f}s "
197
357
  f"{'boomeranged' if args.boomerang else 'looped'})")
198
358
  for note in notes:
199
359
  info(note)
200
- emit(output, boomerang=args.boomerang, **({"notes": notes} if notes else {}))
360
+ # the streams the run must have written: a crossfade unit whose sound came out empty once left
361
+ # a silent loop that still said verified: true
362
+ emit(output, expected_duration=round(requested, 3), verification=verification,
363
+ expect_streams={"video": 1, "audio": 1 if has_audio else 0}, **extra,
364
+ **({"notes": notes} if notes else {}))
201
365
  return 0
202
366
 
203
367
 
@@ -27,6 +27,40 @@ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_c
27
27
 
28
28
 
29
29
 
30
+ # --versions' named targets: integrated loudness (LUFS) and true-peak ceiling (dBTP)
31
+ VERSION_TARGETS = {
32
+ "streaming": (-14.0, -1.0), # YouTube, Spotify and most streaming services
33
+ "ebu-r128": (-23.0, -1.0), # EBU R128 broadcast
34
+ "broadcast-jp": (-24.0, -1.0), # ARIB TR-B32 (Japanese broadcast, LKFS = LUFS)
35
+ }
36
+
37
+
38
+ def parse_versions(text: str) -> "list":
39
+ """[(label, lufs, tp)] from --versions; a refusal names the item that did not parse."""
40
+ out, seen = [], set()
41
+ for item in (x.strip() for x in text.split(",")):
42
+ if not item:
43
+ continue
44
+ if item in VERSION_TARGETS:
45
+ label, (lufs, tp) = item, VERSION_TARGETS[item]
46
+ else:
47
+ try:
48
+ lufs_s, tp_s = item.split("/")
49
+ lufs, tp = float(lufs_s), float(tp_s)
50
+ except ValueError:
51
+ die(f"--versions item {item!r} is neither a named target ({', '.join(VERSION_TARGETS)}) nor LUFS/TP such as -16/-1.5")
52
+ if not (-70 <= lufs <= -5 and -9 <= tp <= 0):
53
+ die(f"--versions item {item!r}: LUFS must be -70..-5 and TP -9..0 (loudnorm's ranges)")
54
+ label = f"{lufs:g}lufs{tp:g}tp".replace("-", "m").replace(".", "_")
55
+ if label in seen:
56
+ die(f"--versions names {label!r} twice")
57
+ seen.add(label)
58
+ out.append((label, lufs, tp))
59
+ if not out:
60
+ die("--versions is empty")
61
+ return out
62
+
63
+
30
64
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
31
65
  if dry_run_input_pending(path):
32
66
  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}
@@ -47,46 +81,10 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
47
81
  return data
48
82
 
49
83
 
50
- def main() -> int:
51
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
52
- ap.add_argument("input")
53
- ap.add_argument("-o", "--output", help="output file (default: <name>_loudnorm.<ext>)")
54
- ap.add_argument("-I", "--lufs", type=float, default=-14.0, help="integrated loudness target in LUFS (default -14)")
55
- ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
56
- ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
57
- ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
58
- ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
59
- ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
60
- add_common(ap)
61
- args = ap.parse_args()
62
- apply_common(args)
63
-
64
- meta = probe(args.input)
65
- if not meta.get("audio"):
66
- die("input has no audio stream")
67
-
68
- stats = measure(args.input, args.lufs, args.tp, args.lra)
69
- if stats.get("silent"):
70
- info("audio is silent (integrated loudness -inf); nothing to normalise")
71
- if args.measure_only:
72
- if STATE.json:
73
- emit(None, measured={"silent": True, "input_i": "-inf"})
74
- else:
75
- print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
76
- return 0
77
- die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
78
- info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
79
- if args.measure_only:
80
- measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
81
- if STATE.json:
82
- emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
83
- else:
84
- print(json.dumps(measured, indent=2))
85
- return 0
86
-
87
- output = args.output or default_output(args.input, "loudnorm")
84
+ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output: str) -> dict:
85
+ """One normalised file at `lufs` / `tp_max`, measured after: the keys emit() takes for it."""
88
86
  af = (
89
- f"loudnorm=I={args.lufs}:TP={args.tp}:LRA={args.lra}"
87
+ f"loudnorm=I={lufs}:TP={tp_max}:LRA={args.lra}"
90
88
  f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
91
89
  f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
92
90
  )
@@ -106,7 +104,7 @@ def main() -> int:
106
104
  def encode(tp: float, bitrate: str) -> None:
107
105
  nonlocal dropped_streams
108
106
  af = (
109
- f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
107
+ f"loudnorm=I={lufs}:TP={tp}:LRA={args.lra}"
110
108
  f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
111
109
  f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
112
110
  )
@@ -124,12 +122,11 @@ def main() -> int:
124
122
  cmd.append(output)
125
123
  run(cmd)
126
124
 
127
- encode(args.tp, bitrate)
125
+ encode(tp_max, bitrate)
128
126
  if STATE.dry_run:
129
127
  # pass 1 measured the input for real; there is no output to measure
130
- emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
131
- return 0
132
- after = measure(output, args.lufs, args.tp, args.lra)
128
+ return {"output": output, "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}}
129
+ after = measure(output, lufs, tp_max, args.lra)
133
130
  # loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
134
131
  # its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
135
132
  # from -2.4 dBFS into +3.7 dBFS, so the file measured +1.2 dBTP after "--tp -1" and
@@ -138,43 +135,43 @@ def main() -> int:
138
135
  # loudness target is untouched) when the caller did not pin the bitrate; then a lower
139
136
  # loudnorm ceiling by the measured overshoot, which in linear mode also lowers the
140
137
  # integrated loudness -- reported, never hidden.
141
- ceiling, rounds = args.tp, 0
138
+ ceiling, rounds = tp_max, 0
142
139
  steps = [] if bitrate_pinned or ext in AUDIO_CODECS and "aac" not in AUDIO_CODECS[ext] else [b for b in ("256k", "320k") if _kbps(b) > _kbps(bitrate)]
143
- while not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB and rounds < 5:
140
+ while not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB and rounds < 5:
144
141
  rounds += 1
145
- overshoot = float(after["input_tp"]) - args.tp
142
+ overshoot = float(after["input_tp"]) - tp_max
146
143
  if steps:
147
144
  bitrate = steps.pop(0)
148
- info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
145
+ info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {tp_max:g} dBTP after encoding "
149
146
  f"(codec overshoot); re-encoding at {bitrate}")
150
147
  else:
151
148
  ceiling -= overshoot + 0.2
152
- info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
149
+ info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {tp_max:g} dBTP after encoding "
153
150
  f"(codec overshoot); re-encoding with the loudnorm ceiling at {ceiling:.2f} dBTP")
154
151
  encode(ceiling, bitrate)
155
- after = measure(output, args.lufs, args.tp, args.lra)
152
+ after = measure(output, lufs, tp_max, args.lra)
156
153
  if not after.get("silent"):
157
- info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS, TP <= {args.tp:g})")
154
+ info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {lufs} LUFS, TP <= {tp_max:g})")
158
155
  result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
159
156
  result["tp_ceiling_used"] = round(ceiling, 2)
160
157
  if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
161
158
  result["audio_bitrate_used"] = bitrate
162
159
  result["encodes"] = rounds + 1
163
- if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > LUFS_TOLERANCE:
164
- result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
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 "
165
162
  f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
166
163
  f"higher --audio-bitrate keeps both")
167
- if not after.get("silent") and float(after["input_tp"]) > args.tp + TP_TOLERANCE_DB:
168
- die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {args.tp:g}); "
164
+ if not after.get("silent") and float(after["input_tp"]) > tp_max + TP_TOLERANCE_DB:
165
+ die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {tp_max:g}); "
169
166
  f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
170
167
  kind="verification", output=output, result=result,
171
168
  hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
172
169
  verification = [{"step": "loudness",
173
- "ok": bool(after.get("silent")) or (abs(float(after["input_i"]) - args.lufs) <= LUFS_TOLERANCE and float(after["input_tp"]) <= args.tp + TP_TOLERANCE_DB),
174
- "lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs": args.lufs, "target_tp": args.tp,
175
- "property": "loudness", "expected": {"lufs": args.lufs, "tp_max": args.tp},
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},
176
173
  "measured": {"lufs": float(after["input_i"]), "tp": float(after["input_tp"])},
177
- "delta": {"lufs": round(float(after["input_i"]) - args.lufs, 2), "tp": round(float(after["input_tp"]) - args.tp, 2)},
174
+ "delta": {"lufs": round(float(after["input_i"]) - lufs, 2), "tp": round(float(after["input_tp"]) - tp_max, 2)},
178
175
  "tolerance": {"lufs": LUFS_TOLERANCE, "tp": TP_TOLERANCE_DB}, "provenance": "user_specified"}]
179
176
  written = probe(output, role="output")
180
177
  out_audio = audio_length(written)
@@ -189,9 +186,86 @@ def main() -> int:
189
186
  # a length that could not be read is not a length that matched
190
187
  verification.append({"step": "duration", "property": "audio_duration", "ok": False,
191
188
  "reason": "not_measured: the " + ("input" if not in_audio else "output") + " reports no duration"})
192
- emit(output, result=result, dropped_non_av_streams=dropped_streams,
193
- measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
194
- targets={"lufs": args.lufs, "tp": args.tp, "lra": args.lra}, verification=verification)
189
+ return {"output": output, "result": result, "dropped_non_av_streams": dropped_streams,
190
+ "measured": {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")},
191
+ "targets": {"lufs": lufs, "tp": tp_max, "lra": args.lra}, "verification": verification}
192
+
193
+
194
+ def _versions(args, meta: dict, plan: list) -> int:
195
+ """--versions: one file per target from the one master, each a whole two-pass normalisation
196
+ (pass 1 is per target: loudnorm's target_offset depends on it) measured after. `verified` is
197
+ true only when every version met its own target."""
198
+ versions, steps, dropped = [], [], False
199
+ for label, lufs, tp in plan:
200
+ out = default_output(args.input, f"loudnorm_{label}")
201
+ stats = measure(args.input, lufs, tp, args.lra)
202
+ if stats.get("silent"):
203
+ die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
204
+ doc = _normalize(args, meta, stats, lufs, tp, out)
205
+ dropped = dropped or bool(doc.get("dropped_non_av_streams"))
206
+ versions.append({"target": label, "output": out, "targets": {"lufs": lufs, "tp": tp, "lra": args.lra},
207
+ **({"result": doc["result"]} if "result" in doc else {})})
208
+ steps += [dict(st, version=label) for st in doc.get("verification") or []]
209
+ emit(None, versions=versions, outputs=[v["output"] for v in versions], dropped_non_av_streams=dropped,
210
+ **({"verification": steps} if steps else {}))
211
+ if not STATE.json and not STATE.plan:
212
+ print("\n".join(v["output"] for v in versions)) # one path per line, as a single run prints its one
213
+ return 0
214
+
215
+
216
+ def main() -> int:
217
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
218
+ ap.add_argument("input")
219
+ ap.add_argument("-o", "--output", help="output file (default: <name>_loudnorm.<ext>)")
220
+ ap.add_argument("-I", "--lufs", type=float, default=-14.0, help="integrated loudness target in LUFS (default -14)")
221
+ ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
222
+ ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
223
+ ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
224
+ ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
225
+ ap.add_argument("--versions", metavar="LIST",
226
+ help="write one normalised file per target from this master, each measured after: a comma list of "
227
+ + ", ".join(f"{n} ({i:g} LUFS / {t:g} dBTP)" for n, (i, t) in VERSION_TARGETS.items())
228
+ + ", or LUFS/TP pairs such as -16/-1.5; files are <name>_loudnorm_<target>.<ext> (not with -o)")
229
+ ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
230
+ add_common(ap)
231
+ args = ap.parse_args()
232
+ apply_common(args)
233
+ # parsed before anything is measured, and an explicitly empty --versions ('' or ',') is refused
234
+ # by parse_versions rather than read as "not given", which wrote one default file instead
235
+ plan = parse_versions(args.versions) if args.versions is not None else None
236
+ if plan is not None and args.output:
237
+ die("--versions writes one file per target, named <name>_loudnorm_<target>.<ext>; it does not take -o")
238
+
239
+ meta = probe(args.input)
240
+ if not meta.get("audio"):
241
+ die("input has no audio stream")
242
+ if plan is not None and not args.measure_only:
243
+ # each version measures the master against its own target (loudnorm's offset depends on
244
+ # it): a pass against -I/--tp first would be one more full decode whose result is unused
245
+ return _versions(args, meta, plan)
246
+
247
+ stats = measure(args.input, args.lufs, args.tp, args.lra)
248
+ if stats.get("silent"):
249
+ info("audio is silent (integrated loudness -inf); nothing to normalise")
250
+ if args.measure_only:
251
+ if STATE.json:
252
+ emit(None, measured={"silent": True, "input_i": "-inf"})
253
+ else:
254
+ print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
255
+ return 0
256
+ die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
257
+ info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
258
+ if args.measure_only:
259
+ measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
260
+ if STATE.json:
261
+ emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
262
+ else:
263
+ print(json.dumps(measured, indent=2))
264
+ return 0
265
+
266
+ output = args.output or default_output(args.input, "loudnorm")
267
+ doc = _normalize(args, meta, stats, args.lufs, args.tp, output)
268
+ emit(doc.pop("output"), **doc)
195
269
  return 0
196
270
 
197
271