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/README.md +4 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +12 -2
- package/package.json +1 -1
- package/references/scripts.md +51 -7
- package/scripts/_common/emit.py +22 -0
- package/scripts/_contract.py +16 -7
- package/scripts/export.py +300 -10
- package/scripts/look.py +60 -5
- package/scripts/loop.py +182 -18
- package/scripts/loudness.py +136 -62
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.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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",
|
|
152
|
-
if target <=
|
|
153
|
-
die(f"--duration ({target:g}s) must be longer than
|
|
154
|
-
stream_loop = math.ceil(target /
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|
|
51
|
-
|
|
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={
|
|
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={
|
|
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(
|
|
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
|
-
|
|
131
|
-
|
|
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 =
|
|
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"]) >
|
|
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"]) -
|
|
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 {
|
|
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 {
|
|
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,
|
|
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 {
|
|
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"]) -
|
|
164
|
-
result["note"] = (f"integrated loudness is {float(after['input_i']) -
|
|
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"]) >
|
|
168
|
-
die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {
|
|
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"]) -
|
|
174
|
-
"lufs": float(after["input_i"]), "tp": float(after["input_tp"]), "target_lufs":
|
|
175
|
-
"property": "loudness", "expected": {"lufs":
|
|
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"]) -
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|