ffmpeg-skill 3.8.10 → 3.8.12

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/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, frame_rate, grid_at_or_after, silence_filter, stream_starts
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,9 +112,27 @@ 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
- if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
109
- die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
110
- cutaways.append({"path": path, "at": at, "length": length, "from": start_b, "has_audio": bool(meta_b.get("audio")), "meta": meta_b})
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})
128
+ # overlap is a property of the windows in time, not of the order they were given in: compared
129
+ # against the one given before it, a later window listed first was called overlapping (A179)
130
+ order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
131
+ for prev, k in zip(order, order[1:]):
132
+ end_prev = cutaways[prev]["at"] + cutaways[prev]["length"]
133
+ if cutaways[k]["at"] < end_prev:
134
+ die(f"cutaway {k + 1} at {cutaways[k]['at']:g}s overlaps cutaway {prev + 1} "
135
+ f"({cutaways[prev]['at']:g}s to {end_prev:g}s)")
111
136
  if args.audio != "a" and not all(c["has_audio"] for c in cutaways):
112
137
  die("--audio b/mix needs audio on every B-roll clip")
113
138
 
@@ -117,7 +142,7 @@ def main() -> int:
117
142
  cmd += ["-i", c["path"]]
118
143
 
119
144
  # Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
120
- # --at time, and overlaid on A; before its first frame and after its last (eof_action=pass)
145
+ # --at time, and overlaid on A; outside the window the overlay is placed off the picture and
121
146
  # A shows through unchanged, so A's timeline is never touched.
122
147
  parts: List[str] = []
123
148
  cur = "[0:v]"
@@ -138,9 +163,32 @@ def main() -> int:
138
163
  + drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
139
164
  tag=f"pad{i}", labelled=True))
140
165
  b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
141
- parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
142
- f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
143
- parts.append(f"{cur}[b{i}]overlay=0:0{overlay_format(meta_a)}:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
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}]")
144
192
  cur = f"[v{i}]"
145
193
  vout = cur
146
194
 
@@ -152,24 +200,40 @@ def main() -> int:
152
200
  aout = "0:a:0" if has_audio_a else None
153
201
  else:
154
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)
155
209
  if has_audio_a:
156
- gate = "".join(f",volume=0:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'" for c in cutaways) if args.audio == "b" else ""
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"
157
219
  parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
158
220
  layers.append("[a0]")
159
221
  for i, c in enumerate(cutaways):
160
222
  # B's sound is shifted by the same constant as its picture, whose first frame (the
161
- # first at or after --from) lands on --at; rebasing the sound to its own first
162
- # sample moved a track that starts 0.3 s late 0.279 s early (#409)
163
- v0b = stream_starts(c["meta"])[0]
164
- first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
165
- # 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
166
227
  # 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
167
228
  # early. A whole millisecond keeps the text it had, rounded (silence_filter)
168
- delay = silence_filter(MIX_RATE, lead=c["at"])
229
+ lead = c["s0"] / MIX_RATE
230
+ delay = silence_filter(MIX_RATE, lead=lead)
169
231
  if "S:" not in delay:
170
- delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
171
- parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
172
- f"aformat=sample_rates=48000:channel_layouts=stereo,{delay}[ab{i}]")
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}]")
173
237
  layers.append(f"[ab{i}]")
174
238
  # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
175
239
  # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
@@ -56,7 +56,7 @@ from _common import asr as _asr
56
56
  from _common.asr import ENGINE_CHOICES
57
57
  from _common.emit import warn
58
58
  from _common.probe import display_size, square_pixels, square_pixels_note
59
- from _common.runner import subtitle_codec_for, subtitle_container_holds
59
+ from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
60
60
  from _common.fonts import font_choice, record_font_choice
61
61
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
62
62
  write_srt)
@@ -1787,7 +1787,7 @@ def main() -> int:
1787
1787
  if meta.get("audio"):
1788
1788
  maps += ["-map", f"0:a:{args.audio_stream}"]
1789
1789
  if existing_subs:
1790
- maps += ["-map", "0:s?"]
1790
+ maps += ["-map", "0:s?"] + carried_stream_maps(args.input, output, chapters=False) # their fonts (A256)
1791
1791
  for n in range(len(added)):
1792
1792
  maps += ["-map", f"{n + 1}:0"]
1793
1793
  cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
package/scripts/color.py CHANGED
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import List, Optional
34
34
 
35
- from _common.runner import fail_if_classified
35
+ from _common.runner import carried_stream_maps, fail_if_classified
36
36
  from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
37
37
  from _common.emit import note, warn
38
38
  from _common.color import is_rgb, rgb_to_bt709_filter
@@ -329,7 +329,8 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
329
329
  ext = os.path.splitext(output)[1].lower()
330
330
  tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
331
331
  bsf = retag_bsf(meta, args.retag)
332
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy"] + bsf + tags
332
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
333
+ + ["-c", "copy"] + bsf + tags)
333
334
  if ext in (".mp4", ".mov", ".m4v"):
334
335
  cmd += ["-movflags", "+faststart"]
335
336
  cmd.append(output)
@@ -357,8 +358,9 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
357
358
  info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
358
359
  label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
359
360
  enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
360
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
361
- "-map", "0:s?", "-map", "0:d?"] + label + enc + ["-c:s", "copy", "-c:d", "copy", output]
361
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
362
+ "-map", "0:s?", "-map", "0:d?"] + carried_stream_maps(args.input, output) + label + enc
363
+ + ["-c:s", "copy", "-c:d", "copy", output])
362
364
  proc2 = run(cmd, check=False)
363
365
  fail_if_classified(proc2)
364
366
  if proc2.returncode != 0:
@@ -552,7 +554,8 @@ def main() -> int:
552
554
  die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
553
555
  if not v.get("dolby_vision"):
554
556
  warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
555
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
557
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
558
+ + ["-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"])
556
559
  if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
557
560
  cmd += ["-movflags", "+faststart"]
558
561
  cmd.append(output)
package/scripts/crop.py CHANGED
@@ -62,7 +62,9 @@ def main() -> int:
62
62
  has_audio = bool(meta.get("audio"))
63
63
 
64
64
  output = args.output or default_output(args.input, "crop")
65
- vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
65
+ # exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
66
+ # and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
67
+ vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
66
68
  if square_pixels(meta["video"]):
67
69
  vf.insert(0, square_pixels(meta["video"]))
68
70
  note(square_pixels_note(meta["video"]))
package/scripts/export.py CHANGED
@@ -46,7 +46,7 @@ import tempfile
46
46
  from pathlib import Path
47
47
  from typing import Dict, List, Optional
48
48
 
49
- from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
49
+ from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
50
50
  from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
@@ -321,15 +321,19 @@ _subtitle_codec = subtitle_codec_for
321
321
 
322
322
  def _data_streams(path: str) -> List[Dict[str, object]]:
323
323
  """The data streams of a file ({index, codec}), without its attachments (probe()'s
324
- data_streams counts both, and -map 0:d maps only the data streams)."""
324
+ data_streams counts both, and -map 0:d maps only the data streams) and without an MP4/MOV
325
+ chapter track, which the muxer writes again from the chapters (carried_stream_maps, A173)."""
325
326
  proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
326
- "stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
327
+ "stream=codec_name,codec_tag_string:chapter=id", "-of", "json", path], quiet=True)
327
328
  try:
328
- streams = json.loads(proc.stdout or "{}").get("streams") or []
329
+ doc = json.loads(proc.stdout or "{}")
329
330
  except ValueError:
330
331
  # an unread list would map nothing and report nothing dropped
331
332
  die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
332
- return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)]
333
+ streams = doc.get("streams") or []
334
+ chapter_track = bool(doc.get("chapters"))
335
+ return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)
336
+ if not (chapter_track and str(s.get("codec_tag_string") or "").lower() == "text")]
333
337
 
334
338
 
335
339
  def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
@@ -338,10 +342,13 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
338
342
  subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
339
343
  with the attempt's error. `cmd` already maps the picture and the sound and has no output.
340
344
  Returns the stream types dropped ([] when everything mapped was written)."""
345
+ src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
341
346
  sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
342
347
  for n, codec in enumerate(sub_codecs):
343
348
  sub_args += [f"-c:s:{n}", codec]
344
- data_args = ["-map", "0:d", "-c:d", "copy"] if data else []
349
+ if sub_codecs:
350
+ sub_args += carried_stream_maps(src, output, chapters=False) # the fonts the subtitles are drawn with (A256)
351
+ data_args = (["-map", "0:d"] + carried_stream_maps(src, output, attachments=False) + ["-c:d", "copy"]) if data else []
345
352
  rungs = [(sub_args + data_args, [])]
346
353
  if sub_codecs and data:
347
354
  rungs.append((sub_args, ["data"]))
@@ -389,7 +396,9 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
389
396
  written_data = _data_streams(output)
390
397
  for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
391
398
  for n, s in enumerate(streams):
392
- row = {"type": kind, "index": n, "codec": s.get("codec")}
399
+ # a data row keeps its n-th-of-type index in the source: the chapter text track is left
400
+ # out of the list, so a timecode track after it is data stream 1, not 0
401
+ row = {"type": kind, "index": s.get("index", n) if kind == "data" else n, "codec": s.get("codec")}
393
402
  if kind == "subtitle":
394
403
  row["language"] = s.get("language")
395
404
  if STATE.dry_run and kind not in dropped:
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
 
@@ -67,7 +67,8 @@ def main() -> int:
67
67
  # no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
68
68
  # and the hold lost, so hold the last frame as --mode extend does (#416 review)
69
69
  vend = v0 + (meta["video"].get("duration") or dur - v0)
70
- if args.mode == "extend" or cut_at >= vend - 0.5 / rate:
70
+ holds_end = args.mode == "extend" or cut_at >= vend - 0.5 / rate
71
+ if holds_end:
71
72
  # tpad's stop_duration clones the last frame for the given duration; no PTS surgery
72
73
  # needed since it only ever appends past the real end.
73
74
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
@@ -82,7 +83,9 @@ def main() -> int:
82
83
  vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
83
84
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
84
85
  if has_audio:
85
- 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))]
86
89
  else:
87
90
  # freeze N frames at `at` by holding on that one source frame for --hold seconds, then
88
91
  # resuming the rest of the clip: split the timeline at `at` and hold the remainder's
@@ -127,10 +130,12 @@ def main() -> int:
127
130
  cmd += aac_args()
128
131
  else:
129
132
  cmd += ["-an"]
130
- if args.mode == "insert":
133
+ if not holds_end:
131
134
  # the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
132
135
  # old timestamps and every cue after the freeze would fire early (same reasoning as
133
- # fit.py --method speed). Drop it rather than ship captions that lie.
136
+ # fit.py --method speed). Drop it rather than ship captions that lie. A hold of the last
137
+ # frame (the default with no --at, or an --at at the end) moves nothing, so the track is
138
+ # kept as --mode extend keeps it (A180)
134
139
  run(cmd + [output])
135
140
  dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
136
141
  else:
@@ -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
@@ -564,7 +573,9 @@ def main(force_route: Optional[str] = None) -> int:
564
573
  rise = int(fs * 0.5)
565
574
  pop = f"min(1,(t-{s:.3f})/0.25)"
566
575
  xe = f"{m_left}" if "left" in pos else f"w-text_w-{m_right}"
567
- ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}-{rise}*(1-{pop})")
576
+ # every position rises into place, on both routes: a bottom chip started `rise` px above
577
+ # its rest on drawtext and dropped, where the ASS \move below rises (A177)
578
+ ye = (f"{m_top}+{rise}*(1-{pop})" if "top" in pos else f"h-text_h-{m_bottom}+{rise}*(1-{pop})")
568
579
  alpha = f"min({pop},{fade_a})"
569
580
  align = (7 if "left" in pos else 9) if "top" in pos else (1 if "left" in pos else 3)
570
581
  y_rest = m_top if "top" in pos else H - m_bottom
@@ -591,7 +602,7 @@ def main(force_route: Optional[str] = None) -> int:
591
602
  if args.duration <= 0:
592
603
  die(f"--duration must be > 0, got {args.duration:g}")
593
604
  he = min(e, s + args.duration)
594
- hen = f"enable='between(t,{s:.3f},{he:.3f})'"
605
+ hen = f"enable='{window_expr(s, he, meta)}'"
595
606
  h1 = int(base * 0.085)
596
607
  bar_h = max(3, int(base * 0.01))
597
608
  hook_title = wrapped(args.title, h1)
@@ -640,7 +651,7 @@ def main(force_route: Optional[str] = None) -> int:
640
651
  ke = ks + seg
641
652
  pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
642
653
  add_text(str(k),
643
- 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)}'",
644
655
  size=fs, color=primary, font=ass_font_family(), align=5, x=W / 2, y=H / 2,
645
656
  outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
646
657
  scale_t=(0, seg * 500, 115))
@@ -764,7 +775,7 @@ def main(force_route: Optional[str] = None) -> int:
764
775
  drawboxes = []
765
776
  for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
766
777
  if as_layers:
767
- 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)))
768
779
  drawboxes.append(f"drawbox={geo}:color={ff_color(colour, alpha)}:t=fill:{enable}")
769
780
  filters = [("" if as_layers else drawboxes[int(f[len(BOX):])]) if f.startswith(BOX) else f for f in filters]
770
781
  filters = [f for f in filters if f]
package/scripts/grid.py CHANGED
@@ -26,7 +26,7 @@ import os
26
26
  import sys
27
27
 
28
28
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
29
- as_limited, draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
29
+ as_limited, draws_chroma, drawn_chain, drawtext_text_opts, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args
30
30
  from _common.fonts import font_choice, record_font_choice
31
31
  from _common import drawtext_fontfile # noqa: E402
32
32
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
@@ -173,7 +173,9 @@ def main() -> int:
173
173
  if args.label == "auto":
174
174
  font_file = label_fonts[i]["file"]
175
175
  font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
176
- drawn.append((f"drawtext=text='{escape_drawtext(labels[i])}':{font_opt}:fontsize={args.font_size}:"
176
+ # the textfile= route every other drawn text takes: escape_drawtext() is for a font name,
177
+ # and dropped an apostrophe or a percent sign from a file name's label (A178)
178
+ drawn.append((f"drawtext={drawtext_text_opts(labels[i])}:{font_opt}:fontsize={args.font_size}:"
177
179
  f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
178
180
  f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
179
181
  gap_r = args.gap if i % args.cols < args.cols - 1 else 0
package/scripts/loop.py CHANGED
@@ -34,7 +34,7 @@ import tempfile
34
34
 
35
35
  from _common import STATE, concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_filter, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
36
36
  from _common.emit import warn
37
- from _common.decision import av_trim, length_output_t
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
39
  from _common.emit import input_length
40
40
 
@@ -60,9 +60,8 @@ def _boomerang_graph(meta, src_dur: float, times) -> str:
60
60
  f"-- cut the clip shorter first (cut.py)")
61
61
  # reverse holds the clip's decoded frames; split, trim and loop pass references to the same
62
62
  # frames, so the peak is about one clip (measured: the same max RSS as a plain reverse)
63
- bytes_per_frame = v["width"] * v["height"] * 1.5 * (2 if (v.get("bit_depth") or 8) > 8 else 1)
64
- held = bytes_per_frame * frames
65
- if held > 2 * 1024 ** 3:
63
+ held = held_frames_bytes(v, frames)
64
+ if held > HELD_FRAMES_WARN_BYTES:
66
65
  warn(f"--boomerang holds about {held / 1024 ** 3:.1f} GiB of decoded frames in memory "
67
66
  f"({frames} frames of {v['width']}x{v['height']}) -- cut the clip shorter first (cut.py) if RAM is tight",
68
67
  "memory_heavy", bytes=int(held))
@@ -160,7 +159,12 @@ def _seamless_unit_cmd(meta, src: str, unit: str, rate: str, frames: int, fade_f
160
159
  if has_audio:
161
160
  # acrossfade writes nothing when its first input is exactly the fade long (FFmpeg 6.1 and
162
161
  # 7.1, measured), so the fade is built by hand: tail faded out + head faded in, summed
163
- graph += (f";[0:a:0]aresample=async=1:first_pts=0,apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
162
+ # the sound is shifted by the picture's start, the constant setpts=PTS-STARTPTS takes off the
163
+ # picture: rebased to its own start (aresample's first_pts=0 alone), a picture that starts
164
+ # 0.2 s after its sound came out 200 ms behind it in every repeat (A168)
165
+ v0 = stream_starts(meta)[0]
166
+ graph += (f";[0:a:0]atrim=start={v0:.6f},asetpts=PTS-{v0:.6f}/TB,aresample=async=1:first_pts=0,"
167
+ f"apad,atrim=end={length:.6f},asplit=3[at][ah][ab];"
164
168
  f"[at]atrim=start={cut:.6f},asetpts=PTS-STARTPTS,afade=t=out:d={fade:.6f}:curve=tri[ta];"
165
169
  f"[ah]atrim=end={fade:.6f},asetpts=PTS-STARTPTS,afade=t=in:d={fade:.6f}:curve=tri[ha];"
166
170
  f"[ta][ha]amix=inputs=2:normalize=0:duration=longest[xa];"
@@ -292,6 +296,8 @@ def main() -> int:
292
296
  die(f"--duration ({target:g}s) must be longer than one repeat ({unit:.3f}s) -- use cut.py to trim instead")
293
297
  stream_loop = math.ceil(target / unit) - 1
294
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)
295
301
  if args.boomerang:
296
302
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", _boomerang_graph(meta, src_dur, args.times), "-map", "[v]"]
297
303
  if meta.get("audio"):
@@ -314,6 +320,18 @@ def main() -> int:
314
320
  fh.write((concat_list_line(repeat) + "\n") * (stream_loop + 1))
315
321
  vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
316
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}"
317
335
  if has_audio:
318
336
  cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
319
337
  "-map", "[v]", "-map", "[a]"]
@@ -325,7 +343,7 @@ def main() -> int:
325
343
  cmd += video_args(meta, args.crf, args.preset)
326
344
  # a crossfade's repeat is already constant at `rate` (its graph's fps=): the loop keeps that rate,
327
345
  # not a variable-rate source's average, which re-timed every repeat by a frame or two
328
- 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
329
347
  if has_audio:
330
348
  cmd += aac_args()
331
349
  else:
@@ -35,6 +35,7 @@ from _common import (guard_sidecar, add_common, apply_common, default_output, de
35
35
  die, emit, ffmpeg_base, fmt_chapter_time, info, propose_chapters, time_arg, probe, run,
36
36
  STATE, read_text_or_die)
37
37
  from _common.emit import input_length
38
+ from _common.runner import carried_stream_maps
38
39
 
39
40
  CHAPTER_CONTAINERS = {".mp4", ".m4v", ".m4a", ".mov", ".mkv", ".mka", ".webm"}
40
41
  TAG_KEYS = ("title", "artist", "album", "comment", "date", "genre")
@@ -267,6 +268,9 @@ def main() -> int:
267
268
  cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "-1"]
268
269
  else:
269
270
  cmd += ["-map", "0", "-map_metadata", "0", "-map_chapters", "0"]
271
+ # not the source's MP4/MOV chapter track: the muxer writes one from the chapters it is given,
272
+ # and the copy was a stray data track holding the old titles (A173)
273
+ cmd += carried_stream_maps(args.input, output, attachments=False)
270
274
  for key, value in tags.items():
271
275
  cmd += ["-metadata", f"{key}={value}"]
272
276
  cmd += ["-c", "copy"]