ffmpeg-skill 3.8.12 → 3.8.13
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 +1 -1
- package/docs/contract.md +5 -4
- package/package.json +1 -1
- package/references/scripts.md +54 -20
- package/scripts/_common/decision.py +27 -5
- package/scripts/_common/emit.py +38 -10
- package/scripts/_common/probe.py +34 -6
- package/scripts/_common/runner.py +50 -4
- package/scripts/_contract.py +2 -2
- package/scripts/_smartcut.py +23 -5
- package/scripts/audio.py +86 -28
- package/scripts/check.py +2 -2
- package/scripts/cut.py +49 -16
- package/scripts/export.py +3 -3
- package/scripts/loop.py +2 -2
- package/scripts/loudness.py +20 -7
- package/scripts/multicam.py +55 -14
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sync.py +10 -3
- package/scripts/waveform.py +12 -2
package/scripts/audio.py
CHANGED
|
@@ -29,8 +29,8 @@ from typing import Any, Dict, List
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import SILENT_PEAK_DBFS
|
|
31
31
|
from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
|
|
32
|
-
from _common.emit import warn
|
|
33
|
-
from _common.decision import conform_filter, conform_to_length
|
|
32
|
+
from _common.emit import file_origin, stream_end, warn
|
|
33
|
+
from _common.decision import LOSSLESS_AUDIO_EXT, conform_filter, conform_to_length
|
|
34
34
|
|
|
35
35
|
# --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
|
|
36
36
|
DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
|
|
@@ -43,10 +43,11 @@ VOICE_CHAIN = f"highpass=f=80,deesser=i=0.4,{VOICE_DENOISE},acompressor=threshol
|
|
|
43
43
|
# which is what a good room recording needs; "strong" is for phone/laptop audio: a harder
|
|
44
44
|
# de-esser, a second compression stage and a soft limiter at -1 dBFS so the peaks stop there
|
|
45
45
|
# instead of at whatever the make-up gain produced.
|
|
46
|
+
VOICE_LIMITER = "alimiter=limit=0.891251:level=disabled:latency=1" # --voice strong's -1 dBFS ceiling
|
|
46
47
|
VOICE_LEVELS = {
|
|
47
48
|
"light": "highpass=f=80,acompressor=threshold=-18dB:ratio=2:attack=5:release=80:makeup=1",
|
|
48
49
|
"medium": VOICE_CHAIN,
|
|
49
|
-
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3,
|
|
50
|
+
"strong": VOICE_CHAIN + ",deesser=i=0.6,acompressor=threshold=-24dB:ratio=4:attack=5:release=120:makeup=3," + VOICE_LIMITER,
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
|
|
@@ -66,11 +67,40 @@ def voice_chain(level: str, denoise_strength: "float | None") -> str:
|
|
|
66
67
|
return chain.replace("highpass=f=80,", f"highpass=f=80,{stage},", 1)
|
|
67
68
|
|
|
68
69
|
|
|
69
|
-
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05
|
|
70
|
-
#
|
|
71
|
-
# default command line is unchanged to the byte while the value is now sayable.
|
|
70
|
+
# The sidechain threshold the music bed has used since 1.4 is the linear 0.05 the filter takes;
|
|
71
|
+
# -26.0206 dBFS is that same number in the unit the flag speaks.
|
|
72
72
|
DUCK_THRESHOLD_DB = -26.0206
|
|
73
73
|
|
|
74
|
+
# the knee the filters take is a linear 1..8 (default 2.82843): in the dB the flag speaks that is
|
|
75
|
+
# 0..18.06 dB, default 9.03 dB. The flag used to accept 1..8 *dB* (linear 1.12..2.51), so no knee
|
|
76
|
+
# from 2.51 up to ffmpeg's own default could be asked for, and the help gave the default as 2.83 dB
|
|
77
|
+
KNEE_MAX_DB = 18.06 # 20*log10(8) = 18.0618, rounded down so the linear value stays inside 1..8
|
|
78
|
+
|
|
79
|
+
# --duck (A134): the bed is lowered by exactly --duck-amount dB while the speech is above the
|
|
80
|
+
# threshold. A sidechain gate keyed by the speech passes the bed where there is speech and nothing
|
|
81
|
+
# elsewhere; that part, scaled by -(1 - 10^(-amount/20)), is added back to the bed, which leaves
|
|
82
|
+
# 10^(-amount/20) of it under speech and all of it in the pauses. The sidechain compressor used
|
|
83
|
+
# through 3.8.8 took the amount as ratio = max(2, amount/3), and a compressor's depth is how far
|
|
84
|
+
# the speech rises over its threshold: on the test's speech 0 and 3 ducked the same 9.7 dB, 60 ducked 18.
|
|
85
|
+
# The key gets a DC offset far below any threshold (-120 dBFS) because agate's detector leaves the
|
|
86
|
+
# gate open on a key that is exact zeros from its first sample. The knee is hard (knee=1): agate's
|
|
87
|
+
# default soft knee (2.82843) opened the gate only about 2 dB over the threshold, so a key just
|
|
88
|
+
# over it left the bed at its level (measured: a sine 1.5 dB over -7.5 dBFS ducked 0 dB of 12).
|
|
89
|
+
DUCK_GATE_RATIO = 9000.0 # agate's largest: closed below the threshold
|
|
90
|
+
DUCK_KEY_DC = "dcshift=shift=0.000001"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def duck_graph(threshold_db: float, amount_db: float, attack: float, release: float) -> List[str]:
|
|
94
|
+
"""The filter-graph lines that turn `[music]` and the speech key `[sc]` into `[ducked]`."""
|
|
95
|
+
cut = 1.0 - 10 ** (-amount_db / 20.0)
|
|
96
|
+
return ["[music]asplit=2[mdry][mwet]",
|
|
97
|
+
f"[sc]{DUCK_KEY_DC}[key]",
|
|
98
|
+
f"[mwet][key]sidechaingate=threshold={db_to_linear(threshold_db):.6g}:ratio={DUCK_GATE_RATIO:g}:range=0:knee=1"
|
|
99
|
+
f":attack={attack:g}:release={release:g}[mspeech]",
|
|
100
|
+
f"[mspeech]volume={-cut:.6g}[mcut]",
|
|
101
|
+
"[mdry][mcut]amix=inputs=2:normalize=0[ducked]"]
|
|
102
|
+
|
|
103
|
+
|
|
74
104
|
# Typed dynamics: every flag maps to one real option of one ffmpeg filter, validated against the
|
|
75
105
|
# range that filter documents (ffmpeg -h filter=acompressor / alimiter / agate). dB flags are
|
|
76
106
|
# converted to the linear value the filter takes, so no string reaches the graph unchecked.
|
|
@@ -81,7 +111,7 @@ DYNAMICS = {
|
|
|
81
111
|
"comp_attack": ("attack", "ms", 0.01, 2000.0),
|
|
82
112
|
"comp_release": ("release", "ms", 0.01, 9000.0),
|
|
83
113
|
"comp_makeup": ("makeup", "dB", 0.0, 36.0), # 1..64 linear
|
|
84
|
-
"comp_knee": ("knee", "dB",
|
|
114
|
+
"comp_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear (A135: was 1..8 dB, linear 1.12..2.51)
|
|
85
115
|
},
|
|
86
116
|
"alimiter": {
|
|
87
117
|
"limit_ceiling": ("limit", "dB", -24.0, 0.0), # 0.0625..1 linear
|
|
@@ -94,7 +124,7 @@ DYNAMICS = {
|
|
|
94
124
|
"gate_attack": ("attack", "ms", 0.01, 9000.0),
|
|
95
125
|
"gate_release": ("release", "ms", 0.01, 9000.0),
|
|
96
126
|
"gate_range": ("range", "dB", -90.0, 0.0), # 0..1 linear: how far the gate closes
|
|
97
|
-
"gate_knee": ("knee", "dB",
|
|
127
|
+
"gate_knee": ("knee", "dB", 0.0, KNEE_MAX_DB), # 1..8 linear
|
|
98
128
|
},
|
|
99
129
|
}
|
|
100
130
|
|
|
@@ -289,8 +319,8 @@ def main() -> int:
|
|
|
289
319
|
music = ap.add_argument_group("music")
|
|
290
320
|
music.add_argument("--music", help="music file to mix underneath")
|
|
291
321
|
music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
|
|
292
|
-
music.add_argument("--duck", action="store_true", help="auto-duck the music
|
|
293
|
-
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB
|
|
322
|
+
music.add_argument("--duck", action="store_true", help="auto-duck the music by --duck-amount dB while the main track has speech (a sidechain gate keyed by it)")
|
|
323
|
+
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB the music is lowered under speech, 0..60 (default 12)")
|
|
294
324
|
music.add_argument("--duck-threshold", type=float, default=DUCK_THRESHOLD_DB,
|
|
295
325
|
help="sidechain threshold in dBFS: the main track is heard as speech above this (default -26.02, the 0.05 linear used since 1.4)")
|
|
296
326
|
music.add_argument("--duck-attack", type=float, default=20.0, help="ms the bed takes to duck once speech starts (default 20)")
|
|
@@ -326,25 +356,27 @@ def main() -> int:
|
|
|
326
356
|
"(an audio output only: beside a picture the sound is conformed to the picture)")
|
|
327
357
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
328
358
|
dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
|
|
329
|
-
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -
|
|
359
|
+
dyn.add_argument("--comp-threshold", type=float, help="dBFS above which gain is reduced, -60..0 (ffmpeg default -18.06, its linear 0.125)")
|
|
330
360
|
dyn.add_argument("--comp-ratio", type=float, help="ratio 1..20 (default 2)")
|
|
331
361
|
dyn.add_argument("--comp-attack", type=float, help="ms 0.01..2000 (default 20)")
|
|
332
362
|
dyn.add_argument("--comp-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
333
363
|
dyn.add_argument("--comp-makeup", type=float, help="make-up gain dB 0..36 (default 0)")
|
|
334
|
-
dyn.add_argument("--comp-knee", type=float, help="knee dB 1..8
|
|
364
|
+
dyn.add_argument("--comp-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
|
|
335
365
|
dyn.add_argument("--limit", action="store_true", help="look-ahead limiter (alimiter), level left as is")
|
|
336
366
|
dyn.add_argument("--limit-ceiling", type=float, help="ceiling dBFS -24..0 (default 0)")
|
|
337
367
|
dyn.add_argument("--limit-attack", type=float, help="ms 0.1..80 (default 5)")
|
|
338
368
|
dyn.add_argument("--limit-release", type=float, help="ms 1..8000 (default 50)")
|
|
339
369
|
dyn.add_argument("--gate", action="store_true", help="noise gate (agate)")
|
|
340
|
-
dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.
|
|
370
|
+
dyn.add_argument("--gate-threshold", type=float, help="dBFS below which the gate closes, -60..0 (default -18.06, ffmpeg's linear 0.125)")
|
|
341
371
|
dyn.add_argument("--gate-ratio", type=float, help="ratio 1..9000 (default 2)")
|
|
342
372
|
dyn.add_argument("--gate-attack", type=float, help="ms 0.01..9000 (default 20)")
|
|
343
373
|
dyn.add_argument("--gate-release", type=float, help="ms 0.01..9000 (default 250)")
|
|
344
|
-
dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -
|
|
345
|
-
dyn.add_argument("--gate-knee", type=float, help="knee dB 1..8
|
|
374
|
+
dyn.add_argument("--gate-range", type=float, help="attenuation when closed, dB -90..0 (default -24.26, ffmpeg's linear 0.06125)")
|
|
375
|
+
dyn.add_argument("--gate-knee", type=float, help="knee width dB 0..18.06 (ffmpeg's linear 1..8; default 9.03, its 2.82843)")
|
|
346
376
|
ap.add_argument("--audio-stream", type=int, default=0, help="which audio stream of the input to process, 0-based in file order (probe lists them under audio_streams)")
|
|
347
|
-
ap.add_argument("--bitrate", default=
|
|
377
|
+
ap.add_argument("--bitrate", default=None,
|
|
378
|
+
help="audio bitrate: AAC's beside a picture (default 192k), and the codec's of a lossy audio output "
|
|
379
|
+
"(.m4a/.aac default 256k, .mp3 -q:a 0, .ogg -q:a 6, .opus 128k); refused for .wav/.flac")
|
|
348
380
|
ap.add_argument("--on-silent", choices=["warn", "fail"], default="warn",
|
|
349
381
|
help="a --music/--replace/--effects file (or, under --duck, the voice) whose peak is at or below "
|
|
350
382
|
"--silence-threshold: warn (default: mix it, report it under `silent`) or fail (refuse before ffmpeg runs)")
|
|
@@ -391,7 +423,7 @@ def main() -> int:
|
|
|
391
423
|
("--duck-attack", args.duck_attack, 0.01, 2000.0),
|
|
392
424
|
("--duck-release", args.duck_release, 0.01, 9000.0)):
|
|
393
425
|
if not (lo <= value <= hi):
|
|
394
|
-
die(f"{flag} {value:g} is outside {lo:g}..{hi:g}
|
|
426
|
+
die(f"{flag} {value:g} is outside {lo:g}..{hi:g}")
|
|
395
427
|
if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
|
|
396
428
|
die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
|
|
397
429
|
|
|
@@ -421,6 +453,9 @@ def main() -> int:
|
|
|
421
453
|
die("input has no audio stream (use --replace to add one)")
|
|
422
454
|
output = args.output or default_output(args.input, "audio")
|
|
423
455
|
audio_out = is_audio_output(output)
|
|
456
|
+
if args.bitrate is not None and Path(output).suffix.lower() in LOSSLESS_AUDIO_EXT:
|
|
457
|
+
# A143: the rate used to be dropped without a word for every audio extension
|
|
458
|
+
die(f"--bitrate has no effect on a lossless {Path(output).suffix.lower()} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
|
|
424
459
|
streams = meta.get("audio_streams") or []
|
|
425
460
|
if streams and not (0 <= args.audio_stream < len(streams)) and not STATE.dry_run:
|
|
426
461
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
@@ -486,8 +521,17 @@ def main() -> int:
|
|
|
486
521
|
# 7.1), so in-phase channels at full scale cannot clip; '=' summed 2.7x and clipped a loud
|
|
487
522
|
# 5.1 while the run said verified.
|
|
488
523
|
fx.append(DOWNMIX_PAN)
|
|
524
|
+
# A129: a limiter is the ceiling of what is delivered, so with a bed mixed in it runs after the
|
|
525
|
+
# mix: on the voice alone, the music and effects added on top of it peaked over the ceiling
|
|
526
|
+
# (--limit-ceiling -6 delivered 0 dBFS, --voice strong's -1 dBFS -0.2)
|
|
527
|
+
limit_after_mix = bool(args.music or args.effects)
|
|
528
|
+
post_limit: List[str] = []
|
|
489
529
|
if args.voice:
|
|
490
|
-
|
|
530
|
+
chain = voice_chain(args.voice, args.denoise_strength if args.denoise else None)
|
|
531
|
+
if limit_after_mix and chain.endswith("," + VOICE_LIMITER):
|
|
532
|
+
chain = chain[: -len("," + VOICE_LIMITER)]
|
|
533
|
+
post_limit.append(VOICE_LIMITER)
|
|
534
|
+
fx.append(chain)
|
|
491
535
|
elif args.denoise:
|
|
492
536
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
493
537
|
if args.gain:
|
|
@@ -497,7 +541,7 @@ def main() -> int:
|
|
|
497
541
|
if args.compress:
|
|
498
542
|
fx.append(dynamics_filter("acompressor", args))
|
|
499
543
|
if args.limit:
|
|
500
|
-
fx.append(dynamics_filter("alimiter", args))
|
|
544
|
+
(post_limit if limit_after_mix else fx).append(dynamics_filter("alimiter", args))
|
|
501
545
|
if args.mono:
|
|
502
546
|
# after --downmix the track is the stereo fold-down, whatever the source had
|
|
503
547
|
in_ch = 2 if args.downmix else (in_channels or 2)
|
|
@@ -542,15 +586,28 @@ def main() -> int:
|
|
|
542
586
|
m = f"{idx}:a:0"
|
|
543
587
|
idx += 1
|
|
544
588
|
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{main_dur:.6f}" if main_dur else "anull"]
|
|
545
|
-
|
|
546
|
-
|
|
589
|
+
# A142: a bed shorter than the output (and not looped) stops at its own end, so its fade
|
|
590
|
+
# ends there: timed on the output's end it started after the bed had stopped at full level
|
|
591
|
+
# ... and at the main track's end when the output runs past it (--length-samples): the
|
|
592
|
+
# atrim above has cut the bed there. All three are timestamps, which is what afade reads:
|
|
593
|
+
# a bed whose sound starts late in its file (2 to 5 s) keeps those, so its fade ends at 5,
|
|
594
|
+
# not at its 3 s length (that fade ran over the bed's first second)
|
|
595
|
+
ends = [t for t in (end_dur, main_dur) if t]
|
|
596
|
+
if args.music_fade_out and not args.music_loop and not dry_run_input_pending(args.music):
|
|
597
|
+
bed_meta = probe(args.music)
|
|
598
|
+
bed_stop = stream_end(bed_meta, "audio")
|
|
599
|
+
if bed_stop is not None and bed_stop - file_origin(bed_meta) > 0:
|
|
600
|
+
ends.append(bed_stop - file_origin(bed_meta))
|
|
601
|
+
bed_end = min(ends) if ends else None
|
|
602
|
+
if args.music_fade_out and bed_end:
|
|
603
|
+
# a fade longer than the bed is cut to it, so the bed still ends silent (a 5 s fade on
|
|
604
|
+
# a 2 s bed started at 0 and stopped at 60% gain)
|
|
605
|
+
m_fade = min(args.music_fade_out, bed_end)
|
|
606
|
+
mfx.append(f"afade=t=out:st={bed_end - m_fade:.3f}:d={m_fade:g}")
|
|
547
607
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
548
608
|
if args.duck:
|
|
549
609
|
graph.append("[main]asplit=2[mainA][sc]")
|
|
550
|
-
graph.
|
|
551
|
-
f"[music][sc]sidechaincompress=threshold={db_to_linear(args.duck_threshold):.6g}"
|
|
552
|
-
f":ratio={max(2.0, args.duck_amount / 3):.1f}:attack={args.duck_attack:g}:release={args.duck_release:g}:makeup=1[ducked]"
|
|
553
|
-
)
|
|
610
|
+
graph += duck_graph(args.duck_threshold, args.duck_amount, args.duck_attack, args.duck_release)
|
|
554
611
|
graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
555
612
|
else:
|
|
556
613
|
graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
@@ -567,11 +624,12 @@ def main() -> int:
|
|
|
567
624
|
graph.append(f"[{last}][effects]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mixfx]")
|
|
568
625
|
last = "mixfx"
|
|
569
626
|
|
|
570
|
-
post: List[str] =
|
|
627
|
+
post: List[str] = list(post_limit)
|
|
571
628
|
if args.fade_in:
|
|
572
629
|
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
573
630
|
if args.fade_out and end_dur:
|
|
574
|
-
|
|
631
|
+
fade_out = min(args.fade_out, end_dur) # cut to the output, so it still ends silent
|
|
632
|
+
post.append(f"afade=t=out:st={end_dur - fade_out:.3f}:d={fade_out:g}")
|
|
575
633
|
# The audio track is conformed to the source duration whenever it is known: padded with
|
|
576
634
|
# silence if the graph came out short, trimmed if long. A mixed track can come out a few
|
|
577
635
|
# hundredths short (amix's dropout_transition, a looped bed's atrim boundary), and with
|
|
@@ -628,7 +686,7 @@ def main() -> int:
|
|
|
628
686
|
audio_block["music_volume"] = args.music_volume
|
|
629
687
|
audio_block["duck"] = ({"amount_db": args.duck_amount, "threshold_db": round(args.duck_threshold, 4),
|
|
630
688
|
"threshold_linear": float(f"{db_to_linear(args.duck_threshold):.6g}"),
|
|
631
|
-
"ratio":
|
|
689
|
+
"ratio": DUCK_GATE_RATIO,
|
|
632
690
|
"attack_ms": args.duck_attack, "release_ms": args.duck_release}
|
|
633
691
|
if args.duck else None)
|
|
634
692
|
verification: List[Dict[str, Any]] = []
|
package/scripts/check.py
CHANGED
|
@@ -50,7 +50,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
50
50
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, TP_TOLERANCE_DB, spec_of, upload_limit_note, resolve as resolve_platform
|
|
51
51
|
from _common.probe import (CHANGE_FLOOR, CHANGE_GRID, change_mask_tiles, change_tiles, LEGAL_RANGE_CHOICES, measure_signal_range, COMPARE_SSIM_WARN, CONTENT_FLAT_STD, SILENT_PEAK_DBFS, compare_pictures, detect_content,
|
|
52
52
|
detect_flashes, first_frame_stats, presentation_times, measured_level_dbfs)
|
|
53
|
-
from _common.emit import file_identity
|
|
53
|
+
from _common.emit import file_identity, own_length
|
|
54
54
|
from _common.probe import PROBED_AUDIO
|
|
55
55
|
from _common.probe import display_size as shown_size, shorter_than_a_loudness_block
|
|
56
56
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
@@ -618,7 +618,7 @@ def main() -> int:
|
|
|
618
618
|
row("true peak", "WARN", "could not measure", f"<= {spec['tp']:g} dBTP", "re-run check.py, or verify true peak manually",
|
|
619
619
|
reason="ffmpeg's loudness measurement didn't produce a readable result -- this was not actually checked")
|
|
620
620
|
elif (lm and lm["lufs"] == float("-inf") and lm["tp"] > SILENT_PEAK_DBFS
|
|
621
|
-
and shorter_than_a_loudness_block(
|
|
621
|
+
and shorter_than_a_loudness_block(own_length(a) or dur_read)):
|
|
622
622
|
# A257: shorter than one 400 ms gating block: a true peak but no integrated
|
|
623
623
|
# loudness. Not silence, and not a -inf LUFS that FAILs the target. Decided on
|
|
624
624
|
# the sound's length: a longer track whose every block is under the gate reads
|
package/scripts/cut.py
CHANGED
|
@@ -56,7 +56,7 @@ import os
|
|
|
56
56
|
import sys
|
|
57
57
|
import tempfile
|
|
58
58
|
import zlib
|
|
59
|
-
from typing import List, Tuple
|
|
59
|
+
from typing import List, Optional, Tuple
|
|
60
60
|
|
|
61
61
|
from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
@@ -81,12 +81,27 @@ def _t(value: str, fps, flag: str = "--segments") -> float:
|
|
|
81
81
|
return time_arg(value, flag, fps)
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None
|
|
84
|
+
def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float, fps=None,
|
|
85
|
+
picture: Optional[Tuple[float, float]] = None,
|
|
86
|
+
dropped: Optional[List[Tuple[float, float]]] = None) -> List[Tuple[float, float]]:
|
|
85
87
|
"""--remove: what is left of [0, total] once `removed` is dropped (merged where they overlap,
|
|
86
88
|
clamped to the file). A leftover shorter than one frame (10 ms without a frame rate) is not a
|
|
87
|
-
piece anyone asked to keep.
|
|
89
|
+
piece anyone asked to keep. With `picture` (where the video starts and ends, for a video
|
|
90
|
+
output), neither is a leftover that holds less than one frame of it: sound before the first
|
|
91
|
+
frame or after the last (a track that outlasts its picture) has no picture to cut, and was
|
|
92
|
+
kept and then refused as a segment the caller never gave (#462 A51). Such leftovers are added
|
|
93
|
+
to `dropped`. Refuses a removal that would leave nothing."""
|
|
88
94
|
least = 1.0 / fps if fps else 0.01
|
|
89
95
|
keep, cursor = [], 0.0
|
|
96
|
+
|
|
97
|
+
def add(a: float, b: float) -> None:
|
|
98
|
+
if b - a < least:
|
|
99
|
+
return
|
|
100
|
+
if picture is not None and min(b, picture[1]) - max(a, picture[0]) < least - 1e-6:
|
|
101
|
+
if dropped is not None:
|
|
102
|
+
dropped.append((a, b))
|
|
103
|
+
return
|
|
104
|
+
keep.append((a, b))
|
|
90
105
|
outside = [(s, e) for s, e in removed if min(total, e) <= min(total, max(0.0, s))]
|
|
91
106
|
if outside:
|
|
92
107
|
die("--remove " + ", ".join(f"{s:g}-{e:g}" for s, e in outside) + f" lies outside the file (0-{total:.3f}s), "
|
|
@@ -94,12 +109,14 @@ def keep_ranges_after_removing(removed: List[Tuple[float, float]], total: float,
|
|
|
94
109
|
for s, e in sorted((min(total, max(0.0, s)), min(total, e)) for s, e in removed):
|
|
95
110
|
if e <= cursor:
|
|
96
111
|
continue
|
|
97
|
-
|
|
98
|
-
keep.append((cursor, s))
|
|
112
|
+
add(cursor, s)
|
|
99
113
|
cursor = max(cursor, e)
|
|
100
|
-
|
|
101
|
-
keep.append((cursor, total))
|
|
114
|
+
add(cursor, total)
|
|
102
115
|
if not keep:
|
|
116
|
+
if dropped:
|
|
117
|
+
die(f"--remove leaves only sound with no picture ({', '.join(f'{a:.3f}-{b:.3f}s' for a, b in dropped)}, "
|
|
118
|
+
f"outside the video's {picture[0]:.3f}-{picture[1]:.3f}s); nothing with a picture would be left to keep. "
|
|
119
|
+
"Write the sound out with an audio -o (e.g. -o part.wav) instead", kind="input")
|
|
103
120
|
die(f"--remove drops the whole file ({total:.3f}s); nothing would be left to keep")
|
|
104
121
|
return keep
|
|
105
122
|
|
|
@@ -277,11 +294,12 @@ def source_skew(src: str, meta: dict, t: float) -> float:
|
|
|
277
294
|
|
|
278
295
|
def video_end(src: str, meta: dict):
|
|
279
296
|
"""Where the video stream ends on the cut's clock (seconds from the file's start), or None when
|
|
280
|
-
the stream's length is unknown.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
the stream's length is unknown. A Matroska/WebM stream states no duration, only its DURATION
|
|
298
|
+
tag, which is already that end (own_end()); read from `duration` alone, a .mkv had no picture
|
|
299
|
+
end and --remove kept a sound-only leftover past it."""
|
|
300
|
+
from _common.emit import own_end
|
|
301
|
+
end = own_end(meta.get("video"))
|
|
302
|
+
return None if end is None else end - file_origin(src)
|
|
285
303
|
|
|
286
304
|
|
|
287
305
|
_SEEK_SHIFTS: dict = {}
|
|
@@ -893,9 +911,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
893
911
|
if is_audio_output(dst) or not meta.get("video"):
|
|
894
912
|
# -ss before -i seeks, then decoding discards samples up to the exact start
|
|
895
913
|
# (accurate_seek); atrim bounds the decoded stream to the requested length at sample
|
|
896
|
-
# resolution.
|
|
914
|
+
# resolution. The input seek counts from the file's start, so a sound that starts after
|
|
915
|
+
# it (late against its picture) arrives at its offset from `start`; it is padded with
|
|
916
|
+
# silence from 0, as the --segments re-cut pads it (av_trim), not rebased to its own
|
|
917
|
+
# first sample, which moved it earlier and made the cut shorter than asked (#462 A46)
|
|
897
918
|
cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"] + stream_maps(meta, dst, copy=False)
|
|
898
|
-
cmd += ["-af", f"atrim=end={dur:.6f},
|
|
919
|
+
cmd += ["-af", f"atrim=end={dur:.6f},aresample=async=1:first_pts=0"]
|
|
899
920
|
else:
|
|
900
921
|
# Seek SEEK_MARGIN early, then drop the margin on the output side, which decodes and
|
|
901
922
|
# discards it from both streams alike. An input seek straight to `start` can land (the
|
|
@@ -1515,6 +1536,7 @@ def main() -> int:
|
|
|
1515
1536
|
forced.append("codec")
|
|
1516
1537
|
|
|
1517
1538
|
fps = (meta.get("video") or {}).get("fps")
|
|
1539
|
+
remove_notes: List[str] = []
|
|
1518
1540
|
if args.remove:
|
|
1519
1541
|
if args.segments or args.end or args.duration or args.start is not None:
|
|
1520
1542
|
die("--remove names what to drop from the whole file: it does not combine with --segments, --start, --end or --duration")
|
|
@@ -1525,7 +1547,18 @@ def main() -> int:
|
|
|
1525
1547
|
if not total:
|
|
1526
1548
|
die(f"--remove needs the input's duration, which {args.input} does not report"
|
|
1527
1549
|
+ (" yet (it is an earlier step's output)" if dry_run_input_pending(args.input) else ""))
|
|
1528
|
-
|
|
1550
|
+
picture = None
|
|
1551
|
+
if (meta.get("video") or {}).get("fps") and not is_audio_output(args.output or "") and not dry_run_input_pending(args.input):
|
|
1552
|
+
vend = video_end(args.input, meta)
|
|
1553
|
+
if vend is not None:
|
|
1554
|
+
picture = ((meta["video"].get("start_time") or 0.0) - file_origin(args.input), vend)
|
|
1555
|
+
sound_only: List[Tuple[float, float]] = []
|
|
1556
|
+
args.segments = ",".join(f"{s:.6f}-{e:.6f}" for s, e in keep_ranges_after_removing(
|
|
1557
|
+
parse_segments(args.remove, fps, clamp=True), total, fps, picture, sound_only))
|
|
1558
|
+
for a, b in sound_only:
|
|
1559
|
+
remove_notes.append(f"--remove left {a:.3f}-{b:.3f}s, which holds sound but no frame of the picture "
|
|
1560
|
+
f"(the video runs {picture[0]:.3f}-{picture[1]:.3f}s); it is not kept")
|
|
1561
|
+
info(remove_notes[-1])
|
|
1529
1562
|
elif args.segments and (args.end or args.duration or args.start is not None):
|
|
1530
1563
|
# A50: the single-range flags were accepted beside --segments and not read, where --remove
|
|
1531
1564
|
# refuses the same combination
|
|
@@ -1588,7 +1621,7 @@ def main() -> int:
|
|
|
1588
1621
|
# clean copy). clip_length's rule, as join.py uses it: less than a frame past -> end the
|
|
1589
1622
|
# segment with its picture; more -> hold the last frame for the sound, which only a re-cut
|
|
1590
1623
|
# can do. The last segment is left as the source had it: nothing is placed after it.
|
|
1591
|
-
join_notes: List[str] =
|
|
1624
|
+
join_notes: List[str] = list(remove_notes)
|
|
1592
1625
|
hold_needed = False
|
|
1593
1626
|
vend = (video_end(args.input, meta) if len(segments) > 1 and video.get("fps") and not is_audio_output(output)
|
|
1594
1627
|
and not dry_run_input_pending(args.input) else None)
|
package/scripts/export.py
CHANGED
|
@@ -52,7 +52,7 @@ from _common.decision import hw_preset_video, restate_last_swap
|
|
|
52
52
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
53
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
|
-
from _common.emit import file_identity, input_length
|
|
55
|
+
from _common.emit import file_identity, input_length, own_length
|
|
56
56
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
57
57
|
from _common.probe import SILENT_PEAK_DBFS, shorter_than_a_loudness_block
|
|
58
58
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, UPLOAD_LIMITS_AS_OF, resolve as resolve_platform
|
|
@@ -254,7 +254,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
254
254
|
pair = {k: [fv.get(k), kv.get(k)] for k in ("width", "height", "nb_frames", "r_frame_rate")}
|
|
255
255
|
# the video streams' lengths: the fill's container also counts its sound, which may run longer
|
|
256
256
|
# than the picture, and the key has none
|
|
257
|
-
pair["duration"] = [round(
|
|
257
|
+
pair["duration"] = [round(own_length(v) or 0, 3) for v in (fv, kv)]
|
|
258
258
|
same = all(a == b and a not in (None, 0) for a, b in pair.values())
|
|
259
259
|
# the two written files against each other: no expected value, so no provenance (#403: it said
|
|
260
260
|
# "output_measurement", which is not one of the contract's values)
|
|
@@ -741,7 +741,7 @@ def main() -> int:
|
|
|
741
741
|
m = measure_loudness(output)
|
|
742
742
|
written_audio = probe(output, role="output").get("audio") or {}
|
|
743
743
|
if (m and not math.isfinite(m["lufs"]) and math.isfinite(m["tp"]) and m["tp"] > SILENT_PEAK_DBFS
|
|
744
|
-
and shorter_than_a_loudness_block(
|
|
744
|
+
and shorter_than_a_loudness_block(own_length(written_audio))):
|
|
745
745
|
# A257: too short for a gating block is not silent (it has a measured peak); a longer
|
|
746
746
|
# track under the gate is not "too short" and takes the path it took before
|
|
747
747
|
_loudness_unmeasured(extra, notes, platform, spec, tp=m["tp"])
|
package/scripts/loop.py
CHANGED
|
@@ -36,7 +36,7 @@ from _common import STATE, concat_list_line, add_common, aac_args, apply_common,
|
|
|
36
36
|
from _common.emit import warn
|
|
37
37
|
from _common.decision import HELD_FRAMES_WARN_BYTES, av_trim, held_frames_bytes, length_output_t, stream_starts
|
|
38
38
|
from _common.probe import decode_gray_frames, decode_pcm_mono
|
|
39
|
-
from _common.emit import input_length
|
|
39
|
+
from _common.emit import input_length, own_length
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
# The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
|
|
@@ -131,7 +131,7 @@ def _conformed_frames(v, rate: float, src_dur: float) -> int:
|
|
|
131
131
|
its length are counted in these, or a repeat is a frame or more off the length planned."""
|
|
132
132
|
# the video stream's length, else Matroska's tag, else the container's (main() refuses a source
|
|
133
133
|
# without one): never the stored frame count, which is what fps= does not keep
|
|
134
|
-
d =
|
|
134
|
+
d = own_length(v) or src_dur
|
|
135
135
|
return int(round(float(d) * rate))
|
|
136
136
|
|
|
137
137
|
|
package/scripts/loudness.py
CHANGED
|
@@ -24,8 +24,9 @@ from _platforms import TP_TOLERANCE_DB
|
|
|
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
26
|
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
|
-
from _common.emit import refuse
|
|
27
|
+
from _common.emit import own_length, refuse
|
|
28
28
|
from _common.runner import ffmpeg_failure_extra
|
|
29
|
+
from _common.decision import LOSSLESS_AUDIO_EXT
|
|
29
30
|
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
|
|
30
31
|
|
|
31
32
|
|
|
@@ -121,7 +122,12 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
121
122
|
sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
|
|
122
123
|
ext = os.path.splitext(output)[1].lower()
|
|
123
124
|
bitrate_pinned = args.audio_bitrate is not None
|
|
124
|
-
|
|
125
|
+
# an AAC audio extension starts at its own rate (256k for .m4a/.aac), which is what it was
|
|
126
|
+
# written at: a pinned --audio-bitrate and the raised retries below never reached it, so the
|
|
127
|
+
# retries re-encoded at 256k and audio_bitrate_used said 192k, then 256k and 320k (A143)
|
|
128
|
+
codec = AUDIO_CODECS.get(ext, [])
|
|
129
|
+
own_rate = codec[codec.index("-b:a") + 1] if "aac" in codec and "-b:a" in codec else None
|
|
130
|
+
bitrate = args.audio_bitrate or own_rate or "192k"
|
|
125
131
|
|
|
126
132
|
dropped_streams = False
|
|
127
133
|
# loudnorm emits its last block whole, and through the AAC encoder that came out as 0.1 s more
|
|
@@ -142,7 +148,9 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
142
148
|
af += f",atrim=duration={float(in_audio):.6f}"
|
|
143
149
|
cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
|
|
144
150
|
if ext in AUDIO_CODECS or not meta.get("video"):
|
|
145
|
-
|
|
151
|
+
# the rate reaches an audio extension's codec when it was pinned or is AAC's (an
|
|
152
|
+
# .mp3/.ogg/.opus left alone keeps its own setting, as it always has)
|
|
153
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate if ext not in AUDIO_CODECS or bitrate_pinned or own_rate else None)
|
|
146
154
|
else:
|
|
147
155
|
cmd += probed_maps(meta) + ["-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
|
|
148
156
|
if ext in (".mp4", ".mov", ".m4v"):
|
|
@@ -185,7 +193,8 @@ def _normalize(args, meta: dict, stats: dict, lufs: float, tp_max: float, output
|
|
|
185
193
|
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {lufs} LUFS, TP <= {tp_max:g})")
|
|
186
194
|
result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
|
|
187
195
|
result["tp_ceiling_used"] = round(ceiling, 2)
|
|
188
|
-
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
|
|
196
|
+
if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext] or bitrate_pinned:
|
|
197
|
+
# a pinned rate reaches an .mp3/.ogg/.opus codec too (A143); unpinned, those keep their own
|
|
189
198
|
result["audio_bitrate_used"] = bitrate
|
|
190
199
|
result["encodes"] = rounds + 1
|
|
191
200
|
# the mode of the last encode: its ceiling is the one the file was made with
|
|
@@ -277,7 +286,8 @@ def main() -> int:
|
|
|
277
286
|
ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
|
|
278
287
|
ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
|
|
279
288
|
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
280
|
-
ap.add_argument("--audio-bitrate", default=None, help="
|
|
289
|
+
ap.add_argument("--audio-bitrate", default=None, help="audio bitrate: AAC's in a video container (default 192k), and the codec's of a lossy audio output (.m4a/.aac default 256k, .mp3/.ogg/.opus their own setting); "
|
|
290
|
+
"refused for .wav/.flac. Raised to 256k/320k for AAC only when the encoder overshoots the true-peak ceiling and you did not pin it")
|
|
281
291
|
ap.add_argument("--versions", metavar="LIST",
|
|
282
292
|
help="write one normalised file per target from this master, each measured after: a comma list of "
|
|
283
293
|
+ ", ".join(f"{n} ({i:g} LUFS / {t:g} dBTP)" for n, (i, t) in VERSION_TARGETS.items())
|
|
@@ -303,6 +313,10 @@ def main() -> int:
|
|
|
303
313
|
meta = probe(args.input)
|
|
304
314
|
if not meta.get("audio"):
|
|
305
315
|
die("input has no audio stream")
|
|
316
|
+
out_ext = os.path.splitext(args.output or default_output(args.input, "loudnorm"))[1].lower()
|
|
317
|
+
if args.audio_bitrate is not None and not args.measure_only and out_ext in LOSSLESS_AUDIO_EXT:
|
|
318
|
+
# A143: the rate was dropped without a word; a lossless codec has none to set
|
|
319
|
+
die(f"--audio-bitrate has no effect on a lossless {out_ext} output; drop it, or write a lossy one (.m4a, .mp3, ...)")
|
|
306
320
|
if plan is not None and not args.measure_only:
|
|
307
321
|
# each version measures the master against its own target (loudnorm's offset depends on
|
|
308
322
|
# it): a pass against -I/--tp first would be one more full decode whose result is unused
|
|
@@ -349,8 +363,7 @@ def audio_length(meta: dict) -> "Optional[float]":
|
|
|
349
363
|
"""The sound's own length: the stream's duration, else Matroska's DURATION tag. Never the
|
|
350
364
|
container's, which is the picture's when the picture runs longer (and would fail a correct
|
|
351
365
|
output against an expected length the sound never had)."""
|
|
352
|
-
|
|
353
|
-
return audio.get("duration") or audio.get("tagged_duration")
|
|
366
|
+
return own_length(meta.get("audio"))
|
|
354
367
|
|
|
355
368
|
|
|
356
369
|
def _kbps(value: str) -> int:
|
package/scripts/multicam.py
CHANGED
|
@@ -30,7 +30,7 @@ import json
|
|
|
30
30
|
import math
|
|
31
31
|
import os
|
|
32
32
|
import sys
|
|
33
|
-
from typing import Dict, List, Tuple
|
|
33
|
+
from typing import Dict, List, Optional, 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
36
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
@@ -60,8 +60,19 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
|
60
60
|
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
61
61
|
if e <= s:
|
|
62
62
|
die(f"switch entry '{raw}': end must be after start")
|
|
63
|
+
if round(e, 3) <= round(s, 3):
|
|
64
|
+
# the cut list (--edl, `cuts`) is written to the millisecond: this range was its
|
|
65
|
+
# "0.000-0.000", a line cut.py --segments refuses (the scenes.py --edl sibling)
|
|
66
|
+
die(f"switch entry '{raw}': shorter than a millisecond, the precision the cut list is written in",
|
|
67
|
+
kind="input")
|
|
63
68
|
out.append((s, e, c))
|
|
64
69
|
out.sort()
|
|
70
|
+
# A132: ranges on one timeline cannot overlap. The fill below kept both, so the picture of the
|
|
71
|
+
# overlap played twice while the sound played once, and every later cut ran behind it
|
|
72
|
+
for (s0, e0, _c0), (s1, e1, _c1) in zip(out, out[1:]):
|
|
73
|
+
if s1 < e0:
|
|
74
|
+
die(f"--switch ranges overlap: {fmt_secs(s0)}-{fmt_secs(e0)} and {fmt_secs(s1)}-{fmt_secs(e1)} "
|
|
75
|
+
"(on the reference timeline each moment has one camera)", kind="input")
|
|
65
76
|
return out
|
|
66
77
|
|
|
67
78
|
|
|
@@ -158,7 +169,7 @@ def main() -> int:
|
|
|
158
169
|
media_out = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
159
170
|
guard_sidecar(args.edl, *args.inputs, siblings=(args.write_project, media_out))
|
|
160
171
|
guard_sidecar(args.write_project, *args.inputs, siblings=(media_out,))
|
|
161
|
-
if args.edl or args.write_project: #
|
|
172
|
+
if args.edl or args.write_project: # refused up front with them, before any decode
|
|
162
173
|
guard_sidecar(media_out, *args.inputs)
|
|
163
174
|
if args.analyze_seconds > 900:
|
|
164
175
|
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
@@ -272,17 +283,26 @@ def main() -> int:
|
|
|
272
283
|
cursor = max(cursor, e)
|
|
273
284
|
if cursor < ref_dur:
|
|
274
285
|
filled.append((cursor, ref_dur, 0))
|
|
286
|
+
# a gap or a clipped range under a millisecond is nothing at the precision the cut list is
|
|
287
|
+
# written in (--switch "0.0004-3:1" left a leading "0.000-0.000", the line cut.py --segments
|
|
288
|
+
# refuses, and a 0.4 ms trim in the render): its neighbour takes it, so the timeline stays whole
|
|
289
|
+
whole: List[Tuple[float, float, int]] = []
|
|
290
|
+
carry: Optional[float] = None
|
|
291
|
+
for s, e, c in filled:
|
|
292
|
+
if carry is not None:
|
|
293
|
+
s, carry = carry, None
|
|
294
|
+
if round(e, 3) <= round(s, 3):
|
|
295
|
+
if whole:
|
|
296
|
+
whole[-1] = (whole[-1][0], e, whole[-1][2])
|
|
297
|
+
else:
|
|
298
|
+
carry = s
|
|
299
|
+
continue
|
|
300
|
+
whole.append((s, e, c))
|
|
301
|
+
filled = whole
|
|
275
302
|
for s, e, c in filled:
|
|
276
303
|
if not metas[c].get("video"):
|
|
277
304
|
die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
|
|
278
305
|
|
|
279
|
-
if args.edl:
|
|
280
|
-
if not STATE.dry_run:
|
|
281
|
-
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
282
|
-
for s, e, _c in filled:
|
|
283
|
-
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
284
|
-
info(f"wrote {args.edl}")
|
|
285
|
-
|
|
286
306
|
v0 = metas[0]["video"]
|
|
287
307
|
# camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
|
|
288
308
|
w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
|
|
@@ -322,9 +342,18 @@ def main() -> int:
|
|
|
322
342
|
c, src_s, src_e = 0, s, e
|
|
323
343
|
rendered.append((s, e, c))
|
|
324
344
|
sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
|
|
325
|
-
|
|
345
|
+
# A130: a drift-corrected camera's range is (e - s) * ratio of its own time; played at that
|
|
346
|
+
# rate it lasts e - s on the reference, as its sound does (asetrate) -- at its own rate the
|
|
347
|
+
# picture ran (ratio - 1) * (e - s) long and every later cut fell behind the sound
|
|
348
|
+
retime = f"(PTS-STARTPTS)/{ratios[c]:.9f}" if abs(ratios[c] - 1.0) > 1e-7 else "PTS-STARTPTS"
|
|
349
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts={retime},{convert[c]}{sq}{geo}[v{i}]")
|
|
326
350
|
labels.append(f"[v{i}]")
|
|
327
|
-
|
|
351
|
+
clip = {"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)}
|
|
352
|
+
if abs(ratios[c] - 1.0) > 1e-7:
|
|
353
|
+
# render.py plays a clip at clips[].speed (its length / speed), as the retime above
|
|
354
|
+
# does: without it a drift-corrected camera's clip ran (ratio - 1) * (e - s) long
|
|
355
|
+
clip["speed"] = round(ratios[c], 9)
|
|
356
|
+
project_clips.append(clip)
|
|
328
357
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
329
358
|
a = args.audio
|
|
330
359
|
a_start = -offsets[a] if offsets[a] < 0 else 0.0
|
|
@@ -358,17 +387,29 @@ def main() -> int:
|
|
|
358
387
|
# render.py project instead of the direct render this tool already does -- additive, not
|
|
359
388
|
# a replacement: the combined output below is still written as usual.
|
|
360
389
|
project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
|
|
361
|
-
"clips": project_clips
|
|
390
|
+
"clips": project_clips,
|
|
391
|
+
# a switch is a cut: render.py's default is a 0.5 s crossfade at every join,
|
|
392
|
+
# which shortens the timeline by 0.5 s a switch and moves every later one
|
|
393
|
+
"transition": {"type": "none"}}
|
|
362
394
|
# the inputs are named as given, relative to the working directory, and render.py reads a
|
|
363
395
|
# project's relative paths against the project file's directory: written elsewhere
|
|
364
396
|
# (--write-project out/cut.json), they named files that are not there
|
|
365
397
|
project = rebase_project_paths(project, os.getcwd())
|
|
398
|
+
|
|
399
|
+
run(cmd)
|
|
400
|
+
# the EDL and the project record the cut: written once it exists, not before an encode that then
|
|
401
|
+
# failed (#462 A41, the silence.py --edl sibling)
|
|
402
|
+
if args.edl:
|
|
403
|
+
if not STATE.dry_run:
|
|
404
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
405
|
+
for s, e, _c in filled:
|
|
406
|
+
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
407
|
+
info(f"wrote {args.edl}")
|
|
408
|
+
if args.write_project:
|
|
366
409
|
if not STATE.dry_run:
|
|
367
410
|
with open(args.write_project, "w", encoding="utf-8") as fh:
|
|
368
411
|
fh.write(json.dumps(project, indent=2) + "\n")
|
|
369
412
|
info(f"wrote {args.write_project}")
|
|
370
|
-
|
|
371
|
-
run(cmd)
|
|
372
413
|
r = probe(output, role="output")
|
|
373
414
|
info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
|
|
374
415
|
extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
|