ffmpeg-skill 3.6.0 → 3.6.2

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/cut.py CHANGED
@@ -62,7 +62,8 @@ from _common.runner import fail_if_classified
62
62
  from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
63
63
  from _common import require_tool
64
64
  from _common.probe import TS_EXTS, signatures_match, stream_signature
65
- from _common.decision import grid_at_or_after
65
+ from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
66
+ from _common.timeline import output_timecode_args
66
67
  from _common import warn, record_fallback, refuse_existing_outputs, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
67
68
 
68
69
  # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
@@ -352,6 +353,28 @@ def copy_presentation(t: float, key, edit_list: bool, fps) -> dict:
352
353
  return {"start_snapped": abs(k_pts - t) > frame + 1e-6, "stored_preroll_seconds": None}
353
354
 
354
355
 
356
+ def first_frame_from(src: str, meta: dict, start: float, reencode: bool, edit_list: bool):
357
+ """Seconds from the source picture's first frame to the frame a cut from `start` begins on,
358
+ for its start timecode: the first frame at or after `start` for a re-encode and an edit-listed
359
+ copy that hides its keyframe's pre-roll, the keyframe for any other copy (it shows the
360
+ pre-roll). None when where a copy lands cannot be read, and for an MXF source, whose demuxer
361
+ lands a seek on another keyframe than seek_keyframe's read (-ss 1.5: the one at 1.44 s, read as
362
+ the one at 0.96 s), so no label would be known to be right."""
363
+ v0, rate = stream_starts(meta)[0], frame_rate(meta)
364
+ if reencode:
365
+ # a start before the picture's first frame (a video that starts after the file's origin)
366
+ # begins on that first frame, not on a grid frame before it
367
+ return grid_at_or_after(max(start, v0), v0, rate) - v0
368
+ if "mxf" in str(meta.get("format") or "").split(","):
369
+ return None
370
+ key = seek_keyframe(src, start)
371
+ if key is None:
372
+ return None
373
+ if edit_list and key[0] <= start + 1e-6:
374
+ return None if key[2] is None else key[2] - v0
375
+ return key[0] - v0
376
+
377
+
355
378
  def av_skew(out_meta: dict, edit_list_remedy: bool = False, expected: float = 0.0):
356
379
  """(seconds audio starts after video, warning note or None); None when either is missing.
357
380
  A lossless cut can start its streams apart -- Core Media HEVC once gave 3.7 s of sound with no
@@ -891,6 +914,9 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
891
914
  edit_list = (edit_list_ok or copy_t is not None) and os.path.splitext(dst)[1].lower() in EDIT_LIST_EXTS
892
915
  cmd = (ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur if copy_t is None else copy_t:.6f}"]
893
916
  + copy_args(meta, dst) + ([] if edit_list else ["-avoid_negative_ts", "make_zero"]) + [dst])
917
+ if not audio_only and (meta.get("timecode") or {}).get("value"):
918
+ cmd[-1:-1] = output_timecode_args(meta, first_frame_from(src, meta, start, reencode, edit_list and copy_t is None),
919
+ delivered_rate(meta) if reencode else None)
894
920
  proc = run(cmd, check=False)
895
921
  if proc.returncode != 0:
896
922
  if not reencode:
@@ -956,9 +982,6 @@ JOIN_CHUNK = 32
956
982
  # plays as a click: measured on a 440 Hz tone, the step at the join was 14x the tone's own largest
957
983
  # sample-to-sample change. A 20 ms fade is too short to hear as a dip and long enough to remove it.
958
984
  DECLICK_S = 0.02
959
- # how far before a segment the fallback seeks, so a start inside the B-frame reorder delay before a
960
- # keyframe still decodes from the keyframe BEFORE it
961
- SEEK_MARGIN = 1.0
962
985
  # codecs whose configuration travels in the packets, so a part with no extradata is not missing any
963
986
  _PARTS: dict = {}
964
987
 
@@ -1102,7 +1125,9 @@ def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: d
1102
1125
  # the source's rate, stated: FFmpeg 7.0 encoded the trimmed, concatenated pictures at 25 fps
1103
1126
  # and dropped frames to fit (98 frames for 117 at 30 fps)
1104
1127
  rate_out = ["-r", rate_arg] if has_v and rate_arg and rate and "-r" not in codec else []
1105
- cmd += ["-sn", "-dn"] + codec + rate_out + [dst]
1128
+ # a join's frames after the first range are not the source's next frames: no start timecode
1129
+ # labels them, so the output carries none (a single range's cut moves it to its first frame)
1130
+ cmd += ["-sn", "-dn"] + codec + rate_out + ([] if intermediate or not has_v else output_timecode_args(meta, None)) + [dst]
1106
1131
  run(cmd)
1107
1132
  return codec
1108
1133
 
@@ -1122,7 +1147,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
1122
1147
  if len(segments) <= JOIN_CHUNK:
1123
1148
  _join_chunk(src, segments, dst, meta, crf, preset, has_v, vend=vend, hold=hold)
1124
1149
  return
1125
- ext = os.path.splitext(dst)[1] or ".mp4"
1150
+ ext = os.path.splitext(dst)[1].lower() or ".mp4" # IMG_0001.MOV is a .mov
1126
1151
  # Matroska chunks with PCM audio: no edit lists and no AAC priming to carry into the join (MP4
1127
1152
  # chunks measured the video 23 ms late against the audio after a copy concat)
1128
1153
  # the chunks' codec tag (hvc1), which a stream copy out of Matroska does not carry into an MP4
@@ -1163,7 +1188,8 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
1163
1188
  audio = [] if not meta.get("audio") else (aac_args() if has_v else audio_codec_for(dst))
1164
1189
  run(ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
1165
1190
  + (["-c:v", "copy"] + (tag if ext in (".mp4", ".mov", ".m4v") else []) if has_v else ["-vn"]) + audio
1166
- + (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else []) + [dst])
1191
+ + (["-movflags", "+faststart"] if ext in (".mp4", ".mov", ".m4v") and has_v else [])
1192
+ + (output_timecode_args(meta, None) if has_v else []) + [dst])
1167
1193
 
1168
1194
 
1169
1195
  BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
@@ -1333,7 +1359,7 @@ def run_smart(args, segments, output: str, meta: dict, forced: List[str], hold_n
1333
1359
  # line can outgrow Windows' limit, and the full re-encode joins in chunks of this size
1334
1360
  return fallback(f"{len(segments)} segments with sound: the smart join takes at most {JOIN_CHUNK}, the "
1335
1361
  "full re-encode joins them in chunks", "segments")
1336
- ext = os.path.splitext(output)[1] or ".mp4"
1362
+ ext = os.path.splitext(output)[1].lower() or ".mp4"
1337
1363
  params = _smartcut.stream_params(args.input)
1338
1364
  bad = _smartcut.ineligible(params, meta, ext)
1339
1365
  if bad:
@@ -1510,7 +1536,7 @@ def main() -> int:
1510
1536
 
1511
1537
  output = args.output or default_output(args.input, "cut")
1512
1538
  refuse_output_is_input(output, args.input)
1513
- ext = os.path.splitext(output)[1] or ".mp4"
1539
+ ext = os.path.splitext(output)[1].lower() or ".mp4" # an upper-case .MP4 is still faststart
1514
1540
 
1515
1541
  # A segment that runs past the end of the video, with a join after it: whatever joins it (the
1516
1542
  # concat demuxer or the concat filter) starts the next segment after its LONGER stream, so the
@@ -1693,6 +1719,8 @@ def main() -> int:
1693
1719
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy"]
1694
1720
  if ext in (".mp4", ".mov", ".m4v"):
1695
1721
  cmd += ["-movflags", "+faststart"]
1722
+ if not is_audio_output(output):
1723
+ cmd += output_timecode_args(meta, None) # a join: no start timecode (_join_chunk)
1696
1724
  cmd += [joined]
1697
1725
  proc = run(cmd, check=False)
1698
1726
  fail_if_classified(proc) # #415: a full disk is not a reason to re-cut every segment
package/scripts/export.py CHANGED
@@ -51,7 +51,7 @@ from _common import STATE, run_analysis, add_common, apply_common, bt709_label_f
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
53
53
  from _common.probe import square_pixels, square_pixels_note
54
- from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, run_analysis as _run_analysis, subtitle_codec_for
54
+ from _common.runner import ffmpeg_encoders, guard_sidecar, remember_written, reserve_sibling, run_analysis as _run_analysis, subtitle_codec_for
55
55
  from _common.emit import file_identity
56
56
  from check import SPECS as PLATFORMS, measure_loudness
57
57
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
@@ -213,7 +213,7 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
213
213
  if not STATE.dry_run:
214
214
  d, base = os.path.split(output)
215
215
  stem, ext = os.path.splitext(base)
216
- fill_tmp = os.path.join(d, f".{stem}.ffskill-fill-{os.getpid()}{ext}")
216
+ fill_tmp = reserve_sibling(d, stem, ext, "fill") # created exclusively: never through a planted link
217
217
  cmd += (p["audio"] if has_audio else ["-an"]) + [fill_tmp or output]
218
218
  cmd += ["-map", "[key]"] + p["video"] + tc_args + cfr_args(meta) + ["-an", key]
219
219
  try:
package/scripts/fit.py CHANGED
@@ -39,6 +39,8 @@ import sys
39
39
  from fractions import Fraction
40
40
  from typing import List
41
41
 
42
+ from _common.decision import SEEK_MARGIN, delivered_rate, frame_rate, grid_at_or_after, stream_starts
43
+ from _common.timeline import exact_rate, output_timecode_args
42
44
  from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
43
45
  BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
44
46
 
@@ -136,6 +138,7 @@ def main() -> int:
136
138
  pre_input: List[str] = []
137
139
  post: List[str] = []
138
140
  factor = 1.0
141
+ tc_first = None # seconds from the source's first frame to the output's, when a trim moves it
139
142
 
140
143
  # ---- rotate / flip
141
144
  if args.rotate == 90:
@@ -175,8 +178,18 @@ def main() -> int:
175
178
  else:
176
179
  if target < src_dur:
177
180
  start = (src_dur - target) / 2 if args.from_center else 0.0
178
- pre_input += ["-ss", f"{start:.3f}"]
179
- post += ["-t", f"{target:.3f}"]
181
+ # seek SEEK_MARGIN early and drop the margin on the output side, as cut.py does:
182
+ # an input seek straight to `start` can land on a keyframe whose picture comes
183
+ # after it (a B-frame HEVC source) and lose the frames before it, so the first
184
+ # picture would not be the one the start timecode below names
185
+ m = min(SEEK_MARGIN, round(start, 3))
186
+ pre_input += ["-ss", f"{start - m:.3f}"]
187
+ post += (["-ss", f"{m:.3f}"] if m > 0 else []) + ["-t", f"{target:.3f}"]
188
+ # the output starts on the first frame at or after the seek (a seek before a
189
+ # picture that starts after the file's origin: on its first frame): its start
190
+ # timecode is that frame's, not the source's first frame's
191
+ v0, rate = stream_starts(meta)[0], frame_rate(meta)
192
+ tc_first = grid_at_or_after(max(round(start, 3), v0), v0, rate) - v0
180
193
  else:
181
194
  info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
182
195
 
@@ -213,6 +226,15 @@ def main() -> int:
213
226
  vf.append(f"fps={args.fps:g}")
214
227
  elif meta["video"].get("variable_frame_rate_suspected"):
215
228
  info("source looks variable-frame-rate; conforming to constant fps automatically")
229
+ # the start timecode is counted at the rate the output is delivered at: a --fps that changes
230
+ # the rate relabels it (01:00:00:12 at 25 fps is 01:00:00:24 at 50) even with no trim, which
231
+ # is also how a render.py clip cut at its own rate reaches the project's frame.fps
232
+ out_rate = args.fps or delivered_rate(meta)
233
+ src_fps = meta["video"].get("fps")
234
+ rate_changed = bool(out_rate and src_fps and exact_rate(float(out_rate)) != exact_rate(float(src_fps)))
235
+ relabel = tc_first is not None or rate_changed
236
+ if relabel:
237
+ post += output_timecode_args(meta, tc_first or 0.0, out_rate)
216
238
 
217
239
  output = args.output or default_output(args.input, "fit")
218
240
  cmd = ffmpeg_base() + pre_input + ["-i", args.input]
@@ -240,7 +262,7 @@ def main() -> int:
240
262
  run(cmd + [output])
241
263
  dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
242
264
  else:
243
- dropped_streams = run_keeping_subtitles(cmd, output)
265
+ dropped_streams = run_keeping_subtitles(cmd, output, relabel_timecode=relabel)
244
266
 
245
267
  result = probe(output, role="output")
246
268
  msg = f"wrote {output} ({fmt_secs(result['duration'])}, {result['video']['width']}x{result['video']['height']})"
@@ -36,7 +36,7 @@ from typing import List, Optional, Tuple
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
38
38
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
39
- ffmpeg_base, info, layer_base_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
39
+ ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
40
40
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
41
41
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
42
42
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
@@ -497,8 +497,9 @@ def main(force_route: Optional[str] = None) -> int:
497
497
  h = max(3, int(base * 0.008))
498
498
  fps = meta['video'].get('fps') or 30
499
499
  fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
500
- if draws_chroma([bg], meta) or alpha_codec:
501
- # a layer (overlay composites the alpha too; drawbox leaves it as it was)
500
+ if draws_chroma([bg], meta) or alpha_codec or overlay_format(meta):
501
+ # a layer (overlay composites the alpha too; drawbox leaves it as it was, and takes a
502
+ # 10-bit picture to 8 bits)
502
503
  fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
503
504
  fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
504
505
  else:
@@ -712,8 +713,9 @@ def main(force_route: Optional[str] = None) -> int:
712
713
  # converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
713
714
  # On an alpha line every box is a layer: overlay composites the box's alpha over the picture's,
714
715
  # while drawbox paints the colour and leaves the alpha as it was (a box on a transparent canvas
715
- # stayed invisible)
716
- as_layers = bool(alpha_codec) or any(draws_chroma([b[0]], meta) for b in boxes)
716
+ # stayed invisible). On a 10-bit picture too: drawbox is 8-bit only on 6.1, so FFmpeg took the
717
+ # whole picture to 8 bits around it (overlay_format)
718
+ as_layers = bool(alpha_codec) or bool(overlay_format(meta)) or any(draws_chroma([b[0]], meta) for b in boxes)
717
719
  drawboxes = []
718
720
  for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
719
721
  if as_layers:
@@ -743,6 +745,9 @@ def main(force_route: Optional[str] = None) -> int:
743
745
  if fc:
744
746
  fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
745
747
  square = "format=yuva444p" + ("," + square if square else "")
748
+ elif overlay_format(meta):
749
+ # a 10-bit picture on a 10-bit line is composited at 10 bits (overlay's default is 8-bit)
750
+ fc = [c.replace(":eof_action=pass", ":eof_action=pass" + overlay_format(meta)) for c in fc]
746
751
  finish = finish_chain(alpha_codec, args.alpha_mode, meta) if alpha_codec else ""
747
752
 
748
753
  cmd = ffmpeg_base() + (decoder_args_for(meta, args.input) if alpha_codec else []) + ["-i", args.input]
@@ -762,7 +767,7 @@ def main(force_route: Optional[str] = None) -> int:
762
767
  last = "vtxt"
763
768
  eo, emoji_inputs = emoji_filter_chain({"overlays": emoji_overlays}, last, "vfinal", first_input=1,
764
769
  to_yuv=rgb_chain_to_bt709(meta),
765
- base_chain=layer_base_chain(meta))
770
+ base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
766
771
  for spec in emoji_inputs:
767
772
  cmd += spec
768
773
  asset = spec[-1]
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}?"]