ffmpeg-skill 3.8.8 → 3.8.10

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.
@@ -46,6 +46,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
46
46
  from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
47
47
  strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
48
48
  from _common.emit import refuse # noqa: E402
49
+ from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
49
50
  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
50
51
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
51
52
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
@@ -103,6 +104,32 @@ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
103
104
  # an SRT timing line: the times alone, the cue text on the lines after it
104
105
  DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
105
106
  SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
107
+ # the same line as WebVTT may write it, without the hours: `00:01.000 --> 00:02.000`
108
+ TIMING_ONLY_RE = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*-->\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*$")
109
+ ASS_SECTIONS = ("[script info]", "[v4+ styles]", "[v4 styles]", "[events]")
110
+
111
+
112
+ def refuse_not_a_cue_file(path: str, lines: List[str]) -> None:
113
+ """--text given a file in another format (#404): read as a cue file, a WebVTT header, an ASS
114
+ section or a JSON document was burned in line by line, auto-timed 3 s apart. Named by what the
115
+ file starts with; the SRT timing-line check below catches a file with no such header."""
116
+ head = next((ln.strip() for ln in lines if ln.strip()), "")
117
+ cue_shape = "A cue file has one cue per line: `START-END text` (or text alone, timed --auto-seconds apart)"
118
+ if head.upper().startswith("WEBVTT"):
119
+ die(f"--text: {path} is a WebVTT subtitle file (it starts with {head[:20]!r}); pass it as --srt, which reads "
120
+ f"WebVTT (render.py: --srt, not --cues). {cue_shape}", kind="input")
121
+ if head.lower() in ASS_SECTIONS or head.startswith("Dialogue:"):
122
+ die(f"--text: {path} is an ASS/SSA subtitle file (it starts with {head[:20]!r}); pass it as --ass, which burns "
123
+ f"it with the styles inside it (in a render.py project, the captions stage's \"ass\" key). {cue_shape}",
124
+ kind="input")
125
+ if head[:1] in "{[":
126
+ try:
127
+ doc = json.loads("\n".join(lines))
128
+ except ValueError:
129
+ doc = None
130
+ if isinstance(doc, dict) or (isinstance(doc, list) and doc and isinstance(doc[0], (dict, list))):
131
+ die(f"--text: {path} is a JSON document, not a cue file. {cue_shape}. A whisper JSON is a transcript: "
132
+ "caption.py --transcribe writes the cues from the audio", kind="input")
106
133
 
107
134
 
108
135
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
@@ -111,7 +138,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
111
138
  # split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
112
139
  # made the rest of the line an auto-timed cue of its own
113
140
  lines = read_text_or_die(path, "--text").split("\n")
141
+ refuse_not_a_cue_file(path, lines)
114
142
  for n, raw in enumerate(lines, 1):
143
+ if TIMING_ONLY_RE.match(raw) and not SRT_TIMING_RE.match(raw):
144
+ # WebVTT's short form (no hours), in a file without the WEBVTT header
145
+ die(f"--text: {path} is a subtitle file, not a cue file (line {n} is a timing line with no text: "
146
+ f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
115
147
  if SRT_TIMING_RE.match(raw):
116
148
  # a cue file puts the text on the line with its times; this line has the times alone, so
117
149
  # the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
@@ -257,6 +289,9 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
257
289
  die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
258
290
  f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
259
291
  "or --emoji none to leave them out of the drawn text", kind="input")
292
+ if want == "mono" and support.get("libass_color"):
293
+ mode = "color"
294
+ warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
260
295
  plan = {"mode": mode, "count": len(clusters_all),
261
296
  "clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
262
297
  "assets": assets, "missing": [], "overlays": []}
@@ -286,12 +321,15 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
286
321
  scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
287
322
  # --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
288
323
  # pops in against a line that is still fading up. These match the \fad values below.
289
- fade_in, fade_out = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
290
- "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)))
291
325
  size_px = args.size * play_h / 288.0
292
326
  margin_px = args.margin * play_h / 288.0
293
327
  line_h = size_px * 1.2
294
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)
295
333
  out_cues = []
296
334
  for start, end, text in cues:
297
335
  lines = _cue_lines(text)
@@ -307,11 +345,11 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
307
345
  y_top = play_h - margin_px - (n - i) * line_h
308
346
  line_w = text_width_em(strip_srt_markup(line), scale) * size_px
309
347
  if align in (1, 4, 7):
310
- x0 = margin_px
348
+ x0 = float(margin_l)
311
349
  elif align in (3, 6, 9):
312
- x0 = play_w - margin_px - line_w
350
+ x0 = play_w - margin_r - line_w
313
351
  else:
314
- x0 = (play_w - line_w) / 2.0
352
+ x0 = margin_l + (play_w - margin_l - margin_r - line_w) / 2.0
315
353
  # libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
316
354
  # occupies the RIGHT end of the rendered line. Measuring the prefix from the left
317
355
  # edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
@@ -354,8 +392,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
354
392
  "--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
355
393
  "linearly -- split the job, or use --emoji none", kind="input")
356
394
  if plan["missing"]:
357
- warn("no PNG in the assets directory for " + ", ".join(plan["missing"]) +
358
- " -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
395
+ warn(emoji_missing_asset_note(plan["missing"], support), "emoji_assets_missing", missing=list(plan["missing"]))
359
396
  plan["box_px"] = int(round(box_px))
360
397
  return out_cues, plan
361
398
 
@@ -527,23 +564,31 @@ def safe_width_fraction(args, play_w: Optional[int]) -> float:
527
564
  return max(0.05, (play_w - left - right) / float(play_w))
528
565
 
529
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
+
530
585
  def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
531
586
  """How many em fit on one caption line at the chosen size, or None without video geometry.
532
587
 
533
588
  --size is in ASS points against a 288-line script (what libass's force_style uses), so the
534
- rendered pixel size is size * play_h / 288.
535
-
536
- --karaoke-style word scales its active word up by --karaoke-scale and freezes the line break
537
- (WrapStyle: 2 in write_ass) so libass can never re-wrap around that scale-up. The budget here
538
- is narrowed by the same factor so the longest wrapped line -- computed at the UNscaled width --
539
- still fits once its active word is drawn --karaoke-scale%% larger; without this, a line that
540
- 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.
541
590
  """
542
- em = line_em_for_size(args.size, play_w, play_h,
543
- safe_fraction=safe_width_fraction(args, play_w))
544
- if em is not None and getattr(args, "karaoke", False) and getattr(args, "karaoke_style", "sweep") == "word":
545
- em = em / max(1.0, getattr(args, "karaoke_scale", 112) / 100.0)
546
- return em
591
+ return line_em_for_size(args.size, play_w, play_h, safe_fraction=line_fraction(args, play_w))
547
592
 
548
593
 
549
594
  def parse_ass_dialogue(path: str) -> str:
@@ -658,35 +703,139 @@ def shift_ass_text(source: str, offset: float) -> Tuple[str, int, int]:
658
703
 
659
704
 
660
705
  def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
661
- n_words: int) -> Optional[List[int]]:
662
- """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)."""
663
711
  inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
664
712
  if len(inside) != n_words or n_words <= 0:
665
713
  return None
666
714
  total_cs = max(1, int(round((end - start) * 100)))
667
- 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]
668
718
  out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
669
- out[-1] += total_cs - sum(out)
719
+ out[-1] += total_cs - lead - sum(out)
670
720
  if out[-1] < 1:
671
721
  return None
672
- return out
722
+ return lead, out
673
723
 
674
724
 
675
- def _karaoke_word_durations(args, video: Optional[str], start: float, end: float, n_words: int) -> List[int]:
676
- """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.
677
728
 
678
729
  Real word timings from the transcript beat both the energy estimate and the even split --
679
- 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.
680
732
  """
681
733
  dur_cs = max(1, int(round((end - start) * 100)))
682
- durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, n_words)
683
- if durs is None:
684
- if getattr(args, "karaoke_timing", "even") == "energy" and video:
685
- 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)
686
835
  else:
687
- per = max(1, dur_cs // max(1, n_words))
688
- durs = [per] * n_words
689
- return durs
836
+ move = "\\pos(%d,%d)" % (x, y2)
837
+ tags = fade + move
838
+ return "{" + tags + "}" if tags else ""
690
839
 
691
840
 
692
841
  # an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
@@ -717,7 +866,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
717
866
  primary = ass_color(args.highlight_color if args.karaoke else args.color)
718
867
  secondary = ass_color(args.color)
719
868
  outline = ass_color(args.outline_color)
720
- 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)
721
871
  word_style = args.karaoke and getattr(args, "karaoke_style", "sweep") == "word"
722
872
  word_past = ass_color(args.color)
723
873
  word_active = ass_color(args.highlight_color)
@@ -752,14 +902,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
752
902
  # an override block. Newlines become \N in the same pass.
753
903
  # SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
754
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)
755
907
  text = ass_escape_markup(text, colour=not args.karaoke)
756
- fx = ""
757
- if args.animate == "fade":
758
- fx = "{\\fad(200,200)}"
759
- elif args.animate == "pop":
760
- fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
761
- elif args.animate == "slide":
762
- 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 = ""
763
911
  body = text
764
912
  # --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
765
913
  # see fit_size()'s docstring for why a size that changes cue to cue is not the default.
@@ -769,7 +917,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
769
917
  own_sizes = getattr(args, "_fit_sizes_out", None) or []
770
918
  own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
771
919
  if own and own != args.size:
772
- fx += "{\\fs%d}" % int(round(own * scale))
920
+ size_fx = "{\\fs%d}" % int(round(own * scale))
921
+ fx += size_fx
773
922
  if args.karaoke and word_style:
774
923
  # One Dialogue event per WORD, each carrying the whole cue -- \k/\kf can only
775
924
  # interpolate colour, so scaling/emboldening the active word (and a third "upcoming"
@@ -785,18 +934,21 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
785
934
  # today's karaoke-off rendering, rather than zero Dialogue lines for this cue
786
935
  out_body = body
787
936
  if EMOJI_SENTINEL in out_body:
788
- 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))
789
938
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{out_body}")
790
939
  continue
791
940
  n_words = len(real_idx)
792
- durs = _karaoke_word_durations(args, video, start, end, n_words)
793
- starts_cs, acc = [], 0
794
- for d in durs:
795
- 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))
796
947
  acc += d
797
- for j in range(n_words):
798
- w_start = start + starts_cs[j] / 100.0
799
- 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
800
952
  # colour/scale each real word by its rank against the active one (j); an
801
953
  # emoji-placeholder token is never active and just keeps its plain text
802
954
  styled, flat_pos = [], 0
@@ -821,18 +973,19 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
821
973
  styled.append(" ".join(styled_line))
822
974
  out_body = "\\N".join(styled)
823
975
  if EMOJI_SENTINEL in out_body:
824
- out_body = out_body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
825
- # the size/fit override (\fsN) belongs on every event; the entrance animation
826
- # (\fad/\move/pop) only on the cue's FIRST word event -- replaying it on every
827
- # later word would re-fade/re-pop the whole cue each time the highlight advances
828
- 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
829
982
  lines.append(f"Dialogue: 0,{t(w_start)},{t(w_end)},Default,,0,0,0,,{event_fx}{out_body}")
830
983
  continue
831
984
  if args.karaoke:
832
985
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
833
986
  segments = body.split("\\N")
834
987
  words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
835
- durs = _karaoke_word_durations(args, video, start, end, len(words))
988
+ lead, durs = _karaoke_word_durations(args, video, start, end, len(words))
836
989
  it = iter(durs)
837
990
  out_segments = []
838
991
  for seg in segments:
@@ -843,8 +996,12 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
843
996
  out_segments.append(" ".join(
844
997
  ("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
845
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
846
1003
  if EMOJI_SENTINEL in body:
847
- 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))
848
1005
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
849
1006
  with open(path, "w", encoding="utf-8-sig") as fh:
850
1007
  fh.write("\n".join(header + lines) + "\n")
@@ -1180,6 +1337,13 @@ def main() -> int:
1180
1337
  "ASS carries burn-only styling with no soft-subtitle equivalent; use --mode burn for an ASS file")
1181
1338
  if args.animate != "none" or args.karaoke:
1182
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")
1183
1347
 
1184
1348
  args.offset = signed_time_arg(str(args.offset), "--offset")
1185
1349
  if args.max_lines < 1:
@@ -1255,7 +1419,7 @@ def main() -> int:
1255
1419
  return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
1256
1420
  max_lines=args.max_lines, play_w=play_w, play_h=play_h,
1257
1421
  mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
1258
- safe_fraction=safe_width_fraction(args, play_w))
1422
+ safe_fraction=line_fraction(args, play_w))
1259
1423
 
1260
1424
  def fit_the_size(cue_list):
1261
1425
  """Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
@@ -1297,7 +1461,7 @@ def main() -> int:
1297
1461
  # budget and then drawing some cues large put lines off the side of the frame.
1298
1462
  args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
1299
1463
  play_w, play_h,
1300
- safe_fraction=safe_width_fraction(args, play_w))
1464
+ safe_fraction=line_fraction(args, play_w))
1301
1465
  for i in range(len(cue_list))]
1302
1466
  args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
1303
1467
 
@@ -1306,7 +1470,9 @@ def main() -> int:
1306
1470
  --offset -- the one place every cue source goes through, so an SRT, a cue file and a
1307
1471
  transcript all come out equally readable."""
1308
1472
  fit_the_size(cue_list)
1309
- 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),
1310
1476
  max_lines=args.max_lines, min_duration=args.min_duration,
1311
1477
  offset=args.offset, wrap=args.wrap, lang=args.language,
1312
1478
  per_cue_em=getattr(args, "_fit_cue_em", None),
@@ -1326,15 +1492,24 @@ def main() -> int:
1326
1492
  for token in (args.srt or []):
1327
1493
  path_part, lang_part = split_srt_lang(token)
1328
1494
  srt_tracks.append((path_part, lang_part))
1329
- 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:
1330
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")
1331
1505
  if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
1332
1506
  die("burning renders pixels; only one language can be in the picture -- burn one and mux "
1333
1507
  "the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
1334
1508
  seen_langs = [lang for _p, lang in srt_tracks if lang]
1335
1509
  for lang in seen_langs:
1336
1510
  if not LANG_TOKEN_RE.match(lang):
1337
- 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 "
1338
1513
  "region, e.g. en, ja, pt-BR)", kind="input")
1339
1514
  if len(set(seen_langs)) != len(seen_langs):
1340
1515
  dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
@@ -1430,7 +1605,8 @@ def main() -> int:
1430
1605
  cues = asr_run.cues
1431
1606
  args._asr = dict(asr_run.facts)
1432
1607
  args._asr_notes = list(asr_run.notes)
1433
- 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)
1434
1610
  cues, changed = lay_out(cues)
1435
1611
  if changed:
1436
1612
  write_srt(cues, srt_path)
@@ -1595,11 +1771,11 @@ def main() -> int:
1595
1771
  side_notes.append(f"subtitle track {n} is {copy}, a copy of {os.path.basename(path)} with its timings "
1596
1772
  "rewritten as hh:mm:ss,mmm, which ffmpeg reads as this tool does")
1597
1773
  added[n] = (copy, lang)
1598
- if len(added) > 1 and any(caption_stats.get(k) for k in ("wrapped", "split", "extended")):
1599
- # --max-lines and --min-duration lay out the first --srt, the track the size fit is
1600
- # made for; the others keep their own line breaks and durations, and the result says so
1601
- side_notes.append(f"--max-lines/--min-duration laid out the first --srt only: the other {len(added) - 1} "
1602
- "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")
1603
1779
  titles = list(args.track_title or [])
1604
1780
  mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
1605
1781
  dropped_titles: List[str] = []
@@ -1739,7 +1915,7 @@ def main() -> int:
1739
1915
  elif font_file or not args.fonts_dir:
1740
1916
  # the family libass is given is the sanitised one (ass_font_name): that is what it resolves
1741
1917
  font_used = record_font_choice(font_choice(ass_font_name(args.font), script_file=font_file, script_family=font_family,
1742
- renderer="libass"))
1918
+ renderer="libass", bold=bool(args.bold)))
1743
1919
  else:
1744
1920
  font_used = {"asked": ass_font_name(args.font), "file": None, "family": ass_font_name(args.font), "how": "fonts_dir",
1745
1921
  "fonts_dir": args.fonts_dir, "substituted": False}
@@ -1767,6 +1943,15 @@ def main() -> int:
1767
1943
  brand if args.brand else None)
1768
1944
  # the PNG route and --emoji none both change the drawn text, so they need the generated ASS
1769
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")
1770
1955
  if emoji_plan and emoji_plan.get("box_px"):
1771
1956
  args._emoji_box_px = emoji_plan["box_px"]
1772
1957
 
@@ -1778,7 +1963,8 @@ def main() -> int:
1778
1963
  planned_cues if planned_cues is not None else parse_srt(srt_path)))
1779
1964
  if args.karaoke and not getattr(args, "_word_timings", None):
1780
1965
  # Use the cue source's sidecar, even when layout rewrites the caller's SRT.
1781
- 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)
1782
1968
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1783
1969
  claim([(ass_path, "ASS")])
1784
1970
  w, h = display_size(meta["video"])
@@ -1934,7 +2120,9 @@ def main() -> int:
1934
2120
  notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
1935
2121
  "--emoji-assets DIR for colour)")
1936
2122
  if emoji_plan["mode"] == "none":
1937
- notes.append("emoji stripped from the drawn text (--emoji none)")
2123
+ # --emoji none was asked for only when it was: an auto run that can draw none says that
2124
+ notes.append("emoji stripped from the drawn text (--emoji none)" if getattr(args, "emoji", "auto") == "none" else
2125
+ "emoji left out of the drawn text: nothing here draws them (--emoji auto; the emoji_not_drawn warning says why)")
1938
2126
  if emoji_plan["missing"]:
1939
2127
  notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
1940
2128
  if notes:
package/scripts/color.py CHANGED
@@ -420,6 +420,41 @@ def legalize_report(standard: str, output: str) -> "tuple":
420
420
  "worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
421
421
 
422
422
 
423
+ def refuse_bad_cube(path: str) -> None:
424
+ """--lut given a .cube that holds no complete 3D LUT (#404): lut3d failed on it with "Invalid
425
+ data found when processing input", which reads as an unreadable video and got that hint. Only
426
+ .cube is checked (lut3d's other formats are left to it), by what lut3d itself needs: a
427
+ LUT_3D_SIZE line and size^3 rows that start with three numbers."""
428
+ if os.path.splitext(path)[1].lower() != ".cube":
429
+ return
430
+ size = rows = 0
431
+ one_d = False
432
+ try:
433
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
434
+ for line in fh:
435
+ words = line.split()
436
+ if not words or words[0].startswith("#"):
437
+ continue
438
+ if words[0] == "LUT_3D_SIZE" and len(words) > 1 and words[1].isdigit():
439
+ size = int(words[1])
440
+ elif words[0] == "LUT_1D_SIZE":
441
+ one_d = True
442
+ elif len(words) >= 3:
443
+ try:
444
+ [float(w) for w in words[:3]]
445
+ rows += 1
446
+ except ValueError:
447
+ pass
448
+ except OSError as e:
449
+ die(f"--lut: cannot read {path}: {e.strerror}", kind="input")
450
+ if size < 2 or rows < size ** 3:
451
+ what = ("it is a 1D LUT (LUT_1D_SIZE), and --lut applies a 3D one" if one_d and size < 2
452
+ else "it has no LUT_3D_SIZE line" if size < 2
453
+ else f"it has {rows} rows of three numbers, and a LUT_3D_SIZE {size} table has {size ** 3}")
454
+ die(f"--lut {path} is not a complete 3D .cube LUT: {what}", kind="input",
455
+ hint="a .cube file is plain text with a LUT_3D_SIZE line and size^3 rows of three numbers; export it again from the grading tool")
456
+
457
+
423
458
  def main() -> int:
424
459
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
425
460
  ap.add_argument("input")
@@ -565,6 +600,7 @@ def main() -> int:
565
600
  f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
566
601
  if not os.path.exists(args.lut):
567
602
  die(f"LUT not found: {args.lut}")
603
+ refuse_bad_cube(args.lut)
568
604
  if not (0.0 <= args.lut_strength <= 1.0):
569
605
  die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
570
606
  lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"