ffmpeg-skill 3.8.11 → 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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.11`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.12`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.8.11", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.12", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.11",
3
+ "version": "3.8.12",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
6
6
  "keywords": [
@@ -176,6 +176,16 @@ def fmt_secs(value: Optional[float]) -> str:
176
176
  return "?s" if value is None else f"{value:.3f}s"
177
177
 
178
178
 
179
+ def ntsc_rate(fps: float) -> float:
180
+ """`fps` as the rate it is a rounding of: 23.976, 29.97, 47.952, 59.94 and 119.88 (what
181
+ probe() reports, and what a caller types after `@`) are N * 1000 / 1001. Any other rate is
182
+ returned as it is. timeline.exact_rate() reads the same five rates the same way."""
183
+ whole = round(fps * 1.001)
184
+ if whole in (24, 30, 48, 60, 120) and abs(fps - whole / 1.001) < 0.01 and abs(fps - whole) > 0.01:
185
+ return whole * 1000 / 1001
186
+ return fps
187
+
188
+
179
189
  def parse_time(value: str, fps: Optional[float] = None) -> float:
180
190
  """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
181
191
  or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
@@ -212,7 +222,10 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
212
222
  # 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
213
223
  # disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
214
224
  total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
215
- return total_frames / fps
225
+ # at the picture's exact rate: probe()'s `fps` is rounded to three decimals, and frame 37
226
+ # counted at 29.97 is 1.2345679 s, 1.2 microseconds past the frame at 37 * 1001 / 30000,
227
+ # so a start read from 00:00:01:07 was the frame after it (ntsc_rate)
228
+ return total_frames / ntsc_rate(fps)
216
229
  if len(parts) > 3:
217
230
  raise ValueError(f"bad time: {value}")
218
231
  total = 0.0
@@ -315,7 +328,9 @@ def fmt_smpte_time(seconds: float, fps: float) -> str:
315
328
  if seconds < 0:
316
329
  seconds = 0.0
317
330
  whole_fps = int(round(fps))
318
- total_frames = int(round(seconds * fps))
331
+ # the frames are counted at the rate parse_time() reads the same text at (29.97 as
332
+ # 30000/1001): at 29.97 itself 06:00:00:00 came back as 05:59:59:29
333
+ total_frames = int(round(seconds * ntsc_rate(fps)))
319
334
  frame = total_frames % whole_fps
320
335
  secs_total = total_frames // whole_fps
321
336
  h, rem = divmod(secs_total, 3600)
@@ -440,6 +455,56 @@ def grid_at_or_after(t: float, v0: float, rate: float) -> float:
440
455
  return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
441
456
 
442
457
 
458
+ def frame_window(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
459
+ half_open: bool = False) -> Optional[Tuple[Optional[float], Optional[float]]]:
460
+ """The two times a filter compares a frame's `t` with so that a window from `start` to `end`
461
+ seconds of `meta`'s picture holds the frames asked: half a frame before the first frame at or
462
+ after `start`, and half a frame after the last frame at or before `end` (`half_open`: half a
463
+ frame before the first frame at or after `end`, which is then outside). A bound written as
464
+ the time itself is compared with `t` on the frame it names: `{:.3f}` put frame 37 of a
465
+ 30000/1001 picture (1.234567 s) after a start of "1.235", and `t` is a double (pts x time
466
+ base) that is not always the number the text reads as (the frame at 1.4 s of a 25 fps MP4 is
467
+ not `<= 1.400`; FFmpeg 6.1.1). The grid is the one grid_at_or_after() counts on. None when
468
+ there is no grid to count on (no rate: a dry run's pending input; a picture probe() suspects
469
+ of a variable rate): the caller keeps the text it had. A bound of None stays None."""
470
+ v = (meta or {}).get("video") or {}
471
+ rate = frame_rate(meta)
472
+ if not rate or v.get("variable_frame_rate_suspected"):
473
+ return None
474
+ v0, half = stream_starts(meta)[0], 0.5 / rate
475
+ lo = None if start is None else grid_at_or_after(start, v0, rate) - half
476
+ if end is None:
477
+ hi = None
478
+ elif half_open:
479
+ hi = grid_at_or_after(end, v0, rate) - half
480
+ else:
481
+ hi = v0 + math.floor((end - v0) * rate + 1e-6) / rate + half
482
+ return lo, hi
483
+
484
+
485
+ def window_expr(start: Optional[float], end: Optional[float], meta: Optional[Dict[str, Any]],
486
+ half_open: bool = False) -> str:
487
+ """The expression that is true on the frames of frame_window(): `between(t,LO,HI)`, or
488
+ `gte(t,LO)` with no end. With no start the window opens at 0, as it always did. Without a
489
+ grid it is the millisecond text the tools wrote before."""
490
+ bounds = frame_window(start, end, meta, half_open)
491
+ if bounds is None:
492
+ lo = f"{start:.3f}" if start is not None else "0"
493
+ return f"gte(t,{lo})" if end is None else f"between(t,{lo},{end:.3f})"
494
+ lo = f"{bounds[0]:.6f}" if bounds[0] is not None else "0"
495
+ return f"gte(t,{lo})" if bounds[1] is None else f"between(t,{lo},{bounds[1]:.6f})"
496
+
497
+
498
+ def windowed_overlay_x(x: Any, window: str) -> str:
499
+ """An `overlay` x that puts the layer at `x` on the frames `window` is true on and off the
500
+ picture on every other frame. overlay's `enable` is not the main picture's alone: FFmpeg
501
+ evaluates it on each frame either input hands over, the layer's next one included, so a still
502
+ looped at 25 fps over a 30000/1001 picture was drawn from the frame before the window's
503
+ first to the frame before its last (36-66 for 37-67; FFmpeg 6.1.1). The position is
504
+ evaluated once per output frame with the main picture's `t`."""
505
+ return f"'if({window},{x},-w)'"
506
+
507
+
443
508
  def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
444
509
  """The video and the audio filters that cut one segment [start, end) of the graph clock out of
445
510
  both streams and shift both by the same constant, so an offset between them survives; `end`
@@ -536,6 +601,28 @@ def silence_filter(rate: Optional[int], lead: Optional[float] = None, tail: Opti
536
601
  return ",".join(parts)
537
602
 
538
603
 
604
+ def tpad_length(seconds: float, meta: Optional[Dict[str, Any]]) -> float:
605
+ """How long the picture `tpad=start_duration={seconds:.3f}` puts ahead of `meta`'s clip is:
606
+ tpad adds whole frames, the nearest count to the text it is given (0.23425 s at 25 fps is
607
+ written "0.234" and becomes 6 frames, 0.240 s). A sound delayed by `seconds` itself would
608
+ start up to half a frame before or after the picture it belongs to; delayed by this length it
609
+ moves with it (both streams of a clip shift by one constant: #409). `seconds` as it is when
610
+ there is no rate to count frames at (a dry run's pending input) or probe() suspects a
611
+ variable one."""
612
+ v = (meta or {}).get("video") or {}
613
+ rate = frame_rate(meta)
614
+ if not rate or v.get("variable_frame_rate_suspected"):
615
+ return seconds
616
+ try:
617
+ exact = Fraction(str(v.get("r_frame_rate") or ""))
618
+ except (ValueError, ZeroDivisionError):
619
+ exact = None
620
+ if exact is None or abs(float(exact) - rate) > 1e-6:
621
+ exact = Fraction(rate).limit_denominator(1001)
622
+ frames = math.floor(Fraction(f"{seconds:.3f}") * exact + Fraction(1, 2)) # a half rounds up, as av_rescale does
623
+ return float(frames / exact)
624
+
625
+
539
626
  def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
540
627
  """The one length a clip holds once both streams start at its first frame (av_trim from the
541
628
  video's start): the video's, or where the sound ends when it runs more than a frame past the
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,7 +112,19 @@ def main() -> int:
105
112
  dur_b = input_length(meta_b)
106
113
  if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
107
114
  die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
108
- 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})
109
128
  # overlap is a property of the windows in time, not of the order they were given in: compared
110
129
  # against the one given before it, a later window listed first was called overlapping (A179)
111
130
  order = sorted(range(len(cutaways)), key=lambda k: cutaways[k]["at"])
@@ -123,7 +142,7 @@ def main() -> int:
123
142
  cmd += ["-i", c["path"]]
124
143
 
125
144
  # Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
126
- # --at time, and overlaid on A; 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
127
146
  # A shows through unchanged, so A's timeline is never touched.
128
147
  parts: List[str] = []
129
148
  cur = "[0:v]"
@@ -144,9 +163,32 @@ def main() -> int:
144
163
  + drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
145
164
  tag=f"pad{i}", labelled=True))
146
165
  b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
147
- parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
148
- 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}]")
149
- 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}]")
150
192
  cur = f"[v{i}]"
151
193
  vout = cur
152
194
 
@@ -158,24 +200,40 @@ def main() -> int:
158
200
  aout = "0:a:0" if has_audio_a else None
159
201
  else:
160
202
  layers: List[str] = []
203
+ # the cutaway's sound follows its picture: it starts with the window's first frame and
204
+ # lasts as long as the window, counted in samples of the mix (without a frame grid:
205
+ # --at and the length asked)
206
+ for c in cutaways:
207
+ c["s0"] = round((c["at"] if c["on"] is None else c["on"]) * MIX_RATE)
208
+ c["sn"] = round((c["length"] if c["on"] is None else c["span"]) * MIX_RATE)
161
209
  if has_audio_a:
162
- 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"
163
219
  parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
164
220
  layers.append("[a0]")
165
221
  for i, c in enumerate(cutaways):
166
222
  # B's sound is shifted by the same constant as its picture, whose first frame (the
167
- # first at or after --from) lands on --at; rebasing the sound to its own first
168
- # sample moved a track that starts 0.3 s late 0.279 s early (#409)
169
- v0b = stream_starts(c["meta"])[0]
170
- first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
171
- # the delay is --at to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
223
+ # first at or after --from) lands on the window's first frame; rebasing the sound
224
+ # to its own first sample moved a track that starts 0.3 s late 0.279 s early (#409)
225
+ first, length = c["first"], c["length"] if c["on"] is None else c["span"]
226
+ # the delay is to the sample of the mix's rate: `int(at * 1000)` cut 1.001 to
172
227
  # 1000 ms and wrote 1.2346 s as 1234 ms, the cutaway's sound 48 and 29 samples
173
228
  # early. A whole millisecond keeps the text it had, rounded (silence_filter)
174
- delay = silence_filter(MIX_RATE, lead=c["at"])
229
+ lead = c["s0"] / MIX_RATE
230
+ delay = silence_filter(MIX_RATE, lead=lead)
175
231
  if "S:" not in delay:
176
- delay = f"adelay={round(c['at'] * 1000)}|{round(c['at'] * 1000)}"
177
- parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
178
- 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}]")
179
237
  layers.append(f"[ab{i}]")
180
238
  # the mix ends with the A-roll, to the sample (#493): every layer is 48 kHz, so the count
181
239
  # is in that rate. `atrim=duration={:.3f}` and `-t {:.3f}` cut it to the millisecond. A
package/scripts/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
@@ -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:
@@ -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"]
package/scripts/sync.py CHANGED
@@ -441,8 +441,12 @@ def main() -> int:
441
441
  # same command, and the seek stays next to the -i it drops the margin from
442
442
  cmd += ["-i", args.second] + out_seek + default_selection_maps(second_meta, output)
443
443
  af_parts = on_clock + drift_af
444
- if delay_ms > 0:
445
- af_parts.append(f"adelay={delay_ms}:all=1")
444
+ # one sound is written here (the default selection, or the probed track of a file
445
+ # with several: default_selection_maps), the one sec_sr is the rate of, so the delay
446
+ # is counted in its samples as --replace-audio's is. A whole millisecond (the
447
+ # default --fine-ms 1 without --fix-drift) keeps `adelay=<ms>:all=1`
448
+ if offset > 0 and round(offset * sec_sr):
449
+ af_parts.append(silence_filter(sec_sr, lead=offset))
446
450
  if has_video:
447
451
  vf = []
448
452
  if delay_ms > 0: