ffmpeg-skill 3.8.11 → 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 +116 -7
- 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/broll.py +74 -16
- package/scripts/check.py +2 -2
- package/scripts/cut.py +49 -16
- package/scripts/export.py +3 -3
- package/scripts/fit.py +35 -4
- package/scripts/freeze.py +4 -2
- package/scripts/graphics.py +17 -8
- package/scripts/loop.py +17 -3
- package/scripts/loudness.py +20 -7
- package/scripts/multicam.py +55 -14
- package/scripts/overlay.py +12 -13
- package/scripts/pad.py +12 -4
- package/scripts/scenes.py +82 -21
- package/scripts/silence.py +62 -16
- package/scripts/sync.py +16 -5
- package/scripts/waveform.py +12 -2
package/scripts/_contract.py
CHANGED
|
@@ -170,7 +170,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
170
170
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
171
171
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
172
172
|
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)", "effects/atmos track (--effects)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
173
|
-
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:
|
|
173
|
+
required=FF, optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaingate", "when": "--duck"}, {"capability": "filter:dcshift", "when": "--duck"},
|
|
174
174
|
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit / --voice strong"}, {"capability": "filter:agate", "when": "--gate"},
|
|
175
175
|
{"capability": "filter:deesser", "when": "--voice medium (the default) / --voice strong"},
|
|
176
176
|
{"capability": "filter:extrastereo", "when": "--stereo-widen"},
|
|
@@ -872,7 +872,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
872
872
|
"silent": {"type": "array", "description": "--on-silent warn (default), only when a track was silent: [{flag, path, peak_db}] for each --music/--replace/--effects file, or under --duck the voice (flag \"input\"), whose whole-file peak is at or below --silence-threshold (default -50 dBFS); it was mixed anyway. --on-silent fail refuses instead, reason \"silent (peak X dBFS)\" in problems"},
|
|
873
873
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
874
874
|
"timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
|
|
875
|
-
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db
|
|
875
|
+
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, the sidechain gate's ratio, attack_ms, release_ms, and amount_db, the depth in dB the bed is lowered under speech)"}}
|
|
876
876
|
for key, prop in EMITTED_KEYS.get(name, {}).items():
|
|
877
877
|
extra.setdefault(key, prop)
|
|
878
878
|
if name in ("overlay", "graphics", "caption", "grid"):
|
package/scripts/_smartcut.py
CHANGED
|
@@ -110,6 +110,21 @@ def ineligible(params: Optional[Dict[str, Any]], meta: Dict[str, Any], ext: str)
|
|
|
110
110
|
return None
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
# Matroska stores every timestamp in milliseconds (its default TimestampScale), whatever the source's
|
|
114
|
+
# time base: an .mkv output's stream reads 1/1000 however it is written
|
|
115
|
+
MATROSKA_TIME_BASE = "1/1000"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def expected_time_base(params: Dict[str, Any], ext: str) -> Optional[str]:
|
|
119
|
+
"""The time base the joined output's video should read: the source's for MP4/MOV, whose time
|
|
120
|
+
scale execute() sets from it (-video_track_timescale), and Matroska's own for .mkv. Comparing a
|
|
121
|
+
.mkv against an MP4 source's 1/15360 failed every such cut, and only after the whole smart cut had
|
|
122
|
+
been built and checked (#462 A45)."""
|
|
123
|
+
if ext.lower() == ".mkv":
|
|
124
|
+
return MATROSKA_TIME_BASE
|
|
125
|
+
return params.get("time_base")
|
|
126
|
+
|
|
127
|
+
|
|
113
128
|
def _rate(params: Dict[str, Any]) -> Optional[Fraction]:
|
|
114
129
|
try:
|
|
115
130
|
q = Fraction(str(params.get("r_frame_rate") or ""))
|
|
@@ -165,8 +180,10 @@ def plan_segment(packets: List[Tuple[Any, Any, bool]], s: float, e: float, vend:
|
|
|
165
180
|
raise SmartFallback(f"the keyframe at {packets[k1][0]:.3f}s opens its GOP (frames decoded after it are shown "
|
|
166
181
|
"before it), so a copy cannot start there", "open_gop")
|
|
167
182
|
k1_pts = packets[k1][0]
|
|
168
|
-
# where the copy stops: the last keyframe presented at or before the end bound, decoded after K1
|
|
169
|
-
|
|
183
|
+
# where the copy stops: the last keyframe presented at or before the end bound, decoded after K1.
|
|
184
|
+
# A segment that runs to the end of the video has no end bound to stop before: its copy runs
|
|
185
|
+
# to the end, and the last GOP is whole, so it is copied, not re-encoded as a tail (#462 A44)
|
|
186
|
+
stops = [i for i in keyidx if not to_end and packets[i][1] > packets[k1][1] + 1e-9
|
|
170
187
|
and (end_bound is None or packets[i][0] <= end_bound + 1e-6)]
|
|
171
188
|
k2 = stops[-1] if stops else None
|
|
172
189
|
if k2 is not None and gop_is_open(packets, k2):
|
|
@@ -422,9 +439,10 @@ def verify(src: str, dst: str, parts: List[Dict[str, Any]], params: Dict[str, An
|
|
|
422
439
|
decoded += g
|
|
423
440
|
offset += n
|
|
424
441
|
out_params = stream_params(dst) or {}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
442
|
+
want_tb = expected_time_base(params, os.path.splitext(dst)[1])
|
|
443
|
+
if out_params.get("time_base") != want_tb:
|
|
444
|
+
raise SmartFallback(f"the output's time base is {out_params.get('time_base')}, where "
|
|
445
|
+
f"{want_tb} was expected (the source's is {params.get('time_base')})", "time_base")
|
|
428
446
|
diff = mismatches(params, out_params)
|
|
429
447
|
if diff:
|
|
430
448
|
raise SmartFallback("the output's stream does not match the source: " + ", ".join(diff), "params_mismatch")
|
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/broll.py
CHANGED
|
@@ -18,10 +18,12 @@ Examples:
|
|
|
18
18
|
python3 broll.py talk.mp4 --insert demo.mp4 --at 30 --duration 8 --audio mix
|
|
19
19
|
"""
|
|
20
20
|
import argparse
|
|
21
|
+
import math
|
|
21
22
|
import sys
|
|
22
23
|
from typing import Any, Dict, List
|
|
23
24
|
|
|
24
|
-
from _common.decision import av_trim, conform_output_t, conform_to_length,
|
|
25
|
+
from _common.decision import (av_trim, conform_output_t, conform_to_length, frame_durations, frame_rate, frame_window,
|
|
26
|
+
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
25
27
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
28
|
from _common.emit import note, record_fallback
|
|
27
29
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
@@ -77,6 +79,11 @@ def main() -> int:
|
|
|
77
79
|
a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
|
|
78
80
|
w, h = ew, eh
|
|
79
81
|
fps = meta_a["video"].get("fps") or 30.0
|
|
82
|
+
# the A-roll's frame grid, when it has one (frame_window): its exact rate and where it starts
|
|
83
|
+
grid = frame_window(0.0, None, meta_a) is not None
|
|
84
|
+
rate_a, v0a = frame_rate(meta_a), stream_starts(meta_a)[0]
|
|
85
|
+
fps_filter = frame_durations(meta_a, rate_a) # fps=30000/1001; a whole rate reads as it did (fps=25)
|
|
86
|
+
fps_filter = fps_filter[:-2] if fps_filter.endswith("/1") else fps_filter
|
|
80
87
|
has_audio_a = bool(meta_a.get("audio"))
|
|
81
88
|
|
|
82
89
|
def per(values: List[str], i: int, default: str) -> str:
|
|
@@ -105,7 +112,19 @@ def main() -> int:
|
|
|
105
112
|
dur_b = input_length(meta_b)
|
|
106
113
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
107
114
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
108
|
-
|
|
115
|
+
# the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
|
|
116
|
+
# A-roll's first frame at or after --at, and `span` runs to its first at or after the end
|
|
117
|
+
v0b, rate_b = stream_starts(meta_b)[0], frame_rate(meta_b)
|
|
118
|
+
first = max(v0b, grid_at_or_after(start_b, v0b, rate_b))
|
|
119
|
+
on = span = None
|
|
120
|
+
if grid:
|
|
121
|
+
on = grid_at_or_after(at, v0a, rate_a)
|
|
122
|
+
span = grid_at_or_after(at + length, v0a, rate_a) - on
|
|
123
|
+
if round(span * rate_a) < 1:
|
|
124
|
+
die(f"cutaway {i + 1}: no frame of the A-roll starts between {at:g}s and {at + length:g}s "
|
|
125
|
+
f"(its frames are {1 / rate_a:.4f}s apart): nothing would be shown")
|
|
126
|
+
cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b,
|
|
127
|
+
"first": first, "rate": rate_b, "on": on, "span": span})
|
|
109
128
|
# overlap is a property of the windows in time, not of the order they were given in: compared
|
|
110
129
|
# against the one given before it, a later window listed first was called overlapping (A179)
|
|
111
130
|
order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
|
|
@@ -123,7 +142,7 @@ def main() -> int:
|
|
|
123
142
|
cmd += ["-i", c["path"]]
|
|
124
143
|
|
|
125
144
|
# Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
|
|
126
|
-
# --at time, and overlaid on A;
|
|
145
|
+
# --at time, and overlaid on A; outside the window the overlay is placed off the picture and
|
|
127
146
|
# A shows through unchanged, so A's timeline is never touched.
|
|
128
147
|
parts: List[str] = []
|
|
129
148
|
cur = "[0:v]"
|
|
@@ -144,9 +163,32 @@ def main() -> int:
|
|
|
144
163
|
+ drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
|
|
145
164
|
tag=f"pad{i}", labelled=True))
|
|
146
165
|
b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
pix = 'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'
|
|
167
|
+
if c["on"] is None:
|
|
168
|
+
# no grid to count the A-roll's frames on (a dry run's pending A-roll, a variable
|
|
169
|
+
# rate): the window as it was written before, to the millisecond
|
|
170
|
+
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
171
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={pix}[b{i}]")
|
|
172
|
+
window = window_expr(c["at"], c["at"] + c["length"], None)
|
|
173
|
+
else:
|
|
174
|
+
# The window is whole frames of the A-roll: from its first frame at or after --at to
|
|
175
|
+
# the frame before its first at or after the end. B is cut half a frame of its own
|
|
176
|
+
# before its first frame at or after --from, for as long as the window lasts, and
|
|
177
|
+
# that frame is put on the tick of the `fps` grid at or before the window's first
|
|
178
|
+
# frame, at the A-roll's exact rate (30000/1001, not probe()'s 29.97: frame 37
|
|
179
|
+
# counted at 29.97 comes 1.2 microseconds after the A-roll's and was shown a frame on)
|
|
180
|
+
# The cut never ends before B's first frame: a window shorter than half a frame of B
|
|
181
|
+
# (one 60 fps frame of the A-roll under a 24 fps cutaway) cut that frame off too
|
|
182
|
+
half_b = 0.5 / c["rate"] if c["rate"] else 0.0
|
|
183
|
+
parts.append(f"[{i + 1}:v]trim=start={max(0.0, c['first'] - half_b):.6f}:end={max(c['first'] + c['span'], c['first'] + 2 * half_b) - half_b:.6f},"
|
|
184
|
+
f"setpts=PTS-STARTPTS+{math.floor(c['on'] * rate_a + 1e-6) / rate_a:.6f}/TB,"
|
|
185
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,{fps_filter},format={pix}[b{i}]")
|
|
186
|
+
window = window_expr(c["on"], c["on"] + c["span"], meta_a, half_open=True)
|
|
187
|
+
# The window is in the cutaway's position: overlay's enable is evaluated on B's frames
|
|
188
|
+
# too. B's last frame is held when B ends (eof_action=repeat) and the position takes it
|
|
189
|
+
# off the picture: with eof_action=pass the window's last frame depended on where B's
|
|
190
|
+
# end fell against it, and an A-roll whose picture starts 13 ms late lost that frame.
|
|
191
|
+
parts.append(f"{cur}[b{i}]overlay={windowed_overlay_x(0, window)}:0{overlay_format(meta_a)}:eof_action=repeat[v{i}]")
|
|
150
192
|
cur = f"[v{i}]"
|
|
151
193
|
vout = cur
|
|
152
194
|
|
|
@@ -158,24 +200,40 @@ def main() -> int:
|
|
|
158
200
|
aout = "0:a:0" if has_audio_a else None
|
|
159
201
|
else:
|
|
160
202
|
layers: List[str] = []
|
|
203
|
+
# the cutaway's sound follows its picture: it starts with the window's first frame and
|
|
204
|
+
# lasts as long as the window, counted in samples of the mix (without a frame grid:
|
|
205
|
+
# --at and the length asked)
|
|
206
|
+
for c in cutaways:
|
|
207
|
+
c["s0"] = round((c["at"] if c["on"] is None else c["on"]) * MIX_RATE)
|
|
208
|
+
c["sn"] = round((c["length"] if c["on"] is None else c["span"]) * MIX_RATE)
|
|
161
209
|
if has_audio_a:
|
|
162
|
-
|
|
210
|
+
# `b` mutes the A-roll over exactly the samples B's sound takes, on the A-roll's
|
|
211
|
+
# own clock: aeval's `t` is each sample's time, and the bounds sit half a sample
|
|
212
|
+
# outside the first and the last. `volume=0:enable='between(t,...)'` is evaluated
|
|
213
|
+
# once per audio frame and muted 48128-96255 where 48000-95999 was asked
|
|
214
|
+
# (1024-sample frames; FFmpeg 6.1.1)
|
|
215
|
+
gate = ""
|
|
216
|
+
if args.audio == "b":
|
|
217
|
+
gate = ",aeval='val(ch)*not(" + "+".join(
|
|
218
|
+
f"between(t,{(c['s0'] - 0.5) / MIX_RATE:.9f},{(c['s0'] + c['sn'] - 0.5) / MIX_RATE:.9f})" for c in cutaways) + ")':c=same"
|
|
163
219
|
parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
|
|
164
220
|
layers.append("[a0]")
|
|
165
221
|
for i, c in enumerate(cutaways):
|
|
166
222
|
# B's sound is shifted by the same constant as its picture, whose first frame (the
|
|
167
|
-
# first at or after --from) lands on
|
|
168
|
-
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
# the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
223
|
+
# first at or after --from) lands on the window's first frame; rebasing the sound
|
|
224
|
+
# to its own first sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
225
|
+
first, length = c["first"], c["length"] if c["on"] is None else c["span"]
|
|
226
|
+
# the delay is to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
|
|
172
227
|
# 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
|
|
173
228
|
# early. A whole millisecond keeps the text it had, rounded (silence_filter)
|
|
174
|
-
|
|
229
|
+
lead = c["s0"] / MIX_RATE
|
|
230
|
+
delay = silence_filter(MIX_RATE, lead=lead)
|
|
175
231
|
if "S:" not in delay:
|
|
176
|
-
delay = f"adelay={round(
|
|
177
|
-
|
|
178
|
-
|
|
232
|
+
delay = f"adelay={round(lead * 1000)}|{round(lead * 1000)}"
|
|
233
|
+
# under `b` the layer is as many samples as the gate mutes, padded or cut
|
|
234
|
+
exact = f"apad=whole_len={c['sn']},atrim=end_sample={c['sn']}," if args.audio == "b" else ""
|
|
235
|
+
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + length)[1]},"
|
|
236
|
+
f"aformat=sample_rates=48000:channel_layouts=stereo,{exact}{delay}[ab{i}]")
|
|
179
237
|
layers.append(f"[ab{i}]")
|
|
180
238
|
# the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
|
|
181
239
|
# is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
|
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)
|