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.
@@ -43,9 +43,10 @@ from typing import Any, Dict, List, Optional, Tuple
43
43
 
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
- from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
46
+ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
47
+ strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
47
48
  from _common.emit import refuse # noqa: E402
48
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
50
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
50
51
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
51
52
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -277,12 +278,12 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
277
278
  margin_px = args.margin * play_h / 288.0
278
279
  line_h = size_px * 1.2
279
280
  box_px = size_px * scale
280
- align = ALIGN[args.position]
281
281
  out_cues = []
282
282
  for start, end, text in cues:
283
283
  lines = _cue_lines(text)
284
284
  n = len(lines)
285
285
  new_lines = []
286
+ align = srt_alignment(text) or ALIGN[args.position] # a cue's {\anN} moves its emoji too
286
287
  for i, line in enumerate(lines):
287
288
  if align in (7, 8, 9):
288
289
  y_top = margin_px + i * line_h
@@ -290,7 +291,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
290
291
  y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
291
292
  else:
292
293
  y_top = play_h - margin_px - (n - i) * line_h
293
- line_w = text_width_em(line, scale) * size_px
294
+ line_w = text_width_em(strip_srt_markup(line), scale) * size_px
294
295
  if align in (1, 4, 7):
295
296
  x0 = margin_px
296
297
  elif align in (3, 6, 9):
@@ -300,7 +301,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
300
301
  # libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
301
302
  # occupies the RIGHT end of the rendered line. Measuring the prefix from the left
302
303
  # edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
303
- rtl = detect_script(line) in BIDI_SCRIPTS
304
+ rtl = detect_script(strip_srt_markup(line)) in BIDI_SCRIPTS
304
305
  rebuilt = ""
305
306
  cursor = 0
306
307
  for idx, cluster in emoji_clusters(line):
@@ -313,6 +314,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
313
314
  rebuilt += line[cursor:idx + len(cluster)]
314
315
  cursor = idx + len(cluster)
315
316
  continue
317
+ prefix = strip_srt_markup(prefix) # a tag before the emoji takes no room
316
318
  if rtl:
317
319
  x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
318
320
  else:
@@ -390,9 +392,12 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
390
392
  # greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
391
393
  # given (`phrase_breaks`). Three wrap_text() calls re-ran the atomiser each time.
392
394
  sliced_atoms: List[int] = []
393
- lines, greedy, measured = wrap_variants(text, own_em, mode=wrap, lang=lang,
395
+ # SRT markup (<i>, <font color>, {\an8}) is not text: the wrap measures and breaks
396
+ # the words a viewer reads, and the tags go back next to the characters they styled
397
+ plain = strip_srt_markup(text)
398
+ lines, greedy, measured = wrap_variants(plain, own_em, mode=wrap, lang=lang,
394
399
  slice_overlong=True, sliced=sliced_atoms)
395
- if lines != [l for l in text.split("\n") if l.strip()]:
400
+ if lines != [l for l in plain.split("\n") if l.strip()]:
396
401
  stats["wrapped"] += 1
397
402
  if sliced_atoms:
398
403
  # the escape hatch fired: a word or run with no break point the wrapper may use
@@ -407,19 +412,22 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
407
412
  stats["rebalanced"] += 1
408
413
  if wrap != "measured" and lines != measured:
409
414
  stats["phrase_breaks"] += 1
415
+ marked = reinsert_srt_markup(text, lines) if plain != text else lines
410
416
  if len(lines) > max_lines:
411
417
  chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
412
418
  weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
413
419
  total_w = sum(weights)
420
+ pieces = carry_srt_markup(["\n".join(marked[i:i + max_lines])
421
+ for i in range(0, len(marked), max_lines)])
414
422
  t = start
415
- for chunk, weight in zip(chunks, weights):
423
+ for piece, weight in zip(pieces, weights):
416
424
  seg = (end - start) * weight / total_w
417
- staged.append((t, min(end, t + seg), "\n".join(chunk)))
425
+ staged.append((t, min(end, t + seg), piece))
418
426
  staged_sizes.append(own_size)
419
427
  t += seg
420
428
  stats["split"] += len(chunks) - 1
421
429
  continue
422
- text = "\n".join(lines)
430
+ text = "\n".join(marked)
423
431
  staged.append((start, end, text))
424
432
  staged_sizes.append(own_size)
425
433
  out: List[Tuple[float, float, str]] = []
@@ -572,7 +580,7 @@ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float,
572
580
 
573
581
 
574
582
  def cue_text_visible(text: str) -> bool:
575
- return bool((text or "").strip())
583
+ return bool(strip_srt_markup(text or "").strip()) # `<i></i>` draws nothing
576
584
 
577
585
 
578
586
  def count_visible_cues(cues, duration) -> Tuple[int, int]:
@@ -664,6 +672,17 @@ def _karaoke_word_durations(args, video: Optional[str], start: float, end: float
664
672
  return durs
665
673
 
666
674
 
675
+ # an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
676
+ _OVERRIDE_BLOCK_RE = re.compile(r"(?<!\\)\{\\[^{}]*\}")
677
+
678
+
679
+ def _karaoke_word(token: str) -> bool:
680
+ """Does this space-separated Dialogue token hold something a viewer reads? An emoji
681
+ placeholder does not, and neither does a lone converted tag (`{\\i1}` from `<i> Hello`):
682
+ neither gets a share of the cue's time or a highlight of its own."""
683
+ return bool(_OVERRIDE_BLOCK_RE.sub("", token).strip(EMOJI_SENTINEL))
684
+
685
+
667
686
  def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
668
687
  """Write a styled ASS file with optional animation and word-by-word highlight."""
669
688
  def t(sec: float) -> str:
@@ -687,6 +706,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
687
706
  word_active = ass_color(args.highlight_color)
688
707
  word_upcoming = ass_color(args.upcoming_color)
689
708
  word_bord = f"{args.outline * scale * (max(1.0, args.karaoke_scale) / 100.0):.2f}"
709
+ style_bord = f"{args.outline * scale:.2f}"
690
710
  word_scale = int(round(args.karaoke_scale))
691
711
  # --karaoke-style word emits one Dialogue per word carrying the WHOLE cue with explicit \N
692
712
  # breaks (the layout's own fixed line list -- see max_line_em's docstring). WrapStyle: 2 tells
@@ -713,7 +733,9 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
713
733
  # escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
714
734
  # a cue that says "use {curly} braces" is read out with its braces, and still cannot open
715
735
  # an override block. Newlines become \N in the same pass.
716
- text = ass_escape(text)
736
+ # SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
737
+ # overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
738
+ text = ass_escape_markup(text, colour=not args.karaoke)
717
739
  fx = ""
718
740
  if args.animate == "fade":
719
741
  fx = "{\\fad(200,200)}"
@@ -740,7 +762,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
740
762
  # the active word's larger \fscx/\fscy, which is the jump the issue is filed against.
741
763
  line_words = [[w for w in seg.split(" ") if w] for seg in body.split("\\N")]
742
764
  flat = [w for lw in line_words for w in lw]
743
- real_idx = [i for i, w in enumerate(flat) if w.strip(EMOJI_SENTINEL)]
765
+ real_idx = [i for i, w in enumerate(flat) if _karaoke_word(w)]
744
766
  if not real_idx:
745
767
  # nothing to highlight (an all-emoji or empty cue) -- one plain event, same as
746
768
  # today's karaoke-off rendering, rather than zero Dialogue lines for this cue
@@ -764,15 +786,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
764
786
  for lw in line_words:
765
787
  styled_line = []
766
788
  for w in lw:
767
- if not w.strip(EMOJI_SENTINEL):
789
+ if not _karaoke_word(w):
768
790
  styled_line.append(w)
769
791
  else:
770
792
  rank = real_idx.index(flat_pos)
771
793
  if rank < j:
772
794
  styled_line.append(f"{{\\c{word_past}}}{w}")
773
795
  elif rank == j:
796
+ # put back what the active word changed, not {\r}: a reset would
797
+ # also drop the cue's own <i>/<b>/colour and a --fit-size \fs
774
798
  styled_line.append(
775
- f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}{{\\r}}")
799
+ f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}"
800
+ f"{{\\fscx100\\fscy100\\bord{style_bord}}}")
776
801
  else:
777
802
  styled_line.append(f"{{\\c{word_upcoming}}}{w}")
778
803
  flat_pos += 1
@@ -789,7 +814,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
789
814
  if args.karaoke:
790
815
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
791
816
  segments = body.split("\\N")
792
- words = [w for seg in segments for w in seg.split(" ") if w and w.strip(EMOJI_SENTINEL)]
817
+ words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
793
818
  durs = _karaoke_word_durations(args, video, start, end, len(words))
794
819
  it = iter(durs)
795
820
  out_segments = []
@@ -799,7 +824,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
799
824
  # past the reserved gap without spending cue time on a glyph nobody sees (rendered
800
825
  # and confirmed -- libass keeps the full gap inside a karaoke run).
801
826
  out_segments.append(" ".join(
802
- ("{\\kf0}" + w) if not w.strip(EMOJI_SENTINEL) else f"{{\\kf{next(it)}}}{w}" for w in ws))
827
+ ("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
803
828
  body = "\\N".join(out_segments)
804
829
  if EMOJI_SENTINEL in body:
805
830
  body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
@@ -1223,7 +1248,7 @@ def main() -> int:
1223
1248
  # viewer sees and would silently change the SRT this run writes, so the mux path is
1224
1249
  # left exactly as 1.16 wrote it.
1225
1250
  return
1226
- fit = fit_size(cue_list, **fit_params())
1251
+ fit = fit_size([(s, e, strip_srt_markup(t)) for s, e, t in cue_list], **fit_params())
1227
1252
  fit_stats["size_used"] = fit["size"]
1228
1253
  fit_stats["size_source"] = "platform-frame" if planned_frame else "input"
1229
1254
  fit_stats["shrunk"] = fit["shrunk"]
@@ -1774,7 +1799,7 @@ def main() -> int:
1774
1799
  side_notes.append(square_pixels_note(meta["video"]))
1775
1800
  cmd = ffmpeg_base() + ["-i", args.input]
1776
1801
  chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
1777
- base_chain=layer_base_chain(meta))
1802
+ base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
1778
1803
  if emoji_plan else ([], []))
1779
1804
  if chains:
1780
1805
  for spec in emoji_inputs:
package/scripts/check.py CHANGED
@@ -525,7 +525,7 @@ def main() -> int:
525
525
  # comes back at a level nobody checked.
526
526
  ch = a.get("channels") or 0
527
527
  row("channels", "PASS" if ch in (1, 2) else "WARN", f"{ch}ch", "1 (mono) or 2 (stereo)",
528
- "audio.py --downmix (5.1/7.1 to stereo with the standard weights) or audio.py --mono",
528
+ "audio.py --downmix (5.1/7.1 to stereo with the ITU-R BS.775 weights) or audio.py --mono",
529
529
  reason="podcast players downmix 5.1 unpredictably")
530
530
  if args.platform == "podcast":
531
531
  chapters = meta.get("chapters") or []
package/scripts/color.py CHANGED
@@ -38,6 +38,7 @@ from _common.emit import note, warn
38
38
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
39
39
  report as alpha_report, source_alpha, tag_args, video_line)
40
40
  from _common.decision import keeps_source_tags
41
+ from _common.color import bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402,F401
41
42
 
42
43
  # the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
43
44
  # until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
@@ -198,47 +199,6 @@ def lut_legs(meta=None, out_fmt: str = "yuv420p") -> "tuple":
198
199
  f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
199
200
 
200
201
 
201
- def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
202
- v = meta["video"]
203
- trc = v.get("color_transfer") or "smpte2084"
204
- prim = v.get("color_primaries") or "bt2020"
205
- space = v.get("color_space") or "bt2020nc"
206
- # zscale needs explicit input tags when the file lacks them
207
- chain: List[str] = [
208
- f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
209
- "format=gbrpf32le",
210
- "zscale=p=bt709",
211
- f"tonemap=tonemap={tonemap}:desat={desat:g}",
212
- "zscale=t=bt709:m=bt709:r=tv",
213
- "format=yuv420p",
214
- ]
215
- return ",".join(chain)
216
-
217
-
218
- def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
219
- """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
220
- true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
221
- its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
222
- white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
223
- as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
224
- v = meta["video"]
225
- trc = v.get("color_transfer") or "bt709"
226
- space = v.get("color_space") or "bt2020nc"
227
- return ",".join([
228
- f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
229
- "format=gbrpf32le",
230
- "zscale=p=bt709",
231
- "zscale=t=bt709:m=bt709:r=tv",
232
- "format=yuv420p",
233
- ])
234
-
235
-
236
- def is_bt2020_sdr(v: dict) -> bool:
237
- """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
238
- return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
239
- and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
240
-
241
-
242
202
  # --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
243
203
  # h264_metadata/hevc_metadata write into the VUI (matrix_coefficients, colour_primaries,
244
204
  # transfer_characteristics).
@@ -436,7 +396,7 @@ def main() -> int:
436
396
  "(luma 14-241, chroma 5-251) legal range after the 8-bit 4:2:0 conversion, then measure the written "
437
397
  "file's out-of-range share (JSON legalize). --correct with no correction values clamps only")
438
398
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
439
- ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
399
+ ap.add_argument("--peak", type=float, default=1000.0, help="the PQ source's peak brightness in nits, the level the tone map takes to SDR white (default 1000; an HLG source peaks at 1000 by definition, so it does not apply there)")
440
400
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
441
401
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
442
402
  ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
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/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]