ffmpeg-skill 3.8.9 → 3.8.11

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.
@@ -56,7 +56,7 @@ from _common import asr as _asr
56
56
  from _common.asr import ENGINE_CHOICES
57
57
  from _common.emit import warn
58
58
  from _common.probe import display_size, square_pixels, square_pixels_note
59
- from _common.runner import subtitle_codec_for, subtitle_container_holds
59
+ from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
60
60
  from _common.fonts import font_choice, record_font_choice
61
61
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
62
62
  write_srt)
@@ -321,12 +321,15 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
321
321
  scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
322
322
  # --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
323
323
  # pops in against a line that is still fading up. These match the \fad values below.
324
- fade_in, fade_out = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
325
- "slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
324
+ fade_in, fade_out = (ms / 1000.0 for ms in ANIMATE_FADE_MS.get(getattr(args, "animate", None) or "none", (0, 0)))
326
325
  size_px = args.size * play_h / 288.0
327
326
  margin_px = args.margin * play_h / 288.0
328
327
  line_h = size_px * 1.2
329
328
  box_px = size_px * scale
329
+ # A62 (#457): libass lays a line out between the Style's MarginL and MarginR (margins_x, the
330
+ # horizontal safe zone), not --margin, which is the vertical distance: a left-aligned PNG was
331
+ # drawn over the first words and a right-aligned one left of its gap
332
+ margin_l, margin_r = margins_x(args, play_w)
330
333
  out_cues = []
331
334
  for start, end, text in cues:
332
335
  lines = _cue_lines(text)
@@ -342,11 +345,11 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
342
345
  y_top = play_h - margin_px - (n - i) * line_h
343
346
  line_w = text_width_em(strip_srt_markup(line), scale) * size_px
344
347
  if align in (1, 4, 7):
345
- x0 = margin_px
348
+ x0 = float(margin_l)
346
349
  elif align in (3, 6, 9):
347
- x0 = play_w - margin_px - line_w
350
+ x0 = play_w - margin_r - line_w
348
351
  else:
349
- x0 = (play_w - line_w) / 2.0
352
+ x0 = margin_l + (play_w - margin_l - margin_r - line_w) / 2.0
350
353
  # libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
351
354
  # occupies the RIGHT end of the rendered line. Measuring the prefix from the left
352
355
  # edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
@@ -561,23 +564,31 @@ def safe_width_fraction(args, play_w: Optional[int]) -> float:
561
564
  return max(0.05, (play_w - left - right) / float(play_w))
562
565
 
563
566
 
567
+ def line_fraction(args, play_w: Optional[int]) -> float:
568
+ """The width fraction one line is wrapped to: safe_width_fraction, narrowed under
569
+ --karaoke-style word by --karaoke-scale.
570
+
571
+ --karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
572
+ (WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget is
573
+ narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
574
+ still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
575
+ exactly filled the safe width would overflow it the moment its emphasised word appears. The
576
+ size fit measures against the same budget, or a cue it reported as fitting was split by the
577
+ layout after it (A58, #457).
578
+ """
579
+ frac = safe_width_fraction(args, play_w)
580
+ if getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
581
+ frac = frac / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
582
+ return frac
583
+
584
+
564
585
  def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
565
586
  """How many em fit on one caption line at the chosen size, or None without video geometry.
566
587
 
567
588
  --size is in ASS points against a 288-line script (what libass's force_style uses), so the
568
- rendered pixel size is size * play_h / 288.
569
-
570
- --karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
571
- (WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget here
572
- is narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
573
- still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
574
- exactly filled the safe width would overflow it the moment its emphasised word appears.
589
+ rendered pixel size is size * play_h / 288; the width is line_fraction's.
575
590
  """
576
- em = line_em_for_size(args.size, play_w, play_h,
577
- safe_fraction=safe_width_fraction(args, play_w))
578
- if em is not None and getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
579
- em = em / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
580
- return em
591
+ return line_em_for_size(args.size, play_w, play_h, safe_fraction=line_fraction(args, play_w))
581
592
 
582
593
 
583
594
  def parse_ass_dialogue(path: str) -> str:
@@ -692,35 +703,139 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
692
703
 
693
704
 
694
705
  def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
695
- n_words: int) -> Optional[List[int]]:
696
- """Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
706
+ n_words: int) -> Optional[Tuple[int, List[int]]]:
707
+ """(lead, centiseconds per word) for one cue from real word timings; None when they don't
708
+ cover it. `lead` is the silence between the cue's start and its first word: the highlight
709
+ waits for it rather than starting at the cue and running ahead of the speech by that much
710
+ (A59, #457)."""
697
711
  inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
698
712
  if len(inside) != n_words or n_words <= 0:
699
713
  return None
700
714
  total_cs = max(1, int(round((end - start) * 100)))
701
- bounds = [max(start, inside[0][0])] + [max(start, min(end, w[1])) for w in inside]
715
+ first = min(end, max(start, inside[0][0]))
716
+ lead = int(round((first - start) * 100))
717
+ bounds = [first] + [max(first, min(end, w[1])) for w in inside]
702
718
  out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
703
- out[-1] += total_cs - sum(out)
719
+ out[-1] += total_cs - lead - sum(out)
704
720
  if out[-1] < 1:
705
721
  return None
706
- return out
722
+ return lead, out
707
723
 
708
724
 
709
- def _karaoke_word_durations(args, video: Optional[str], start: float, end: float, n_words: int) -> List[int]:
710
- """Centiseconds per word for one cue, shared by --karaoke-style sweep and word.
725
+ def _karaoke_word_durations(args, video: Optional[str], start: float, end: float,
726
+ n_words: int) -> Tuple[int, List[int]]:
727
+ """(lead, centiseconds per word) for one cue, shared by --karaoke-style sweep and word.
711
728
 
712
729
  Real word timings from the transcript beat both the energy estimate and the even split --
713
- they are what the speaker actually did, not a proxy for it.
730
+ they are what the speaker actually did, not a proxy for it. Only they carry a lead (the
731
+ silence before the first word); the other two share the whole cue out from its start.
714
732
  """
715
733
  dur_cs = max(1, int(round((end - start) * 100)))
716
- durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
717
- if durs is None:
718
- if getattr(args, "karaoke_timing", "even") == "energy" and video:
719
- durs = word_durations_from_audio(video, start, end, n_words, getattr(args, "audio_stream", 0))
734
+ timed = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
735
+ if timed is not None:
736
+ return timed
737
+ if getattr(args, "karaoke_timing", "even") == "energy" and video:
738
+ return 0, word_durations_from_audio(video, start, end, n_words, getattr(args, "audio_stream", 0))
739
+ per = max(1, dur_cs // max(1, n_words))
740
+ return 0, [per] * n_words
741
+
742
+
743
+ def shift_words(words: List[Tuple[float, float, str]], offset: float) -> List[Tuple[float, float, str]]:
744
+ """Word timings moved by --offset, as the cues they belong to are (#505): the offset corrects a
745
+ transcript that runs early or late against the sound, and its word timings come from the same
746
+ transcript."""
747
+ if not offset:
748
+ return list(words or [])
749
+ return [(s + offset, e + offset, w) for s, e, w in (words or [])]
750
+
751
+
752
+ # A `{...}` that ffmpeg's SRT reader passes on to libass as text, where it is an override block and
753
+ # draws nothing: not `{\...}` (ffmpeg removes those itself), not a MicroDVD `{y:i}` style code, not
754
+ # the `\{` escape
755
+ SRT_HIDDEN_BRACES_RE = re.compile(r"(?<!\\)\{(?!\\)(?![CcFfoPSsYy]:)[^{}]*\}")
756
+
757
+
758
+ # --animate's envelope in ms (fade in, fade out), and the slide's rise: one table for the ASS
759
+ # below and the emoji PNGs that ride it (plan_emoji)
760
+ ANIMATE_FADE_MS = {"fade": (200, 200), "pop": (80, 120), "slide": (150, 150)}
761
+ SLIDE_MS = 250
762
+
763
+
764
+ def line_anchor(align: int, play_w: int, play_h: int, margin: int, margin_l: int, margin_r: int) -> Tuple[int, int]:
765
+ """The point libass anchors a line of alignment `align` (numpad) at, from the Style's margins:
766
+ what a `\\move` has to end on so that --animate slide lands where the line is drawn without it
767
+ (A56, #457: the slide always ended at the bottom centre)."""
768
+ if align in (1, 4, 7):
769
+ x = margin_l
770
+ elif align in (3, 6, 9):
771
+ x = play_w - margin_r
772
+ else:
773
+ x = margin_l + (play_w - margin_l - margin_r) // 2
774
+ if align in (7, 8, 9):
775
+ y = margin
776
+ elif align in (4, 5, 6):
777
+ y = play_h // 2
778
+ else:
779
+ y = play_h - margin
780
+ return x, y
781
+
782
+
783
+ def animate_fx(kind: str, o: int, e: int, dur: int, anchor: Tuple[int, int], rise: int) -> str:
784
+ """The --animate override for an event shown from `o` to `e` ms into a cue `dur` ms long.
785
+
786
+ A whole cue (o == 0, e == dur) gets the envelope it always had. --karaoke-style word draws a
787
+ cue as one event per word: each gets the part of the cue's one envelope that falls inside it,
788
+ started where the event before left it, so the fade-out comes at the cue's end and not at the
789
+ end of the first word, and a slide or a pop does not restart or jump at a word (A57, #457)."""
790
+ if kind not in ANIMATE_FADE_MS:
791
+ return ""
792
+ fin, fout = ANIMATE_FADE_MS[kind]
793
+ x, y2 = anchor
794
+ y1 = y2 + rise
795
+ whole = o == 0 and e == dur
796
+ if whole:
797
+ fade = "\\fad(%d,%d)" % (fin, fout)
798
+ else:
799
+ out_at = dur - fout
800
+
801
+ def alpha(t: float) -> int:
802
+ a = 0.0
803
+ if t < fin:
804
+ a = 255.0 * (1.0 - t / float(fin))
805
+ if t > out_at and fout:
806
+ a = max(a, 255.0 * (t - out_at) / float(fout))
807
+ return int(round(min(255.0, max(0.0, a))))
808
+ if o >= fin and e <= out_at:
809
+ fade = ""
810
+ else:
811
+ a1 = alpha(o)
812
+ t2 = max(0, fin - o)
813
+ t3 = max(t2, out_at - o)
814
+ fade = "\\fade(%d,%d,255,0,%d,%d,%d)" % (a1, a1 if o > out_at else 0, t2, t3, max(t3, dur - o))
815
+ if kind == "fade":
816
+ tags = fade
817
+ elif kind == "pop":
818
+ if o == 0:
819
+ scale = "\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)"
820
+ elif o < 120:
821
+ v = int(round(60 + 50 * o / 120.0))
822
+ scale = ("\\fscx%d\\fscy%d\\t(0,%d,\\fscx110\\fscy110)\\t(%d,%d,\\fscx100\\fscy100)"
823
+ % (v, v, 120 - o, 120 - o, 200 - o))
824
+ elif o < 200:
825
+ v = int(round(110 - 10 * (o - 120) / 80.0))
826
+ scale = "\\fscx%d\\fscy%d\\t(0,%d,\\fscx100\\fscy100)" % (v, v, 200 - o)
827
+ else:
828
+ scale = ""
829
+ tags = fade + scale
830
+ else:
831
+ if o == 0:
832
+ move = "\\move(%d,%d,%d,%d,0,%d)" % (x, y1, x, y2, SLIDE_MS)
833
+ elif o < SLIDE_MS:
834
+ move = "\\move(%d,%d,%d,%d,0,%d)" % (x, int(round(y1 + (y2 - y1) * o / float(SLIDE_MS))), x, y2, SLIDE_MS - o)
720
835
  else:
721
- per = max(1, dur_cs // max(1, n_words))
722
- durs = [per] * n_words
723
- return durs
836
+ move = "\\pos(%d,%d)" % (x, y2)
837
+ tags = fade + move
838
+ return "{" + tags + "}" if tags else ""
724
839
 
725
840
 
726
841
  # an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
@@ -751,7 +866,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
751
866
  primary = ass_color(args.highlight_color if args.karaoke else args.color)
752
867
  secondary = ass_color(args.color)
753
868
  outline = ass_color(args.outline_color)
754
- back = ass_color(args.outline_color, 0x80)
869
+ back_alpha = 0x80 # the --box colour is half transparent; an emoji placeholder restores it
870
+ back = ass_color(args.outline_color, back_alpha)
755
871
  word_style = args.karaoke and getattr(args, "karaoke_style", "sweep") == "word"
756
872
  word_past = ass_color(args.color)
757
873
  word_active = ass_color(args.highlight_color)
@@ -786,14 +902,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
786
902
  # an override block. Newlines become \N in the same pass.
787
903
  # SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
788
904
  # overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
905
+ # the line's anchor for --animate slide: the cue's own {\anN} first, as libass reads it
906
+ anchor = line_anchor(srt_alignment(text) or ALIGN[args.position], play_w, play_h, margin, margin_l, margin_r)
789
907
  text = ass_escape_markup(text, colour=not args.karaoke)
790
- fx = ""
791
- if args.animate == "fade":
792
- fx = "{\\fad(200,200)}"
793
- elif args.animate == "pop":
794
- fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
795
- elif args.animate == "slide":
796
- fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
908
+ cue_ms = max(1, int(round((end - start) * 100))) * 10
909
+ fx = animate_fx(args.animate, 0, cue_ms, cue_ms, anchor, int(30 * scale))
910
+ size_fx = ""
797
911
  body = text
798
912
  # --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
799
913
  # see fit_size()'s docstring for why a size that changes cue to cue is not the default.
@@ -803,7 +917,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
803
917
  own_sizes = getattr(args, "_fit_sizes_out", None) or []
804
918
  own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
805
919
  if own and own != args.size:
806
- fx += "{\\fs%d}" % int(round(own * scale))
920
+ size_fx = "{\\fs%d}" % int(round(own * scale))
921
+ fx += size_fx
807
922
  if args.karaoke and word_style:
808
923
  # One Dialogue event per WORD, each carrying the whole cue -- \k/\kf can only
809
924
  # interpolate colour, so scaling/emboldening the active word (and a third "upcoming"
@@ -819,18 +934,21 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
819
934
  # today's karaoke-off rendering, rather than zero Dialogue lines for this cue
820
935
  out_body = body
821
936
  if EMOJI_SENTINEL in out_body:
822
- out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
937
+ out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
823
938
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{out_body}")
824
939
  continue
825
940
  n_words = len(real_idx)
826
- durs = _karaoke_word_durations(args, video, start, end, n_words)
827
- starts_cs, acc = [], 0
828
- for d in durs:
829
- starts_cs.append(acc)
941
+ lead, durs = _karaoke_word_durations(args, video, start, end, n_words)
942
+ # (first cs, cs, active word): the silence before the first word is an event of its
943
+ # own in which no word is active yet (A59)
944
+ events, acc = ([(0, lead, -1)] if lead > 0 else []), lead
945
+ for j, d in enumerate(durs):
946
+ events.append((acc, d, j))
830
947
  acc += d
831
- for j in range(n_words):
832
- w_start = start + starts_cs[j] / 100.0
833
- w_end = start + (starts_cs[j] + durs[j]) / 100.0
948
+ span_ms = acc * 10 # the --animate envelope spans the events, first to last
949
+ for ev_cs, ev_len, j in events:
950
+ w_start = start + ev_cs / 100.0
951
+ w_end = start + (ev_cs + ev_len) / 100.0
834
952
  # colour/scale each real word by its rank against the active one (j); an
835
953
  # emoji-placeholder token is never active and just keeps its plain text
836
954
  styled, flat_pos = [], 0
@@ -855,18 +973,19 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
855
973
  styled.append(" ".join(styled_line))
856
974
  out_body = "\\N".join(styled)
857
975
  if EMOJI_SENTINEL in out_body:
858
- out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
859
- # the size/fit override (\fsN) belongs on every event; the entrance animation
860
- # (\fad/\move/pop) only on the cue's FIRST word event -- replaying it on every
861
- # later word would re-fade/re-pop the whole cue each time the highlight advances
862
- event_fx = fx if j == 0 else (fx[fx.index("{\\fs"):] if "{\\fs" in fx else "")
976
+ out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
977
+ # the size/fit override (\fsN) belongs on every event; --animate is the cue's one
978
+ # envelope, and each event takes the part of it that falls inside the event: the
979
+ # fade-out at the cue's end, the slide and the pop carried on, not restarted (A57)
980
+ event_fx = animate_fx(args.animate, ev_cs * 10, (ev_cs + ev_len) * 10, span_ms, anchor,
981
+ int(30 * scale)) + size_fx
863
982
  lines.append(f"Dialogue: 0,{t(w_start)},{t(w_end)},Default,,0,0,0,,{event_fx}{out_body}")
864
983
  continue
865
984
  if args.karaoke:
866
985
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
867
986
  segments = body.split("\\N")
868
987
  words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
869
- durs = _karaoke_word_durations(args, video, start, end, len(words))
988
+ lead, durs = _karaoke_word_durations(args, video, start, end, len(words))
870
989
  it = iter(durs)
871
990
  out_segments = []
872
991
  for seg in segments:
@@ -877,8 +996,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
877
996
  out_segments.append(" ".join(
878
997
  ("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
879
998
  body = "\\N".join(out_segments)
999
+ if lead > 0:
1000
+ # the silence before the first word: an empty \k syllable, so the fill starts
1001
+ # with the speech and not at the cue's start (A59)
1002
+ body = "{\\k%d}" % lead + body
880
1003
  if EMOJI_SENTINEL in body:
881
- body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
1004
+ body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size), back_alpha))
882
1005
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
883
1006
  with open(path, "w", encoding="utf-8-sig") as fh:
884
1007
  fh.write("\n".join(header + lines) + "\n")
@@ -1214,6 +1337,13 @@ def main() -> int:
1214
1337
  "ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
1215
1338
  if args.animate != "none" or args.karaoke:
1216
1339
  die("--animate/--karaoke render pixels into the picture and require --mode burn")
1340
+ if args.max_lines != 2 or args.wrap != "phrase":
1341
+ # A55: a soft track is wrapped by the player at the size it draws; this run does not wrap
1342
+ # or split it, so a layout flag would be accepted and do nothing
1343
+ asked = f"--max-lines {args.max_lines}" if args.max_lines != 2 else f"--wrap {args.wrap}"
1344
+ die(f"{asked} lays out burned captions; --mode mux adds the cues as they are and the player wraps "
1345
+ "a soft subtitle at the size it draws it", kind="input",
1346
+ hint=f"drop {asked.split()[0]}, or use --mode burn")
1217
1347
 
1218
1348
  args.offset = signed_time_arg(str(args.offset), "--offset")
1219
1349
  if args.max_lines < 1:
@@ -1289,7 +1419,7 @@ def main() -> int:
1289
1419
  return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
1290
1420
  max_lines=args.max_lines, play_w=play_w, play_h=play_h,
1291
1421
  mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
1292
- safe_fraction=safe_width_fraction(args, play_w))
1422
+ safe_fraction=line_fraction(args, play_w))
1293
1423
 
1294
1424
  def fit_the_size(cue_list):
1295
1425
  """Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
@@ -1331,7 +1461,7 @@ def main() -> int:
1331
1461
  # budget and then drawing some cues large put lines off the side of the frame.
1332
1462
  args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
1333
1463
  play_w, play_h,
1334
- safe_fraction=safe_width_fraction(args, play_w))
1464
+ safe_fraction=line_fraction(args, play_w))
1335
1465
  for i in range(len(cue_list))]
1336
1466
  args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
1337
1467
 
@@ -1340,7 +1470,9 @@ def main() -> int:
1340
1470
  --offset -- the one place every cue source goes through, so an SRT, a cue file and a
1341
1471
  transcript all come out equally readable."""
1342
1472
  fit_the_size(cue_list)
1343
- out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
1473
+ # A55 (#457): a soft subtitle is laid out by the player, at a size this run does not
1474
+ # know; wrapping it at the burn-in size split a cue on a portrait video into three
1475
+ out, stats = layout_cues(cue_list, max_em=None if args.mode == "mux" else max_line_em(args, play_w, play_h),
1344
1476
  max_lines=args.max_lines, min_duration=args.min_duration,
1345
1477
  offset=args.offset, wrap=args.wrap, lang=args.language,
1346
1478
  per_cue_em=getattr(args, "_fit_cue_em", None),
@@ -1360,15 +1492,24 @@ def main() -> int:
1360
1492
  for token in (args.srt or []):
1361
1493
  path_part, lang_part = split_srt_lang(token)
1362
1494
  srt_tracks.append((path_part, lang_part))
1363
- if len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
1495
+ # --language tags the track only where there is a track to tag (A71, #457): a burn reads it as
1496
+ # the script and wrap hint, and refusing it as an --srt suffix named a flag nobody had given
1497
+ tagged_by_language = False
1498
+ if args.mode == "mux" and len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
1364
1499
  srt_tracks[0] = (srt_tracks[0][0], args.language)
1500
+ tagged_by_language = True
1501
+ if args.mode == "mux" and args.language and not srt_tracks and not LANG_TOKEN_RE.match(args.language):
1502
+ # the --text/--transcribe track is tagged with it too, and was tagged unchecked
1503
+ die(f"--language: '{args.language}' is not a language code (two or three letters, optionally with a "
1504
+ "region, e.g. en, ja, pt-BR)", kind="input")
1365
1505
  if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
1366
1506
  die("burning renders pixels; only one language can be in the picture -- burn one and mux "
1367
1507
  "the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
1368
1508
  seen_langs = [lang for _p, lang in srt_tracks if lang]
1369
1509
  for lang in seen_langs:
1370
1510
  if not LANG_TOKEN_RE.match(lang):
1371
- die(f"--srt: '{lang}' is not a language code (two or three letters, optionally with a "
1511
+ die(f"{'--language' if tagged_by_language else '--srt'}: '{lang}' is not a language code (two or "
1512
+ "three letters, optionally with a "
1372
1513
  "region, e.g. en, ja, pt-BR)", kind="input")
1373
1514
  if len(set(seen_langs)) != len(seen_langs):
1374
1515
  dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
@@ -1464,7 +1605,8 @@ def main() -> int:
1464
1605
  cues = asr_run.cues
1465
1606
  args._asr = dict(asr_run.facts)
1466
1607
  args._asr_notes = list(asr_run.notes)
1467
- args._word_timings = whisper_word_timings(srt_path) or sorted((w["start"], w["end"], w["word"]) for w in asr_run.words)
1608
+ args._word_timings = shift_words(whisper_word_timings(srt_path) or sorted(
1609
+ (w["start"], w["end"], w["word"]) for w in asr_run.words), args.offset)
1468
1610
  cues, changed = lay_out(cues)
1469
1611
  if changed:
1470
1612
  write_srt(cues, srt_path)
@@ -1629,11 +1771,11 @@ def main() -> int:
1629
1771
  side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
1630
1772
  "rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
1631
1773
  added[n] = (copy, lang)
1632
- if len(added) > 1 and any(caption_stats.get(k) for k in ("wrapped", "split", "extended")):
1633
- # --max-lines and --min-duration lay out the first --srt, the track the size fit is
1634
- # made for; the others keep their own line breaks and durations, and the result says so
1635
- side_notes.append(f"--max-lines/--min-duration laid out the first --srt only: the other {len(added) - 1} "
1636
- "added track(s) keep the line breaks and cue lengths of their own files")
1774
+ if len(added) > 1 and caption_stats.get("extended"):
1775
+ # --min-duration lengthens the first --srt's cues; the others keep their own durations,
1776
+ # and the result says so (no track is wrapped or split in a mux, A55)
1777
+ side_notes.append(f"--min-duration lengthened cues of the first --srt only: the other {len(added) - 1} "
1778
+ "added track(s) keep the cue lengths of their own files")
1637
1779
  titles = list(args.track_title or [])
1638
1780
  mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
1639
1781
  dropped_titles: List[str] = []
@@ -1645,7 +1787,7 @@ def main() -> int:
1645
1787
  if meta.get("audio"):
1646
1788
  maps += ["-map", f"0:a:{args.audio_stream}"]
1647
1789
  if existing_subs:
1648
- maps += ["-map", "0:s?"]
1790
+ maps += ["-map", "0:s?"] + carried_stream_maps(args.input, output, chapters=False) # their fonts (A256)
1649
1791
  for n in range(len(added)):
1650
1792
  maps += ["-map", f"{n + 1}:0"]
1651
1793
  cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
@@ -1773,7 +1915,7 @@ def main() -> int:
1773
1915
  elif font_file or not args.fonts_dir:
1774
1916
  # the family libass is given is the sanitised one (ass_font_name): that is what it resolves
1775
1917
  font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
1776
- renderer="libass"))
1918
+ renderer="libass", bold=bool(args.bold)))
1777
1919
  else:
1778
1920
  font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
1779
1921
  "fonts_dir": args.fonts_dir, "substituted": False}
@@ -1801,6 +1943,15 @@ def main() -> int:
1801
1943
  brand if args.brand else None)
1802
1944
  # the PNG route and --emoji none both change the drawn text, so they need the generated ASS
1803
1945
  force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
1946
+ if (not args.ass and not force_ass and args.animate == "none" and not args.karaoke
1947
+ and any(SRT_HIDDEN_BRACES_RE.search(t) for _s, _e, t in src_cues)):
1948
+ # A68 (#457): ffmpeg's SRT reader hands `{curly}` to libass as an override block, which
1949
+ # draws nothing of it, while the generated ASS draws the braces and their text; the cues
1950
+ # go the generated-ASS way, so the burned words are the words given
1951
+ force_ass = True
1952
+ info("cue text has braces that ffmpeg's SRT reader would hide; drawing the cues through the generated ASS")
1953
+ side_notes.append("the cues hold text in braces, which a plain SRT burn does not draw ({...} is an override "
1954
+ "block to libass), so they were drawn through the generated ASS, braces included")
1804
1955
  if emoji_plan and emoji_plan.get("box_px"):
1805
1956
  args._emoji_box_px = emoji_plan["box_px"]
1806
1957
 
@@ -1812,7 +1963,8 @@ def main() -> int:
1812
1963
  planned_cues if planned_cues is not None else parse_srt(srt_path)))
1813
1964
  if args.karaoke and not getattr(args, "_word_timings", None):
1814
1965
  # Use the cue source's sidecar, even when layout rewrites the caller's SRT.
1815
- args._word_timings = whisper_word_timings(args.srt if args.srt and not (args.text or args.transcribe) else srt_path)
1966
+ args._word_timings = shift_words(whisper_word_timings(
1967
+ args.srt if args.srt and not (args.text or args.transcribe) else srt_path), args.offset)
1816
1968
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1817
1969
  claim([(ass_path, "ASS")])
1818
1970
  w, h = display_size(meta["video"])
package/scripts/color.py CHANGED
@@ -32,7 +32,7 @@ import os
32
32
  import sys
33
33
  from typing import List, Optional
34
34
 
35
- from _common.runner import fail_if_classified
35
+ from _common.runner import carried_stream_maps, fail_if_classified
36
36
  from _common import STATE, add_common, analyze_levels, LEGAL_RANGE_CHOICES, legal_limits, measure_signal_range, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
37
37
  from _common.emit import note, warn
38
38
  from _common.color import is_rgb, rgb_to_bt709_filter
@@ -329,7 +329,8 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
329
329
  ext = os.path.splitext(output)[1].lower()
330
330
  tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
331
331
  bsf = retag_bsf(meta, args.retag)
332
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy"] + bsf + tags
332
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
333
+ + ["-c", "copy"] + bsf + tags)
333
334
  if ext in (".mp4", ".mov", ".m4v"):
334
335
  cmd += ["-movflags", "+faststart"]
335
336
  cmd.append(output)
@@ -357,8 +358,9 @@ def retag(args: argparse.Namespace, meta: dict, has_audio: bool, src_alpha=lambd
357
358
  info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
358
359
  label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
359
360
  enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
360
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
361
- "-map", "0:s?", "-map", "0:d?"] + label + enc + ["-c:s", "copy", "-c:d", "copy", output]
361
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
362
+ "-map", "0:s?", "-map", "0:d?"] + carried_stream_maps(args.input, output) + label + enc
363
+ + ["-c:s", "copy", "-c:d", "copy", output])
362
364
  proc2 = run(cmd, check=False)
363
365
  fail_if_classified(proc2)
364
366
  if proc2.returncode != 0:
@@ -552,7 +554,8 @@ def main() -> int:
552
554
  die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
553
555
  if not v.get("dolby_vision"):
554
556
  warn("no Dolby Vision metadata detected; removing unregistered SEI anyway", "dovi_not_detected")
555
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
557
+ cmd = (ffmpeg_base() + ["-i", args.input, "-map", "0"] + carried_stream_maps(args.input, output, attachments=False)
558
+ + ["-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"])
556
559
  if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
557
560
  cmd += ["-movflags", "+faststart"]
558
561
  cmd.append(output)
package/scripts/crop.py CHANGED
@@ -62,7 +62,9 @@ def main() -> int:
62
62
  has_audio = bool(meta.get("audio"))
63
63
 
64
64
  output = args.output or default_output(args.input, "crop")
65
- vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
65
+ # exact=1: without it crop rounds an odd --x/--y down to the chroma grid of a 4:2:0 frame,
66
+ # and the rectangle written was one pixel left of / above the one asked for (A170's sibling)
67
+ vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}:exact=1", "setsar=1"]
66
68
  if square_pixels(meta["video"]):
67
69
  vf.insert(0, square_pixels(meta["video"]))
68
70
  note(square_pixels_note(meta["video"]))
package/scripts/export.py CHANGED
@@ -46,7 +46,7 @@ import tempfile
46
46
  from pathlib import Path
47
47
  from typing import Dict, List, Optional
48
48
 
49
- from _common.runner import child_exit_code, fail_if_classified, kept_clause, mark_delivered
49
+ from _common.runner import carried_stream_maps, child_exit_code, fail_if_classified, kept_clause, mark_delivered
50
50
  from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
51
51
  from _common.decision import hw_preset_video, restate_last_swap
52
52
  from _common.emit import note, record_fallback, record_skip, warn
@@ -321,15 +321,19 @@ _subtitle_codec = subtitle_codec_for
321
321
 
322
322
  def _data_streams(path: str) -> List[Dict[str, object]]:
323
323
  """The data streams of a file ({index, codec}), without its attachments (probe()'s
324
- data_streams counts both, and -map 0:d maps only the data streams)."""
324
+ data_streams counts both, and -map 0:d maps only the data streams) and without an MP4/MOV
325
+ chapter track, which the muxer writes again from the chapters (carried_stream_maps, A173)."""
325
326
  proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "d", "-show_entries",
326
- "stream=codec_name,codec_tag_string", "-of", "json", path], quiet=True)
327
+ "stream=codec_name,codec_tag_string:chapter=id", "-of", "json", path], quiet=True)
327
328
  try:
328
- streams = json.loads(proc.stdout or "{}").get("streams") or []
329
+ doc = json.loads(proc.stdout or "{}")
329
330
  except ValueError:
330
331
  # an unread list would map nothing and report nothing dropped
331
332
  die(f"ffprobe's data-stream list for {path} did not parse", kind="ffmpeg")
332
- return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)]
333
+ streams = doc.get("streams") or []
334
+ chapter_track = bool(doc.get("chapters"))
335
+ return [{"index": n, "codec": s.get("codec_name") or s.get("codec_tag_string")} for n, s in enumerate(streams)
336
+ if not (chapter_track and str(s.get("codec_tag_string") or "").lower() == "text")]
333
337
 
334
338
 
335
339
  def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data: int) -> List[str]:
@@ -338,10 +342,13 @@ def _run_keeping_non_av(cmd: List[str], output: str, sub_codecs: List[str], data
338
342
  subtitles + data, subtitles alone, neither; each retry is a `non_av_streams_dropped` fallback
339
343
  with the attempt's error. `cmd` already maps the picture and the sound and has no output.
340
344
  Returns the stream types dropped ([] when everything mapped was written)."""
345
+ src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
341
346
  sub_args: List[str] = ["-map", "0:s"] if sub_codecs else []
342
347
  for n, codec in enumerate(sub_codecs):
343
348
  sub_args += [f"-c:s:{n}", codec]
344
- data_args = ["-map", "0:d", "-c:d", "copy"] if data else []
349
+ if sub_codecs:
350
+ sub_args += carried_stream_maps(src, output, chapters=False) # the fonts the subtitles are drawn with (A256)
351
+ data_args = (["-map", "0:d"] + carried_stream_maps(src, output, attachments=False) + ["-c:d", "copy"]) if data else []
345
352
  rungs = [(sub_args + data_args, [])]
346
353
  if sub_codecs and data:
347
354
  rungs.append((sub_args, ["data"]))
@@ -389,7 +396,9 @@ def _non_av_report(subs: List[Dict], sub_codecs: List[str], data: List[Dict], dr
389
396
  written_data = _data_streams(output)
390
397
  for kind, streams, written in (("subtitle", subs, written_subs), ("data", data, written_data)):
391
398
  for n, s in enumerate(streams):
392
- row = {"type": kind, "index": n, "codec": s.get("codec")}
399
+ # a data row keeps its n-th-of-type index in the source: the chapter text track is left
400
+ # out of the list, so a timecode track after it is data stream 1, not 0
401
+ row = {"type": kind, "index": s.get("index", n) if kind == "data" else n, "codec": s.get("codec")}
393
402
  if kind == "subtitle":
394
403
  row["language"] = s.get("language")
395
404
  if STATE.dry_run and kind not in dropped:
package/scripts/freeze.py CHANGED
@@ -67,7 +67,8 @@ def main() -> int:
67
67
  # no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
68
68
  # and the hold lost, so hold the last frame as --mode extend does (#416 review)
69
69
  vend = v0 + (meta["video"].get("duration") or dur - v0)
70
- if args.mode == "extend" or cut_at >= vend - 0.5 / rate:
70
+ holds_end = args.mode == "extend" or cut_at >= vend - 0.5 / rate
71
+ if holds_end:
71
72
  # tpad's stop_duration clones the last frame for the given duration; no PTS surgery
72
73
  # needed since it only ever appends past the real end.
73
74
  vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
@@ -127,10 +128,12 @@ def main() -> int:
127
128
  cmd += aac_args()
128
129
  else:
129
130
  cmd += ["-an"]
130
- if args.mode == "insert":
131
+ if not holds_end:
131
132
  # the picture gains `hold` seconds at `at`; a stream-copied subtitle track would keep its
132
133
  # old timestamps and every cue after the freeze would fire early (same reasoning as
133
- # fit.py --method speed). Drop it rather than ship captions that lie.
134
+ # fit.py --method speed). Drop it rather than ship captions that lie. A hold of the last
135
+ # frame (the default with no --at, or an --at at the end) moves nothing, so the track is
136
+ # kept as --mode extend keeps it (A180)
134
137
  run(cmd + [output])
135
138
  dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
136
139
  else: