ffmpeg-skill 3.6.1 → 3.6.3

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/join.py CHANGED
@@ -36,9 +36,11 @@ audio stream is never silent.
36
36
  """
37
37
  import argparse
38
38
  import json
39
+ import math
39
40
  import os
40
41
  import tempfile
41
42
  import sys
43
+ from fractions import Fraction
42
44
  from typing import Any, Dict, List, Optional, Tuple
43
45
 
44
46
  from _common.probe import SILENT_PEAK_DBFS, signatures_match, stream_signature
@@ -46,6 +48,7 @@ from _common import STATE, AUDIO_MEDIA_EXT, concat_list_line, guard_sidecar, pla
46
48
  from _common.emit import note, warn
47
49
  from _common.decision import av_clip_length, av_trim, stream_starts
48
50
  from _common.probe import display_size, square_pixels, square_pixels_note
51
+ from _common.timeline import exact_rate
49
52
 
50
53
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
51
54
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -112,6 +115,81 @@ def clip_length(meta: Dict[str, Any], fps: float) -> float:
112
115
  return av_clip_length(meta, fps)
113
116
 
114
117
 
118
+ # the video join's sound rate (every clip is resampled to it before the trims)
119
+ JOIN_AUDIO_RATE = 48000
120
+
121
+
122
+ def output_rate(fps: float, first: Dict[str, Any], asked: bool) -> Fraction:
123
+ """The join's frame rate as an exact fraction: the first clip's own r_frame_rate/avg_frame_rate
124
+ when `fps` is that clip's (30000/1001, not 29.97), else the NTSC fraction or the plain one
125
+ `fps` names (timeline.exact_rate). Every clip length, offset and sound length is counted in
126
+ frames of this rate, so the graph's fps filter runs at the same fraction."""
127
+ if not asked:
128
+ for key in ("r_frame_rate", "avg_frame_rate"):
129
+ try:
130
+ q = Fraction(str(first.get(key) or ""))
131
+ except (ValueError, ZeroDivisionError):
132
+ continue
133
+ # the declared rate also wins when the average was taken over a length that leaves out
134
+ # the last frame's duration (#475: 5 frames in 0.1333 s read 37.5 fps, and the whole
135
+ # join ran at 75/2)
136
+ if 0 < q <= 240 and (abs(float(q) - fps) < 1e-3 or short_last_frame(first, q)):
137
+ return q
138
+ return exact_rate(fps)
139
+
140
+
141
+ def short_last_frame(video: Optional[Dict[str, Any]], q: Fraction) -> bool:
142
+ """Whether a picture's duration is its frame count less one frame at rate `q`: a container
143
+ that stores no duration for the last frame (MOV written with -fps_mode passthrough, seen on
144
+ FFmpeg 7.1) reads 5 frames as 0.1333 s at 30 fps (#475)."""
145
+ v = video or {}
146
+ nb, dur = v.get("nb_frames"), v.get("duration")
147
+ if not nb or nb < 2 or not dur or q <= 0:
148
+ return False
149
+ step = float(1 / q)
150
+ return abs(dur - (nb - 1) * step) < 0.25 * step and abs(dur - nb * step) > 0.25 * step
151
+
152
+
153
+ def with_whole_picture(meta: Dict[str, Any], q: Fraction) -> Dict[str, Any]:
154
+ """`meta` with its picture counted as every frame it holds when its duration leaves out the
155
+ last one (short_last_frame), so the clip-length rule does not read the sound as running a
156
+ frame past the picture and hold the clip longer (#475). Other clips are returned as they are."""
157
+ v = meta.get("video") or {}
158
+ # each clip at its OWN declared rate: at the join's rate a complete 30 fps clip of 5 frames
159
+ # (5/30 s) also equals 4 frames at --fps 24 and would be stretched to 5/24 s
160
+ try:
161
+ own = Fraction(str(v.get("r_frame_rate") or ""))
162
+ except (ValueError, ZeroDivisionError):
163
+ own = q
164
+ if not 0 < own <= 240:
165
+ own = q
166
+ if not short_last_frame(v, own):
167
+ return meta
168
+ return dict(meta, video=dict(v, duration=float(v["nb_frames"] / own)))
169
+
170
+
171
+ def clip_frames(meta: Dict[str, Any], length: float, q: Fraction) -> int:
172
+ """How many frames of rate `q` a clip of `length` seconds (clip_length) holds in the join: its
173
+ picture's frames, or, when the clip lasts as long as its sound, enough frames to cover that
174
+ sound (narration is never cut; a hundredth of a frame is rounding, not sound). A pending
175
+ dry-run stub's 0 s stays 0; a measured clip holds at least one frame (#455: a length in
176
+ milliseconds rounded up started every next clip late, and the errors added up)."""
177
+ exact = length * q.numerator / q.denominator
178
+ vlen = (meta.get("video") or {}).get("duration") or 0.0
179
+ frames = math.ceil(exact - 0.01) if vlen and length > vlen else round(exact)
180
+ return frames if meta.get("dry_run") else max(1, frames)
181
+
182
+
183
+ def frame_time(frames: int, q: Fraction) -> str:
184
+ """The time of frame `frames` at rate `q` in seconds, rounded down to the microsecond: never
185
+ after that frame's own timestamp, so a filter that starts on the first frame at or after it
186
+ (xfade's offset) starts on that frame and not the next (#468: 2.566667 written 2.567 started
187
+ the transition a frame late). Trailing zeros are dropped (0.5, not 0.500000)."""
188
+ us = (frames * q.denominator * 1_000_000) // q.numerator
189
+ text = f"{us // 1_000_000}.{us % 1_000_000:06d}".rstrip("0").rstrip(".")
190
+ return text or "0"
191
+
192
+
115
193
  def find_duplicates(paths: List[str]) -> List[Dict[str, Any]]:
116
194
  """Every path (compared by its resolved absolute form) listed more than once, with the 0-based
117
195
  list positions it appears at. A warning, not a refusal: repeating a clip can be intentional."""
@@ -543,6 +621,10 @@ def main() -> int:
543
621
  apply_common(args)
544
622
  if args.fps is not None and args.fps <= 0:
545
623
  die(f"--fps must be positive, got {args.fps:g}")
624
+ # nan and inf are refused by the parser (finite_float); a negative length was taken as the
625
+ # one-frame minimum and the join succeeded with a transition nobody asked for
626
+ if args.duration < 0:
627
+ die(f"--duration must be 0 or more seconds, got {args.duration:g}")
546
628
 
547
629
  if args.list:
548
630
  if args.inputs:
@@ -666,6 +748,9 @@ def main() -> int:
666
748
  w, h = fw, fh
667
749
  fps = args.fps or first.get("fps") or 30.0
668
750
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
751
+ # the exact rate every length below is counted in (30000/1001, not 29.97)
752
+ rate_q = output_rate(fps, first, bool(args.fps))
753
+ fps = float(rate_q)
669
754
  w, h = w - (w % 2), h - (h % 2)
670
755
  note_short_segments(args.inputs, metas, MIN_SEGMENT_FRAMES / fps, f"{MIN_SEGMENT_FRAMES} frames at {fps:g} fps")
671
756
  durs = [m.get("duration") or 0.0 for m in metas]
@@ -675,11 +760,37 @@ def main() -> int:
675
760
  # its picture and its sound together. The plain cut used to take the raw streams, and a sound
676
761
  # past the picture (a music bed padded to the container, even a 20 ms AAC tail) left a hole in
677
762
  # the video there.
678
- lens = [clip_length(m, fps) for m in metas]
763
+ # Each length is a whole number of frames of the output rate, and each sound length the
764
+ # samples those frames last (#455): a length in milliseconds rounded 5 frames at 30 fps
765
+ # (0.166667 s) up to 0.167 s, the concat filter started the next clip after it, and 60 such
766
+ # clips left one-frame holes from about 8 s on. Sample counts come from the running frame
767
+ # total, so the sound never drifts from the picture by more than one sample.
768
+ lmetas = [with_whole_picture(m, rate_q) for m in metas]
769
+ frames = [clip_frames(m, clip_length(m, fps), rate_q) for m in lmetas]
770
+ # the transition is a whole number of frames too, so xfade's offsets (and acrossfade's
771
+ # overlap) land on frames of the output rate (#468). A transition is at least one frame:
772
+ # --duration 0 used to build acrossfade=ns=0, which FFmpeg refuses
773
+ tframes = max(1, round(d * fps)) if args.transition != "none" else 0
774
+ d = tframes / fps
775
+ lens = [f / fps for f in frames]
679
776
  place_silent(lens, d)
680
- for p, dur in zip(args.inputs, lens):
681
- if d and dur <= d * 2 and not STATE.dry_run:
682
- die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
777
+ for p, nf in zip(args.inputs, frames):
778
+ if d and nf <= tframes * 2 and not STATE.dry_run:
779
+ die(f"{p} is only {nf / fps:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
780
+ # every sample boundary is a frame position of the OUTPUT rounded once: clip i spans output
781
+ # frames [start_i, start_i + frames_i) and overlaps the clip before it over [start_i,
782
+ # start_i + tframes), so its samples and each overlap's are differences of rounded
783
+ # cumulative positions and their sum telescopes to the output's own length. Rounding each
784
+ # overlap on its own (14414.4 samples -> 14414 at 30000/1001 and 9 frames) left the sound
785
+ # 0.4 samples longer per transition.
786
+ def at(frame: int) -> int:
787
+ return round(frame * JOIN_AUDIO_RATE / rate_q)
788
+ starts, start = [], 0
789
+ for i, nf in enumerate(frames):
790
+ starts.append(start)
791
+ start += nf - tframes
792
+ samples = [at(s0 + nf) - at(s0) for s0, nf in zip(starts, frames)]
793
+ overlaps = [at(s0 + tframes) - at(s0) for s0 in starts]
683
794
 
684
795
  cmd = ffmpeg_base()
685
796
  extra_inputs: List[str] = []
@@ -745,7 +856,10 @@ def main() -> int:
745
856
  if len(set(chans)) > 1:
746
857
  info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
747
858
  for i in range(n):
748
- parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
859
+ # the fps filter's time base is one frame (1/rate), so every timestamp downstream is a
860
+ # frame number and an offset in seconds is rounded to a whole frame, never a microsecond
861
+ # off it on any FFmpeg (AVTB left xfade to compare microseconds)
862
+ parts.append(f"[{i}:v]{convert[i]}{geo(i)},setsar=1,setpts=PTS-STARTPTS,fps={rate_q.numerator}/{rate_q.denominator},format={pixfmt}[v{i}]")
749
863
  # the sound is shifted by the picture's start, not to its own (#409): #338 started each
750
864
  # clip's picture at 0, and a per-stream PTS-STARTPTS then moved a sound that starts 0.3 s
751
865
  # after its picture 0.3 s early. Sound before the first frame is dropped and a late sound
@@ -755,23 +869,29 @@ def main() -> int:
755
869
 
756
870
  # pad the shorter stream to the clip's length: the audio with silence, the picture by holding
757
871
  # its last frame, then cut both there -- each clip boundary is then the same for both chains
872
+ # -- by frame and by sample, never by rounded seconds (#455)
758
873
  for i in range(n):
759
- vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
760
- hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
761
- parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
762
- parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
874
+ vlen = (lmetas[i].get("video") or {}).get("duration") or lens[i]
875
+ have = round(vlen * fps)
876
+ # one frame more than the gap is held, and the trim cuts at the clip's last frame
877
+ hold = f"tpad=stop_mode=clone:stop={frames[i] - have + 1}," if frames[i] > have else ""
878
+ parts.append(f"[v{i}]{hold}trim=end_frame={frames[i]}[vp{i}]")
879
+ parts.append(f"[a{i}]apad=whole_len={samples[i]},atrim=end_sample={samples[i]}[ap{i}]")
763
880
  if args.transition == "none":
764
881
  chain = "".join(f"[vp{i}][ap{i}]" for i in range(n))
765
882
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
766
883
  else:
767
884
  vprev, aprev = "vp0", "ap0"
768
- offset = 0.0
885
+ start = 0
769
886
  for i in range(1, n):
770
- offset += lens[i - 1] - d
887
+ # clip i starts on frame `start` of the output; xfade starts on the first frame at or
888
+ # after its offset, so the offset is that frame's time rounded down (#468)
889
+ start += frames[i - 1] - tframes
771
890
  vout = f"vx{i}" if i < n - 1 else "vout"
772
891
  aout = f"ax{i}" if i < n - 1 else "aout"
773
- parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
774
- parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
892
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={frame_time(tframes, rate_q)}"
893
+ f":offset={frame_time(start, rate_q)}[{vout}]")
894
+ parts.append(f"[{aprev}][ap{i}]acrossfade=ns={overlaps[i]}:c1=tri:c2=tri[{aout}]")
775
895
  vprev, aprev = vout, aout
776
896
 
777
897
  # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
@@ -784,7 +904,7 @@ def main() -> int:
784
904
  "by its measured length")
785
905
  elif not d and any(m.get("dry_run") for m in metas):
786
906
  # the plain cut trims/holds every clip to clip_length too (2.4.1), so a pending clip's
787
- # planned trim=duration=0.000 is the stub's placeholder, not the cut
907
+ # planned trim=end_frame=0 is the stub's placeholder, not the cut
788
908
  STATE_NOTES.append("the planned trim lengths count each pending input as 0 s long: a real run trims "
789
909
  "or holds it to its measured length")
790
910
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
@@ -818,6 +938,7 @@ def main() -> int:
818
938
  info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
819
939
  overlap = overlap_report(lens, expected)
820
940
  emit(output, mode="video", **overlap, **reported(), clips=n, transition=args.transition, expected_duration=expected,
941
+ frame_rate=f"{rate_q.numerator}/{rate_q.denominator}", clip_frames=frames, transition_frames=tframes,
821
942
  verification=verification, expect_streams={"video": 1, "audio": 1}, stream_reference=metas,
822
943
  audio_substituted=substituted,
823
944
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
package/scripts/look.py CHANGED
@@ -23,7 +23,7 @@ from _common import STATE, require_tool, add_common, apply_common, default_font_
23
23
  from _common import drawtext_fontfile # noqa: E402
24
24
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
25
25
  from _common.emit import note, record_fallback # noqa: E402
26
- from _common.color import bt709_label_filter # noqa: E402
26
+ from _common.color import bt709_label_filter, bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note # noqa: E402
28
28
 
29
29
  # --guides: broadcast safe areas as outlines, in fractions of the frame (a tile as much as a frame).
@@ -177,6 +177,23 @@ def measure_ink(png_path: str, *, box=None, margin_frac: float = 0.03, threshold
177
177
  }
178
178
 
179
179
 
180
+ def display_conversion(meta: dict, what: str = "source") -> str:
181
+ """The filter that shows `meta`'s frames on an SDR BT.709 display, or "" for a picture that is
182
+ already one. HDR is tone-mapped so the agent judges representative colours, not raw HLG/PQ."""
183
+ v = meta.get("video") or {}
184
+ if not v.get("bt2020_or_hdr"):
185
+ return ""
186
+ # color.py --to-sdr's own chains at its defaults (hable, a 1000-nit PQ peak), so the sheet
187
+ # shows what that conversion gives. Through 3.6.0 this tone map linearised at npl=1000 and
188
+ # a sheet's white came out at about Y 151 of 235; a BT.2020 SDR source was tone-mapped
189
+ # too, where color.py only converts its gamut.
190
+ if is_bt2020_sdr(v):
191
+ info(f"BT.2020 SDR {what}: frames are converted to BT.709 for display")
192
+ return bt2020_sdr_to_bt709_chain(meta)
193
+ info(f"HDR {what}: frames are tone-mapped to SDR for display")
194
+ return hdr_to_sdr_chain({"video": dict(v, color_transfer=v.get("color_transfer") or "arib-std-b67")}, "hable")
195
+
196
+
180
197
  def main() -> int:
181
198
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
182
199
  ap.add_argument("input")
@@ -264,13 +281,10 @@ def main() -> int:
264
281
  boxes, guides = guide_boxes(list(dict.fromkeys(names)))
265
282
  safe_filter += "," + ",".join(boxes)
266
283
  tc = safe_filter + tc
267
- # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
268
- if meta["video"].get("bt2020_or_hdr"):
269
- v = meta["video"]
270
- tm = (f"zscale=tin={v.get('color_transfer') or 'arib-std-b67'}:pin={v.get('color_primaries') or 'bt2020'}:min={v.get('color_space') or 'bt2020nc'}:rin=tv:t=linear:npl=1000,"
271
- "format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
272
- tc = "," + tm.rstrip(",") + tc
273
- info("HDR source: frames are tone-mapped to SDR for display")
284
+ marks = tc # the boxes and the stamp, without the display conversion (each --compare input has its own)
285
+ tm = display_conversion(meta)
286
+ if tm:
287
+ tc = "," + tm + tc
274
288
  outputs: List[str] = []
275
289
  # An untagged SDR source is BT.709 (what players and every encoder line here take it as), but the
276
290
  # conversion to the PNG's RGB reads "unknown" as BT.601: C03028 came out B31F28 (#410). Its
@@ -293,6 +307,9 @@ def main() -> int:
293
307
  cw, ch = display_size(cmeta.get("video") or {})
294
308
  if cw and ch and cw / ch < 4 / 3 + 0.01:
295
309
  die(f"--guides 4:3 draws the 4:3 centre cut of a wider frame; the --compare clip is {cw}x{ch}")
310
+ # the second picture is converted for display by its own tags: through 3.6.1 the first
311
+ # input's tone map ran on both, so an SDR clip beside a PQ one was read as PQ
312
+ tm2 = display_conversion(cmeta, "--compare clip")
296
313
  lab2 = bt709_label_filter(cmeta)
297
314
  lab2 = lab2 + "," if lab2 else ""
298
315
  csq = (square_pixels(cmeta.get("video")) + ",") if square_pixels(cmeta.get("video")) else ""
@@ -310,15 +327,17 @@ def main() -> int:
310
327
  # the tone map and the --safe / --guides boxes go on each picture as scaled, before the
311
328
  # pad: drawn after it, a picture letterboxed in the box got guides from the padded
312
329
  # height (a 2:1 clip beside a 16:9 one); the timecode stamp is drawn on the box
313
- pre = tc.replace("," + timecode_filter(font_prefix), "")
330
+ pre = marks.replace("," + timecode_filter(font_prefix), "")
314
331
  # each picture keeps its own aspect inside a half-width box as tall as the taller of the
315
332
  # two (letterboxed, not stretched): through 3.1.3 scale2ref forced the first to the
316
333
  # second's size, so a 4:3 beside a 16:9 came out squashed (#411)
317
334
  box_h = max(_half_height(half, display_size(meta["video"])),
318
335
  _half_height(half, display_size(cmeta.get("video"))))
319
- fit = (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{pre},"
320
- f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
321
- fc = (f"[0:v]{lab}{sq}{fit}[a];[1:v]{lab2}{csq}{fit}[b];"
336
+ def fit(conv):
337
+ conv = "," + conv if conv else ""
338
+ return (f"scale={half}:{box_h}:force_original_aspect_ratio=decrease{conv}{pre},"
339
+ f"pad={half}:{box_h}:(ow-iw)/2:(oh-ih)/2:color=0x202020,setsar=1{stamp}")
340
+ fc = (f"[0:v]{lab}{sq}{fit(tm)}[a];[1:v]{lab2}{csq}{fit(tm2)}[b];"
322
341
  f"[a][b]hstack=inputs=2[out]")
323
342
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
324
343
  "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
@@ -28,12 +28,13 @@ Examples:
28
28
  import argparse
29
29
  import json
30
30
  import math
31
+ import os
31
32
  import sys
32
33
  from typing import Dict, List, Tuple
33
34
 
34
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
35
36
  from _common.emit import note, record_fallback, warn
36
- from _common.decision import av_trim
37
+ from _common.decision import av_trim, rebase_project_paths
37
38
  from _common.probe import display_size, square_pixels, square_pixels_note
38
39
  from sync import measure_offset
39
40
 
@@ -335,6 +336,10 @@ def main() -> int:
335
336
  # a replacement: the combined output below is still written as usual.
336
337
  project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
337
338
  "clips": project_clips}
339
+ # the inputs are named as given, relative to the working directory, and render.py reads a
340
+ # project's relative paths against the project file's directory: written elsewhere
341
+ # (--write-project out/cut.json), they named files that are not there
342
+ project = rebase_project_paths(project, os.getcwd())
338
343
  if not STATE.dry_run:
339
344
  with open(args.write_project, "w", encoding="utf-8") as fh:
340
345
  fh.write(json.dumps(project, indent=2) + "\n")
@@ -31,11 +31,11 @@ with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 dec
31
31
  import argparse
32
32
  import re
33
33
  import sys
34
- from typing import List, Optional
34
+ from typing import Any, Dict, List, Optional
35
35
 
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common import still_input_args # noqa: E402
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, 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
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.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
41
41
  has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
@@ -428,7 +428,9 @@ def main() -> int:
428
428
  if end is not None and end > args.fade:
429
429
  chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
430
430
  x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
431
- ov = f"overlay={x}:{y}:format=auto"
431
+ # a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
432
+ # still took the whole picture through 8-bit RGB (overlay_format)
433
+ ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
432
434
  if enable:
433
435
  ov += f":enable='{enable}'"
434
436
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
@@ -480,13 +482,18 @@ def main() -> int:
480
482
  elif args.scale:
481
483
  chain.append(f"scale={args.scale}:-2")
482
484
  # an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
483
- chain.append("format=yuva444p" if alpha_codec else "format=yuva420p")
485
+ # a 10-bit layer over a main overlay_format() composites at 10 bits stays 10-bit
486
+ deep = bool(overlay_format(meta)) and (pip_meta["video"].get("bit_depth") or 8) > 8
487
+ chain.append("format=yuva444p" if alpha_codec else "format=yuva420p10le" if deep else "format=yuva420p")
484
488
  if args.chromakey:
485
489
  chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
486
490
  if args.opacity < 1:
487
- chain.append(f"colorchannelmixer=aa={args.opacity:g}")
491
+ # the alpha is scaled in YUV: colorchannelmixer takes RGB only, so FFmpeg converted the
492
+ # layer to RGB and overlay=format=auto then composited the whole main picture in RGB,
493
+ # which came back BT.601 under the BT.709 tag (#333's defect, on this path)
494
+ chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
488
495
  x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
489
- ov = f"overlay={x}:{y}:format=auto"
496
+ ov = f"overlay={x}:{y}" + (overlay_format(meta) or ":format=auto")
490
497
  if enable:
491
498
  ov += f":enable='{enable}'"
492
499
  # a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
@@ -501,7 +508,13 @@ def main() -> int:
501
508
  # converted to BT.709 YUV with its alpha plane: the 4:4:4 the conversion ends in had none,
502
509
  # and the layer covered the picture as an opaque rectangle
503
510
  pip = pip.replace("format=yuv444p", "format=yuva444p")
504
- if base or pip:
511
+ # an untagged picture is labelled beside a converted one, and beside a tagged one too: FFmpeg
512
+ # 8.0's overlay negotiates one colorspace for both inputs, and a BT.709-tagged main under an
513
+ # untagged PiP was converted to the PiP's "unknown" (BT.601) matrix, away from the PiP too
514
+ # (C03028 at 80/110/192 came back 88/103/192 on 8.0.1). Two untagged pictures agree as they are.
515
+ def tagged(m: Dict[str, Any]) -> bool:
516
+ return ((m.get("video") or {}).get("color_space") or "unknown") not in ("unknown", "unspecified")
517
+ if base or pip or tagged(meta) != tagged(pip_meta):
505
518
  base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
506
519
  # overlay mixes samples: a full-range picture is taken to limited range (#410)
507
520
  base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
package/scripts/redact.py CHANGED
@@ -27,7 +27,7 @@ Examples:
27
27
  import argparse
28
28
  import sys
29
29
 
30
- 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, fmt_secs
30
+ from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
31
31
  from _common.emit import note
32
32
  from _common.probe import display_size, square_pixels, square_pixels_note
33
33
 
@@ -110,7 +110,7 @@ def main() -> int:
110
110
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
111
111
  else:
112
112
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
113
- fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
113
+ fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}{overlay_format(meta)}[out]"
114
114
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
115
115
  if has_audio:
116
116
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
package/scripts/render.py CHANGED
@@ -74,9 +74,10 @@ from typing import Any, Dict, List, Optional, Sequence
74
74
 
75
75
  from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
- from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
77
+ from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
78
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, remember_written
79
79
  from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
80
+ from _common.decision import rebase_project_paths
80
81
  import subprocess
81
82
  from _contract import CONTRACT_VERSION
82
83
  from batch import file_key
@@ -362,7 +363,7 @@ def render_pack(names: List[str], args) -> int:
362
363
  ("--chapters", args.chapters), ("--image", args.image),
363
364
  ("--intermediate-preset", args.intermediate_preset)):
364
365
  if value:
365
- argv += [flag, str(value)]
366
+ argv += option_args(flag, value)
366
367
  info(f"→ pack: {name}")
367
368
  proc = run_tool(argv + child_args() + ["--json"])
368
369
  try:
@@ -1260,6 +1261,12 @@ def main() -> int:
1260
1261
  emit(args.write_project, template=args.template, stages=[], check=None)
1261
1262
  return 0
1262
1263
 
1264
+ # every file the project names is read against the project file's directory (a template's,
1265
+ # the working directory): the same keys batch.py and multicam.py rebase on a copy they write
1266
+ # elsewhere, including the two rel() below never saw (audiogram.image, overlays[].font_file
1267
+ # were read against the working directory)
1268
+ proj = rebase_project_paths(proj, base)
1269
+
1263
1270
  def rel(p: Any) -> str:
1264
1271
  p = str(p)
1265
1272
  return p if os.path.isabs(p) else str(base / p)
@@ -1407,17 +1414,17 @@ def main() -> int:
1407
1414
  if needs_cut:
1408
1415
  argv: List[Any] = [src, "-o", part, "--accurate"]
1409
1416
  if c.get("in") is not None:
1410
- argv += ["--start", c["in"]]
1417
+ argv += option_args("--start", c["in"])
1411
1418
  if c.get("out") is not None:
1412
- argv += ["--end", c["out"]]
1419
+ argv += option_args("--end", c["out"])
1413
1420
  clip_snap = dict(snap_spec)
1414
1421
  clip_snap.update(c.get("snap") or {})
1415
1422
  if str(clip_snap.get("to") or "none") == "beats":
1416
1423
  argv += ["--snap", "beats"]
1417
1424
  if clip_snap.get("tolerance") is not None:
1418
- argv += ["--snap-tolerance", str(clip_snap["tolerance"])]
1425
+ argv += option_args("--snap-tolerance", clip_snap["tolerance"])
1419
1426
  if clip_snap.get("min_confidence") is not None:
1420
- argv += ["--min-confidence", str(clip_snap["min_confidence"])]
1427
+ argv += option_args("--min-confidence", clip_snap["min_confidence"])
1421
1428
  if clip_snap.get("source"):
1422
1429
  argv += ["--snap-source", rel(clip_snap["source"])]
1423
1430
  sh("cut.py", *argv, stage="clips")
@@ -1464,7 +1471,7 @@ def main() -> int:
1464
1471
  # against the project file, as every other path here and as the output guard
1465
1472
  # checks it; it used to reach waveform.py as written, against the cwd
1466
1473
  value = rel(value)
1467
- argv += [flag, str(value)]
1474
+ argv += option_args(flag, value)
1468
1475
  argv += brand_args
1469
1476
  sh("waveform.py", *argv, stage="audiogram")
1470
1477
  current = nxt
@@ -1480,11 +1487,11 @@ def main() -> int:
1480
1487
  current = str(work / f"joined{mid}")
1481
1488
  argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
1482
1489
  if frame.get("width"):
1483
- argv += ["--width", str(frame["width"])]
1490
+ argv += option_args("--width", frame["width"])
1484
1491
  if frame.get("height"):
1485
- argv += ["--height", str(frame["height"])]
1492
+ argv += option_args("--height", frame["height"])
1486
1493
  if frame.get("fps"):
1487
- argv += ["--fps", str(frame["fps"])]
1494
+ argv += option_args("--fps", frame["fps"])
1488
1495
  sh("join.py", *argv, stage="join")
1489
1496
  stages_done.append("join")
1490
1497
  if args.stop_after == "join":
@@ -1499,7 +1506,7 @@ def main() -> int:
1499
1506
  for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin"),
1500
1507
  ("margin_lead", "--margin-lead"), ("margin_tail", "--margin-tail")):
1501
1508
  if sil.get(k) is not None:
1502
- argv += [flag, str(sil[k])]
1509
+ argv += option_args(flag, sil[k])
1503
1510
  sh("silence.py", *argv, stage="silence")
1504
1511
  current = nxt
1505
1512
  stages_done.append("silence")
@@ -1536,7 +1543,7 @@ def main() -> int:
1536
1543
  argv = [current, "-o", nxt]
1537
1544
  for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
1538
1545
  if fit.get(k) is not None:
1539
- argv += [flag, str(fit[k])]
1546
+ argv += option_args(flag, fit[k])
1540
1547
  sh("fit.py", *argv, stage="fit")
1541
1548
  current = nxt
1542
1549
  stages_done.append("fit")
@@ -1560,7 +1567,7 @@ def main() -> int:
1560
1567
  for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
1561
1568
  ("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
1562
1569
  if cap.get(k) is not None:
1563
- argv += [flag, str(cap[k])]
1570
+ argv += option_args(flag, cap[k])
1564
1571
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
1565
1572
  if cap.get(k):
1566
1573
  argv.append(flag)
@@ -1578,11 +1585,11 @@ def main() -> int:
1578
1585
  nxt = str(work / f"graphics{i:02d}{mid}")
1579
1586
  if not g.get("template"):
1580
1587
  die(f"graphics[{i}] needs a template")
1581
- argv = [current, "-o", nxt, "--template", g["template"]]
1588
+ argv = [current, "-o", nxt] + option_args("--template", g["template"])
1582
1589
  for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color"), ("lang", "--lang"),
1583
1590
  ("text", "--text"), ("top", "--top"), ("bottom", "--bottom"), ("duration", "--duration"), ("margin", "--margin"), ("platform", "--platform")):
1584
1591
  if g.get(k) is not None:
1585
- argv += [flag, str(g[k])]
1592
+ argv += option_args(flag, g[k])
1586
1593
  # the entry's own "platform" is the more specific statement than the template's destination
1587
1594
  sh("graphics.py", *(argv + brand_args + ([] if g.get("platform") else platform_args)), stage="graphics")
1588
1595
  current = nxt
@@ -1601,7 +1608,7 @@ def main() -> int:
1601
1608
  elif ov.get("image"):
1602
1609
  argv += ["--image", rel(ov["image"])]
1603
1610
  elif ov.get("text"):
1604
- argv += ["--text", ov["text"]]
1611
+ argv += option_args("--text", ov["text"])
1605
1612
  elif ov.get("video"):
1606
1613
  # #326: sized and placed here, after fit, against the frame that ships
1607
1614
  argv += ["--video", rel(ov["video"])]
@@ -1611,7 +1618,7 @@ def main() -> int:
1611
1618
  ("chromakey", "--chromakey"), ("chromakey_similarity", "--chromakey-similarity"), ("chromakey_blend", "--chromakey-blend")):
1612
1619
  if ov.get(k) is not None:
1613
1620
  # font_file is a path: the same one project_input_files() guards
1614
- argv += [flag, rel(ov[k]) if k == "font_file" and ov[k] else str(ov[k])]
1621
+ argv += option_args(flag, rel(ov[k]) if k == "font_file" and ov[k] else ov[k])
1615
1622
  if ov.get("box"):
1616
1623
  argv.append("--box")
1617
1624
  # review 12: the overlay stage was the one stage that never heard which destination this
@@ -1647,10 +1654,10 @@ def main() -> int:
1647
1654
  argv += [flag, rel(au[k])]
1648
1655
  for k, flag in (("music_volume", "--music-volume"), ("effects_volume", "--effects-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount"), ("duck_threshold", "--duck-threshold"), ("duck_attack", "--duck-attack"), ("duck_release", "--duck-release"), ("stereo_widen", "--stereo-widen")):
1649
1656
  if au.get(k) is not None:
1650
- argv += [flag, str(au[k])]
1657
+ argv += option_args(flag, au[k])
1651
1658
  # "voice": true is the medium chain; "voice": "light"|"medium"|"strong" names one
1652
1659
  if au.get("voice") is not None and au.get("voice") is not False:
1653
- argv += ["--voice"] + ([] if au["voice"] is True else [str(au["voice"])])
1660
+ argv += ["--voice"] if au["voice"] is True else option_args("--voice", au["voice"])
1654
1661
  for k, flag in (("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
1655
1662
  if au.get(k):
1656
1663
  argv.append(flag)
@@ -1672,9 +1679,9 @@ def main() -> int:
1672
1679
  nxt = str(work / f"loudnorm{mid}")
1673
1680
  argv = [current, "-o", nxt]
1674
1681
  if ld.get("lufs") is not None:
1675
- argv += ["-I", str(ld["lufs"])]
1682
+ argv += option_args("--lufs", ld["lufs"])
1676
1683
  if ld.get("tp") is not None:
1677
- argv += ["--tp", str(ld["tp"])]
1684
+ argv += option_args("--tp", ld["tp"])
1678
1685
  sh("loudness.py", *argv, stage="loudness")
1679
1686
  current = nxt
1680
1687
  stages_done.append("loudness")
@@ -1687,9 +1694,9 @@ def main() -> int:
1687
1694
  if ex and ex.get("preset"):
1688
1695
  argv = [current, "--preset", ex["preset"], "-o", output]
1689
1696
  if ex.get("fit"):
1690
- argv += ["--fit", ex["fit"]]
1697
+ argv += option_args("--fit", ex["fit"])
1691
1698
  if ex.get("crf") is not None:
1692
- argv += ["--crf", str(ex["crf"])]
1699
+ argv += option_args("--crf", ex["crf"])
1693
1700
  normalize = ex.get("normalize")
1694
1701
  if normalize is None and ex["preset"] in PLATFORM_OF and not proj.get("loudness"):
1695
1702
  # eval 8: a reels project without the key rendered fully, failed the loudness check and
@@ -82,14 +82,17 @@ def silence_positions(silences: List[Tuple[float, float]], words: "list",
82
82
  of the word after (on demos/footage/ai_talk_en.mp4 three word starts sit 0.45 to 0.5 s before
83
83
  the sound does), and a word's midpoint stays on its own side of the pause either way. A
84
84
  silence with no word before it is `inside`."""
85
+ import bisect
85
86
  timed = sorted(words, key=lambda w: w["start"] + w["end"])
87
+ mids = [(w["start"] + w["end"]) / 2 for w in timed]
86
88
  out: List[str] = []
87
89
  for s, e in silences:
88
90
  if e == float("inf"):
89
91
  e = max(s, duration) if duration is not None else s
90
- ref = (s + e) / 2
91
- before = [w for w in timed if (w["start"] + w["end"]) / 2 < ref]
92
- out.append("sentence_end" if before and ends_sentence(before[-1]["word"]) else "inside")
92
+ # the last word whose midpoint is before the silence's: a binary search, not a scan of the
93
+ # whole transcript per silence (an hour of speech has thousands of each)
94
+ k = bisect.bisect_left(mids, (s + e) / 2)
95
+ out.append("sentence_end" if k and ends_sentence(timed[k - 1]["word"]) else "inside")
93
96
  return out
94
97
 
95
98