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/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(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
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(written_audio.get("duration") or written_audio.get("tagged_duration"))):
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 ends before the target (13 to 62 ms before it on FFmpeg 6.1.1), so the
193
- # millisecond cuts nothing off a retimed track and stays
194
- post += ["-t", f"{target:.3f}" if af else length_output_t(target, rate, runs_on=True)]
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
- post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", length_output_t(target, rate, runs_on=True)]
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
- cmd += ["-map", "0:a:0?", "-af", silence_filter(meta["audio"].get("sample_rate"), lead=args.hold)]
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
@@ -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
- en = f"enable='between(t,{s:.3f},{e:.3f})'"
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=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
454
- fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
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=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
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=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
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='between(t,{s:.3f},{he:.3f})'"
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='between(t,{ks:.3f},{ke:.3f})'",
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), f"x={x_expr or x - x % 2}:y={y - y % 2}:{enable}"))
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 = v.get("duration") or v.get("tagged_duration") or src_dur
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 cfr_args(meta, fps if fade_frames and v.get("variable_frame_rate_suspected") else None)
346
+ cmd += ["-fps_mode", "passthrough"] if args.boomerang else forced_rate
333
347
  if has_audio:
334
348
  cmd += aac_args()
335
349
  else:
@@ -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
- bitrate = args.audio_bitrate or "192k"
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
- cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn"] + audio_codec_for(output, bitrate)
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="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
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
- audio = meta.get("audio") or {}
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:
@@ -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: # they are written before the cut: refuse its output first
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
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
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
- project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
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 {}
@@ -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
- s = f"{start:.3f}" if start is not None else "0"
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
- ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
468
- if enable:
469
- ov += f":enable='{enable}'"
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
- ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
549
- if enable:
550
- ov += f":enable='{enable}'"
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
- # the silence is counted in samples when --start/--end is not a whole millisecond
69
- af = silence_filter(meta["audio"].get("sample_rate"), args.start, args.end)
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"]