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/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/fit.py
CHANGED
|
@@ -68,6 +68,24 @@ def atempo_chain(factor: float) -> str:
|
|
|
68
68
|
return ",".join(parts)
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def picture_output_t(target: float, meta: dict) -> str:
|
|
72
|
+
"""The output `-t` of a trim from the start that has no sound to end: `{target:.3f}` as it
|
|
73
|
+
always was, unless that text keeps another number of frames than `target` does. The frames
|
|
74
|
+
kept are those that start before `target`. 37 frames of a 30000/1001 picture are 1.2345667 s:
|
|
75
|
+
`-t 1.235` is past the 38th frame's start and wrote it, and `-t 1.001` for 1.0014 s stopped
|
|
76
|
+
before the 31st. The limit is then written half a frame before the first frame left out.
|
|
77
|
+
Without a frame grid (no rate: a dry run's pending input; a variable rate, which the output's
|
|
78
|
+
-r regrids) the millisecond text stays."""
|
|
79
|
+
text = f"{target:.3f}"
|
|
80
|
+
rate = frame_rate(meta)
|
|
81
|
+
if not rate or (meta.get("video") or {}).get("variable_frame_rate_suspected"):
|
|
82
|
+
return text
|
|
83
|
+
out = grid_at_or_after(target, stream_starts(meta)[0], rate) # the first frame left out
|
|
84
|
+
if out - 1 / rate + 1e-6 < float(text) <= out + 1e-9:
|
|
85
|
+
return text
|
|
86
|
+
return f"{out - 0.5 / rate:.6f}"
|
|
87
|
+
|
|
88
|
+
|
|
71
89
|
def main() -> int:
|
|
72
90
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
73
91
|
ap.add_argument("input")
|
|
@@ -189,9 +207,17 @@ def main() -> int:
|
|
|
189
207
|
vf.append("tblend=all_mode=average")
|
|
190
208
|
if has_audio:
|
|
191
209
|
af.append(atempo_chain(factor))
|
|
192
|
-
# atempo's sound
|
|
193
|
-
# millisecond
|
|
194
|
-
|
|
210
|
+
# atempo's sound is not the input's length over the factor (it comes out some
|
|
211
|
+
# milliseconds short or long), and the millisecond -t stays on a retimed track: padding
|
|
212
|
+
# it to the target's samples (apad after atempo in -af) never ended on FFmpeg 8.0.1
|
|
213
|
+
# (seen in CI: killed at the time limit), so the track keeps the length atempo gives.
|
|
214
|
+
# Where the sound is not retimed (a factor within 1e-4 of 1) the source's runs on and
|
|
215
|
+
# the -t ends it to the sample. A picture without sound that is not retimed either is
|
|
216
|
+
# a trim, counted in frames; one that is retimed, or regridded by --fps, keeps the
|
|
217
|
+
# millisecond
|
|
218
|
+
retimed = abs(factor - 1.0) > 1e-4
|
|
219
|
+
post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True) if rate
|
|
220
|
+
else f"{target:.3f}" if retimed or args.fps or has_audio else picture_output_t(target, meta)]
|
|
195
221
|
STATE.duration_hint = target
|
|
196
222
|
else:
|
|
197
223
|
if target < src_dur:
|
|
@@ -202,7 +228,12 @@ def main() -> int:
|
|
|
202
228
|
# picture would not be the one the start timecode below names
|
|
203
229
|
m = min(SEEK_MARGIN, round(start, 3))
|
|
204
230
|
pre_input += ["-ss", f"{start - m:.3f}"]
|
|
205
|
-
|
|
231
|
+
# without sound the -t only counts frames (picture_output_t). Not from the centre:
|
|
232
|
+
# there the output's clock starts at the seek, between two frames, and the encoder
|
|
233
|
+
# rounds each frame to its own tick
|
|
234
|
+
keep = (picture_output_t(target, meta) if not rate and not args.fps and not args.from_center
|
|
235
|
+
else length_output_t(target, rate, runs_on=True))
|
|
236
|
+
post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", keep]
|
|
206
237
|
# the output starts on the first frame at or after the seek (a seek before a
|
|
207
238
|
# picture that starts after the file's origin: on its first frame): its start
|
|
208
239
|
# timecode is that frame's, not the source's first frame's
|
package/scripts/freeze.py
CHANGED
|
@@ -19,7 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, silence_filter, stream_starts, tpad_length
|
|
23
23
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, time_arg, fmt_secs, run
|
|
24
24
|
from _common.emit import input_length
|
|
25
25
|
|
|
@@ -83,7 +83,9 @@ def main() -> int:
|
|
|
83
83
|
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
84
84
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
85
85
|
if has_audio:
|
|
86
|
-
|
|
86
|
+
# the sound waits as long as the picture is held: whole frames (tpad_length), as the
|
|
87
|
+
# hold in the middle of a clip pads by the held frames' length
|
|
88
|
+
cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=tpad_length(args.hold, meta))]
|
|
87
89
|
else:
|
|
88
90
|
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
89
91
|
# resuming the rest of the clip: split the timeline at `at` and hold the remainder's
|
package/scripts/graphics.py
CHANGED
|
@@ -51,6 +51,7 @@ from _common.fonts import font_choice, record_font_choice
|
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
53
53
|
from _common import drawtext_fontfile # noqa: E402
|
|
54
|
+
from _common.decision import window_expr, windowed_overlay_x # noqa: E402
|
|
54
55
|
from _common.emit import input_length # noqa: E402
|
|
55
56
|
|
|
56
57
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
@@ -232,7 +233,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
232
233
|
hint="give a --start inside the clip")
|
|
233
234
|
if e <= s:
|
|
234
235
|
die("--end must be after --start")
|
|
235
|
-
|
|
236
|
+
# the window's bounds sit half a frame from the frames they select (window_expr): `{:.3f}`
|
|
237
|
+
# put the frame at 1.234567 s of a 30000/1001 picture after a --start of "1.235"
|
|
238
|
+
en = f"enable='{window_expr(s, e, meta)}'"
|
|
236
239
|
base = min(W, H) * args.scale # scale everything from the short side
|
|
237
240
|
filters: List[str] = []
|
|
238
241
|
rate = f"{meta['video'].get('fps') or 30:g}"
|
|
@@ -253,6 +256,12 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
253
256
|
filters.append(f"{BOX}{len(boxes)}")
|
|
254
257
|
boxes.append((colour, alpha, drawbox_geo, x, y, w, h, enable, x_expr))
|
|
255
258
|
|
|
259
|
+
def layer_at(x, y, enable: str) -> str:
|
|
260
|
+
"""An overlay's `x=...:y=...` for a layer shown inside `enable`'s window: the window is in
|
|
261
|
+
the layer's position, which is evaluated on the picture's frames, where overlay's own
|
|
262
|
+
enable is evaluated on the layer's frames too (windowed_overlay_x)."""
|
|
263
|
+
return f"x={windowed_overlay_x(str(x).strip(chr(39)), enable[len('enable='):].strip(chr(39)))}:y={y}"
|
|
264
|
+
|
|
256
265
|
def layer_src(colour: str, alpha: float, w: int, h: int, left: int = 0, top: int = 0) -> str:
|
|
257
266
|
"""A colour source to overlay (a box, the lower-third bar, the progress fill), converted when it
|
|
258
267
|
has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
|
|
@@ -450,8 +459,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
450
459
|
x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{m_left})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{m_left}-({bar_w}+{m_left})*(1-({e:.3f}-t)/0.3),{m_left}))"
|
|
451
460
|
fc.append(f"{layer_src(bg, 0.85, bar_w, bar_h)}[bar]")
|
|
452
461
|
fc.append(f"{layer_src(primary, 1.0, int(base * 0.012), bar_h)}[acc]")
|
|
453
|
-
fc.append(f"[0:v][bar]overlay=
|
|
454
|
-
fc.append(f"[v1][acc]overlay=
|
|
462
|
+
fc.append(f"[0:v][bar]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v1]")
|
|
463
|
+
fc.append(f"[v1][acc]overlay={layer_at(x_expr, y0, en)}:eof_action=pass[v2]")
|
|
455
464
|
tx = f"({x_expr})+{tx_pad}"
|
|
456
465
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
457
466
|
draws = []
|
|
@@ -546,10 +555,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
546
555
|
# a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
|
|
547
556
|
# 10-bit picture to 8 bits)
|
|
548
557
|
fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
|
|
549
|
-
fc.append(f"[0:v][pbg]overlay=
|
|
558
|
+
fc.append(f"[0:v][pbg]overlay={layer_at(0, (H - h) // 2 * 2, en)}:eof_action=pass[v1]")
|
|
550
559
|
else:
|
|
551
560
|
fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
|
|
552
|
-
fc.append(f"[v1][pb]overlay=
|
|
561
|
+
fc.append(f"[v1][pb]overlay={layer_at(f'-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))', H - h, en)}:eof_action=pass[vout]")
|
|
553
562
|
|
|
554
563
|
elif args.template == "sticker":
|
|
555
564
|
# A social sticker: a filled chip of text that pops in. drawtext's box gives the chip
|
|
@@ -593,7 +602,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
593
602
|
if args.duration <= 0:
|
|
594
603
|
die(f"--duration must be > 0, got {args.duration:g}")
|
|
595
604
|
he = min(e, s + args.duration)
|
|
596
|
-
hen = f"enable='
|
|
605
|
+
hen = f"enable='{window_expr(s, he, meta)}'"
|
|
597
606
|
h1 = int(base * 0.085)
|
|
598
607
|
bar_h = max(3, int(base * 0.01))
|
|
599
608
|
hook_title = wrapped(args.title, h1)
|
|
@@ -642,7 +651,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
642
651
|
ke = ks + seg
|
|
643
652
|
pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
|
|
644
653
|
add_text(str(k),
|
|
645
|
-
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='
|
|
654
|
+
f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='{window_expr(ks, ke, meta, half_open=k > 0)}'",
|
|
646
655
|
size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
|
|
647
656
|
outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
|
|
648
657
|
scale_t=(0, seg * 500, 115))
|
|
@@ -766,7 +775,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
766
775
|
drawboxes = []
|
|
767
776
|
for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
|
|
768
777
|
if as_layers:
|
|
769
|
-
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2),
|
|
778
|
+
layers.append((layer_src(colour, alpha, w, h, x % 2, y % 2), layer_at(x_expr or x - x % 2, y - y % 2, enable)))
|
|
770
779
|
drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
|
|
771
780
|
filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
|
|
772
781
|
filters = [f for f in filters if f]
|
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
|
|
|
@@ -296,6 +296,8 @@ def main() -> int:
|
|
|
296
296
|
die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
|
|
297
297
|
stream_loop = math.ceil(target / unit) - 1
|
|
298
298
|
|
|
299
|
+
# a variable-rate source is conformed by the output's own -fps_mode cfr -r (cfr_args)
|
|
300
|
+
forced_rate = cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
|
|
299
301
|
if args.boomerang:
|
|
300
302
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
|
|
301
303
|
if meta.get("audio"):
|
|
@@ -318,6 +320,18 @@ def main() -> int:
|
|
|
318
320
|
fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
|
|
319
321
|
vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
|
|
320
322
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
|
|
323
|
+
v_end = stream_starts(meta)[0] + (v.get("duration") or 0.0)
|
|
324
|
+
if has_audio and not forced_rate and not fade_frames and src_dur - v_end > 0.5 / fps:
|
|
325
|
+
# A repeat is as long as the file, so where the sound outlasts the picture by more
|
|
326
|
+
# than half a frame the next repeat's first frame comes later than one frame after
|
|
327
|
+
# the last. What stood in that gap was left to the output's frame-rate handling:
|
|
328
|
+
# FFmpeg 6.1 repeated the last frame and showed the next repeat's first frame twice,
|
|
329
|
+
# from a frame before its time (a 1 s 25 fps clip with 1.23425 s of sound: frame 0 of
|
|
330
|
+
# the second repeat at 1.20 s and 1.24 s, its sound from 1.23425 s); FFmpeg 7.1 and 8
|
|
331
|
+
# held the last frame (50 packets for 56). The graph ends on the clip's own rate, so
|
|
332
|
+
# the gap is the last frame repeated and each later repeat's frames sit on the grid
|
|
333
|
+
# once. A clip without that gap has nothing to fill and keeps the command it had.
|
|
334
|
+
vtrim += f",fps={rate}"
|
|
321
335
|
if has_audio:
|
|
322
336
|
cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
|
|
323
337
|
"-map", "[v]", "-map", "[a]"]
|
|
@@ -329,7 +343,7 @@ def main() -> int:
|
|
|
329
343
|
cmd += video_args(meta, args.crf, args.preset)
|
|
330
344
|
# a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
|
|
331
345
|
# not a variable-rate source's average, which re-timed every repeat by a frame or two
|
|
332
|
-
cmd += ["-fps_mode", "passthrough"] if args.boomerang else
|
|
346
|
+
cmd += ["-fps_mode", "passthrough"] if args.boomerang else forced_rate
|
|
333
347
|
if has_audio:
|
|
334
348
|
cmd += aac_args()
|
|
335
349
|
else:
|
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 {}
|
package/scripts/overlay.py
CHANGED
|
@@ -38,7 +38,7 @@ from _common import still_input_args # noqa: E402
|
|
|
38
38
|
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
39
39
|
from _common.emit import note, warn
|
|
40
40
|
from _common.color import is_rgb
|
|
41
|
-
from _common.decision import length_output_t
|
|
41
|
+
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
|
42
42
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
43
43
|
has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
44
44
|
from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
|
|
@@ -128,13 +128,12 @@ def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0
|
|
|
128
128
|
return wrapped
|
|
129
129
|
|
|
130
130
|
|
|
131
|
-
def enable_expr(start: Optional[float], end: Optional[float]) -> str:
|
|
131
|
+
def enable_expr(start: Optional[float], end: Optional[float], meta: Optional[dict] = None) -> str:
|
|
132
|
+
"""True on the main picture's frames from --start to --end, both inclusive (window_expr):
|
|
133
|
+
the bounds sit half a frame from the frames they select, not on a frame's own time."""
|
|
132
134
|
if start is None and end is None:
|
|
133
135
|
return ""
|
|
134
|
-
|
|
135
|
-
if end is None:
|
|
136
|
-
return f"gte(t,{s})"
|
|
137
|
-
return f"between(t,{s},{end:.3f})"
|
|
136
|
+
return window_expr(start, end, meta)
|
|
138
137
|
|
|
139
138
|
|
|
140
139
|
def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fade: float) -> str:
|
|
@@ -440,7 +439,7 @@ def main() -> int:
|
|
|
440
439
|
"from --start to --end without a fade", hint="drop --fade for the video layer")
|
|
441
440
|
|
|
442
441
|
output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
|
|
443
|
-
enable = enable_expr(start, end)
|
|
442
|
+
enable = enable_expr(start, end, meta)
|
|
444
443
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
445
444
|
|
|
446
445
|
if args.image:
|
|
@@ -464,9 +463,9 @@ def main() -> int:
|
|
|
464
463
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
465
464
|
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
466
465
|
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
466
|
+
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
467
|
+
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
468
|
+
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
470
469
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
471
470
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
472
471
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
|
@@ -545,9 +544,9 @@ def main() -> int:
|
|
|
545
544
|
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
546
545
|
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
547
546
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
547
|
+
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
548
|
+
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
549
|
+
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
551
550
|
# a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
|
|
552
551
|
# drops the alpha, and the layer covered the picture as an opaque rectangle
|
|
553
552
|
cmd = ffmpeg_base() + ["-i", args.input] + decoder_args_for(pip_meta, args.video) + ["-i", args.video]
|
package/scripts/pad.py
CHANGED
|
@@ -16,8 +16,8 @@ Examples:
|
|
|
16
16
|
import argparse
|
|
17
17
|
import sys
|
|
18
18
|
|
|
19
|
-
from _common.emit import record_fallback
|
|
20
|
-
from _common.decision import silence_filter
|
|
19
|
+
from _common.emit import note, record_fallback
|
|
20
|
+
from _common.decision import silence_filter, tpad_length
|
|
21
21
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
22
22
|
|
|
23
23
|
|
|
@@ -65,8 +65,16 @@ def main() -> int:
|
|
|
65
65
|
# front (one -filter_complex), so the record is made here, as broll and stabilize do
|
|
66
66
|
record_fallback(f"{args.input} is {v.get('width')}x{v.get('height')}: scaled to even dimensions first",
|
|
67
67
|
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
68
|
-
#
|
|
69
|
-
|
|
68
|
+
# The sound is delayed by what the picture is: tpad puts whole frames ahead of the clip
|
|
69
|
+
# (--start 0.23425 at 25 fps: 6 frames, 0.240 s), and a delay of --start itself moved the
|
|
70
|
+
# sound 5.75 ms ahead of its picture (tpad_length). The silence after the clip moves
|
|
71
|
+
# nothing and is --end as asked. Either is counted in samples when it is not a whole
|
|
72
|
+
# millisecond (silence_filter).
|
|
73
|
+
lead = tpad_length(args.start, meta)
|
|
74
|
+
if abs(lead - args.start) > 1e-9:
|
|
75
|
+
note(f"--start {args.start:g}: the picture can only be padded by whole frames, {lead:.6f} s here; "
|
|
76
|
+
f"the sound is delayed by the same {lead:.6f} s so the clip keeps its sync")
|
|
77
|
+
af = silence_filter(meta["audio"].get("sample_rate"), lead, args.end)
|
|
70
78
|
cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
|
|
71
79
|
else:
|
|
72
80
|
cmd += ["-vf", vf, "-map", "0:v:0"]
|