videopython 0.34.0__tar.gz → 0.34.1__tar.gz

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.
Files changed (60) hide show
  1. {videopython-0.34.0 → videopython-0.34.1}/PKG-INFO +1 -1
  2. {videopython-0.34.0 → videopython-0.34.1}/pyproject.toml +1 -1
  3. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/image_text.py +274 -97
  4. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/transcription_overlay.py +12 -1
  5. {videopython-0.34.0 → videopython-0.34.1}/.gitignore +0 -0
  6. {videopython-0.34.0 → videopython-0.34.1}/LICENSE +0 -0
  7. {videopython-0.34.0 → videopython-0.34.1}/README.md +0 -0
  8. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/__init__.py +0 -0
  9. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/__init__.py +0 -0
  10. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/_device.py +0 -0
  11. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/__init__.py +0 -0
  12. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/config.py +0 -0
  13. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/dubber.py +0 -0
  14. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/expressiveness.py +0 -0
  15. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/loudness.py +0 -0
  16. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/models.py +0 -0
  17. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/pipeline.py +0 -0
  18. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/quality.py +0 -0
  19. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/remux.py +0 -0
  20. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/timing.py +0 -0
  21. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/dubbing/voice_sample.py +0 -0
  22. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/__init__.py +0 -0
  23. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/audio.py +0 -0
  24. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/image.py +0 -0
  25. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/qwen3.py +0 -0
  26. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/translation.py +0 -0
  27. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/generation/video.py +0 -0
  28. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/transforms.py +0 -0
  29. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/__init__.py +0 -0
  30. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/audio.py +0 -0
  31. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/faces.py +0 -0
  32. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/image.py +0 -0
  33. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/separation.py +0 -0
  34. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/understanding/temporal.py +0 -0
  35. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/video_analysis/__init__.py +0 -0
  36. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/video_analysis/analyzer.py +0 -0
  37. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/video_analysis/models.py +0 -0
  38. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/video_analysis/sampling.py +0 -0
  39. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/ai/video_analysis/stages.py +0 -0
  40. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/audio/__init__.py +0 -0
  41. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/audio/analysis.py +0 -0
  42. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/audio/audio.py +0 -0
  43. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/__init__.py +0 -0
  44. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/_dimensions.py +0 -0
  45. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/_ffmpeg.py +0 -0
  46. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/_video_io.py +0 -0
  47. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/description.py +0 -0
  48. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/exceptions.py +0 -0
  49. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/fonts/DejaVuSans.ttf +0 -0
  50. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/fonts/LICENSE_DEJAVU +0 -0
  51. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/fonts/__init__.py +0 -0
  52. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/transcription.py +0 -0
  53. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/base/video.py +0 -0
  54. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/__init__.py +0 -0
  55. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/effects.py +0 -0
  56. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/operation.py +0 -0
  57. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/streaming.py +0 -0
  58. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/transforms.py +0 -0
  59. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/editing/video_edit.py +0 -0
  60. {videopython-0.34.0 → videopython-0.34.1}/src/videopython/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: videopython
3
- Version: 0.34.0
3
+ Version: 0.34.1
4
4
  Summary: Minimal video generation and processing library.
5
5
  Project-URL: Homepage, https://videopython.com
6
6
  Project-URL: Repository, https://github.com/bartwojtowicz/videopython/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "videopython"
3
- version = "0.34.0"
3
+ version = "0.34.1"
4
4
  description = "Minimal video generation and processing library."
5
5
  authors = [
6
6
  { name = "Bartosz Wójtowicz", email = "bartoszwojtowicz@outlook.com" },
@@ -96,6 +96,17 @@ class TextBoxRect:
96
96
  callers short-circuit such boxes (nothing to draw). ``width`` mirrors the
97
97
  resolved ``box_width`` and may be a float when an absolute >1 value was
98
98
  passed, matching legacy behaviour.
99
+
100
+ ``content_width`` is the widest a rendered line actually gets -- worst
101
+ case over the animated highlight when ``highlight_size_multiplier > 1``.
102
+
103
+ There are two independent notions of "fitting" here. ``fits`` is
104
+ box-vs-image *only* -- the legacy contract that gates
105
+ :meth:`write_text_box`'s ``OutOfBoundsError`` -- and does **not** imply
106
+ the content fits the box: legacy callers intentionally overflow the box
107
+ while staying inside the image. A caller that needs the content inside
108
+ the box (subtitles, where the box is frame-clamped) must additionally
109
+ check ``content_width <= width`` itself.
99
110
  """
100
111
 
101
112
  x: float
@@ -104,6 +115,27 @@ class TextBoxRect:
104
115
  height: int
105
116
  fits: bool
106
117
  lines: tuple[str, ...]
118
+ content_width: int = 0
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _WordPlacement:
123
+ """One word's resolved font/size and pixel offset within a highlighted line.
124
+
125
+ ``dx``/``dy`` are offsets from the line's left/top. Produced once by
126
+ :meth:`ImageText._layout_highlighted_line` and consumed by both the
127
+ measurer and the renderer, so the box reserved by ``measure_text_box``
128
+ and the pixels drawn by ``write_text_box`` cannot disagree.
129
+ """
130
+
131
+ word: str
132
+ font_filename: str | None
133
+ font_size: int
134
+ width: int
135
+ height: int
136
+ dx: int
137
+ dy: int
138
+ is_highlighted: bool
107
139
 
108
140
 
109
141
  class ImageText:
@@ -614,16 +646,22 @@ class ImageText:
614
646
  font_size: int = 11,
615
647
  anchor: AnchorPoint = AnchorPoint.TOP_LEFT,
616
648
  margin: MarginType = 0,
649
+ highlight_size_multiplier: float = 1.0,
650
+ highlight_bold_font: str | None = None,
617
651
  ) -> TextBoxRect:
618
652
  """Measure where a wrapped text box would land, without drawing it.
619
653
 
620
654
  Pure: resolves margins/box-width/position, wraps the text, applies the
621
655
  anchor, and bounds-checks against the image — the exact math
622
- :meth:`write_text_box` used to do inline. Highlighting and per-line
623
- alignment (``place``) do not change the box envelope, so they are not
624
- parameters here; this intentionally preserves the pre-existing
625
- behaviour that an enlarged highlighted word is *not* accounted for in
626
- the fit check.
656
+ :meth:`write_text_box` used to do inline.
657
+
658
+ ``highlight_size_multiplier > 1`` makes the measurement worst-case for
659
+ an *animated* highlight (any word may be the enlarged one over the
660
+ cue's lifetime): wrapping reserves room so even an enlarged word keeps
661
+ its line within ``box_width``, and ``height`` uses each line's tallest
662
+ possible highlighted variant. With the default ``1.0`` the result is
663
+ byte-identical to the plain base-font measurement, so existing callers
664
+ and ``place`` (alignment) are unaffected.
627
665
 
628
666
  Returns:
629
667
  A :class:`TextBoxRect`. ``fits`` is ``False`` when the box would
@@ -655,15 +693,32 @@ class ImageText:
655
693
  # Calculate initial position based on margin and anchor before splitting text
656
694
  x_pos, y_pos = self._convert_position(xy, margin_top, margin_left, available_width, available_height)
657
695
 
658
- # Split text into lines that fit within box_width
696
+ # Wrap at the real box width (same as the renderer).
659
697
  lines = self._split_lines_by_width(text, font_filename, font_size, int(box_width))
660
698
 
661
- # Calculate total height of all lines
662
- lines_height = sum(self.get_text_dimensions(font_filename, font_size, line)[1] for line in lines)
699
+ # Per-line extent. With an animated highlight any word may be the
700
+ # enlarged one over the cue's lifetime, so each line contributes the
701
+ # widest/tallest variant it could ever render as.
702
+ # ``_highlighted_line_max_extent`` derives that envelope from the same
703
+ # per-word geometry the renderer uses (single source of truth).
704
+ hl_mult = max(1.0, highlight_size_multiplier)
705
+ content_width = 0
706
+ lines_height = 0
707
+ for line in lines:
708
+ if hl_mult > 1.0:
709
+ line_w, line_h = self._highlighted_line_max_extent(
710
+ line, font_filename, font_size, hl_mult, highlight_bold_font
711
+ )
712
+ else:
713
+ line_w, line_h = self.get_text_dimensions(font_filename, font_size, line)
714
+ content_width = max(content_width, line_w)
715
+ lines_height += line_h
663
716
  if lines_height == 0:
664
717
  # No renderable lines (e.g. whitespace-only text); position is the
665
718
  # unadjusted insertion point and the box trivially "fits".
666
- return TextBoxRect(x=x_pos, y=y_pos, width=box_width, height=0, fits=True, lines=tuple(lines))
719
+ return TextBoxRect(
720
+ x=x_pos, y=y_pos, width=box_width, height=0, fits=True, lines=tuple(lines), content_width=0
721
+ )
667
722
 
668
723
  # Final position calculation based on anchor point
669
724
  if anchor in AnchorPoint.center_anchors():
@@ -682,7 +737,15 @@ class ImageText:
682
737
  or x_pos + box_width > self.image_size[1]
683
738
  or y_pos + lines_height > self.image_size[0]
684
739
  )
685
- return TextBoxRect(x=x_pos, y=y_pos, width=box_width, height=lines_height, fits=fits, lines=tuple(lines))
740
+ return TextBoxRect(
741
+ x=x_pos,
742
+ y=y_pos,
743
+ width=box_width,
744
+ height=lines_height,
745
+ fits=fits,
746
+ lines=tuple(lines),
747
+ content_width=content_width,
748
+ )
686
749
 
687
750
  def write_text_box(
688
751
  self,
@@ -761,7 +824,11 @@ class ImageText:
761
824
  if highlight_word_index is not None and highlight_color is None:
762
825
  highlight_color = text_color
763
826
 
764
- # Measure (single source of truth for box geometry), then render.
827
+ # Measure (single source of truth for box geometry), then render. When
828
+ # a word will be highlighted, measure worst-case so the box reserves
829
+ # room for the enlarged word -- otherwise stay byte-identical to the
830
+ # plain base-font measurement.
831
+ measure_mult = highlight_size_multiplier if highlight_word_index is not None else 1.0
765
832
  rect = self.measure_text_box(
766
833
  text=text,
767
834
  font_filename=font_filename,
@@ -770,6 +837,8 @@ class ImageText:
770
837
  font_size=font_size,
771
838
  anchor=anchor,
772
839
  margin=margin,
840
+ highlight_size_multiplier=measure_mult,
841
+ highlight_bold_font=highlight_bold_font,
773
842
  )
774
843
  lines = list(rect.lines)
775
844
  if rect.height == 0:
@@ -783,56 +852,53 @@ class ImageText:
783
852
  f"Text box with size ({box_width}x{lines_height}) at position ({x_pos}, {y_pos}) is out of bounds!"
784
853
  )
785
854
 
786
- # Write lines
855
+ # Write lines. The line that holds the highlighted word is positioned
856
+ # and advanced by its *true* (enlarged) extent via the shared
857
+ # ``_highlighted_line_size`` -- the same numbers ``measure_text_box``
858
+ # reserved -- so an enlarged word can never push the line out of the
859
+ # box (hence out of the frame) regardless of alignment.
787
860
  current_text_height = y_pos
788
861
  word_index_offset = 0 # Track global word index across lines
789
862
  for line in lines:
790
- line_dimensions = self.get_text_dimensions(font_filename, font_size, line)
863
+ line_words = line.split()
864
+ hl_local_index = -1
865
+ if highlight_word_index is not None:
866
+ line_end_word_index = word_index_offset + len(line_words) - 1
867
+ if word_index_offset <= highlight_word_index <= line_end_word_index:
868
+ hl_local_index = highlight_word_index - word_index_offset
791
869
 
792
- # Calculate horizontal position based on alignment
870
+ if hl_local_index >= 0:
871
+ line_w, line_h = self._highlighted_line_size(
872
+ line, font_filename, font_size, hl_local_index, highlight_size_multiplier, highlight_bold_font
873
+ )
874
+ else:
875
+ line_w, line_h = self.get_text_dimensions(font_filename, font_size, line)
876
+
877
+ # Calculate horizontal position based on alignment (true line width)
793
878
  if place == TextAlign.LEFT:
794
879
  x_left = x_pos
795
880
  elif place == TextAlign.RIGHT:
796
- x_left = x_pos + box_width - line_dimensions[0]
881
+ x_left = x_pos + box_width - line_w
797
882
  elif place == TextAlign.CENTER:
798
- x_left = int(x_pos + ((box_width - line_dimensions[0]) / 2))
883
+ x_left = int(x_pos + ((box_width - line_w) / 2))
799
884
  else:
800
885
  valid_places = [e.value for e in TextAlign]
801
886
  raise ValueError(f"Place '{place}' is not supported. Must be one of: {', '.join(valid_places)}")
802
887
 
803
- # Check if highlighting is needed for this line
804
- if highlight_word_index is not None:
805
- line_words = line.split()
806
- line_start_word_index = word_index_offset
807
- line_end_word_index = word_index_offset + len(line_words) - 1
808
-
809
- # Check if the highlighted word is in this line
810
- if line_start_word_index <= highlight_word_index <= line_end_word_index:
811
- self._write_line_with_highlight(
812
- line=line,
813
- font_filename=font_filename,
814
- font_size=font_size,
815
- font_border_size=font_border_size,
816
- text_color=text_color,
817
- highlight_color=highlight_color or (255, 255, 255),
818
- highlight_size_multiplier=highlight_size_multiplier,
819
- highlight_word_local_index=highlight_word_index - line_start_word_index,
820
- highlight_bold_font=highlight_bold_font,
821
- x_left=int(x_left),
822
- y_top=int(current_text_height),
823
- )
824
- else:
825
- # Write normal line without highlighting
826
- self.write_text(
827
- text=line,
828
- font_filename=font_filename,
829
- xy=(x_left, current_text_height),
830
- font_size=font_size,
831
- font_border_size=font_border_size,
832
- color=text_color,
833
- )
834
-
835
- word_index_offset += len(line_words)
888
+ if hl_local_index >= 0:
889
+ self._write_line_with_highlight(
890
+ line=line,
891
+ font_filename=font_filename,
892
+ font_size=font_size,
893
+ font_border_size=font_border_size,
894
+ text_color=text_color,
895
+ highlight_color=highlight_color or (255, 255, 255),
896
+ highlight_size_multiplier=highlight_size_multiplier,
897
+ highlight_word_local_index=hl_local_index,
898
+ highlight_bold_font=highlight_bold_font,
899
+ x_left=int(x_left),
900
+ y_top=int(current_text_height),
901
+ )
836
902
  else:
837
903
  # Write normal line without highlighting
838
904
  self.write_text(
@@ -844,8 +910,9 @@ class ImageText:
844
910
  color=text_color,
845
911
  )
846
912
 
847
- # Increment vertical position for next line
848
- current_text_height += line_dimensions[1]
913
+ word_index_offset += len(line_words)
914
+ # Increment vertical position for next line (true line height)
915
+ current_text_height += line_h
849
916
 
850
917
  # Add background color for the text if specified
851
918
  if background_color is not None:
@@ -921,6 +988,148 @@ class ImageText:
921
988
 
922
989
  return (int(x_pos + box_width), int(current_text_height))
923
990
 
991
+ def _highlight_font(
992
+ self,
993
+ font_filename: str | None,
994
+ font_size: int,
995
+ highlight_size_multiplier: float,
996
+ highlight_bold_font: str | None,
997
+ ) -> tuple[str | None, int, int, int]:
998
+ """Resolve the enlarged-word basics once.
999
+
1000
+ Returns ``(font_file, font_size, baseline_offset, space_width)`` -- the
1001
+ single definition of the highlight constants, shared by the per-word
1002
+ layout (render / exact-size path) and the worst-case extent (measure
1003
+ path) so those paths cannot drift apart on the fundamentals.
1004
+ """
1005
+ hl_font_size = int(font_size * highlight_size_multiplier)
1006
+ hl_font_file = highlight_bold_font if highlight_bold_font is not None else font_filename
1007
+ baseline_offset = self._get_font_baseline_offset(font_filename, font_size, hl_font_file, hl_font_size)
1008
+ space_width = self.get_text_dimensions(font_filename, font_size, " ")[0]
1009
+ return hl_font_file, hl_font_size, baseline_offset, space_width
1010
+
1011
+ def _layout_highlighted_line(
1012
+ self,
1013
+ line: str,
1014
+ font_filename: str | None,
1015
+ font_size: int,
1016
+ highlight_word_local_index: int,
1017
+ highlight_size_multiplier: float,
1018
+ highlight_bold_font: str | None,
1019
+ ) -> list[_WordPlacement]:
1020
+ """Per-word placement for ``line`` with one word enlarged.
1021
+
1022
+ The single source of truth for the highlighted-line advance (enlarged
1023
+ font size, bold-font swap, base-size inter-word space, baseline
1024
+ offset). Both :meth:`_highlighted_line_size` (measuring the line that
1025
+ actually owns the highlight) and :meth:`_write_line_with_highlight`
1026
+ (rendering it) consume this list, so the reserved box and the drawn
1027
+ pixels agree by construction.
1028
+
1029
+ Reached only for the line that owns the highlighted word, so
1030
+ ``highlight_word_local_index`` is in range; degenerate inputs are
1031
+ handled by the callers' own guards.
1032
+ """
1033
+ words = line.split()
1034
+ hl_font_file, hl_font_size, baseline_offset, space_width = self._highlight_font(
1035
+ font_filename, font_size, highlight_size_multiplier, highlight_bold_font
1036
+ )
1037
+ placements: list[_WordPlacement] = []
1038
+ dx = 0
1039
+ for i, word in enumerate(words):
1040
+ is_hl = i == highlight_word_local_index
1041
+ wf = hl_font_file if is_hl else font_filename
1042
+ ws = hl_font_size if is_hl else font_size
1043
+ w, h = self.get_text_dimensions(wf, ws, word)
1044
+ placements.append(
1045
+ _WordPlacement(
1046
+ word=word,
1047
+ font_filename=wf,
1048
+ font_size=ws,
1049
+ width=w,
1050
+ height=h,
1051
+ dx=dx,
1052
+ dy=baseline_offset if is_hl else 0,
1053
+ is_highlighted=is_hl,
1054
+ )
1055
+ )
1056
+ dx += w
1057
+ if i < len(words) - 1:
1058
+ dx += space_width
1059
+ return placements
1060
+
1061
+ def _highlighted_line_size(
1062
+ self,
1063
+ line: str,
1064
+ font_filename: str | None,
1065
+ font_size: int,
1066
+ highlight_word_local_index: int,
1067
+ highlight_size_multiplier: float,
1068
+ highlight_bold_font: str | None,
1069
+ ) -> tuple[int, int]:
1070
+ """Rendered (width, height) of ``line`` with one *specific* word enlarged.
1071
+
1072
+ A reduction of the shared :meth:`_layout_highlighted_line`, so it is
1073
+ exact w.r.t. the renderer by construction. Used to position/advance
1074
+ the line that owns the highlighted word. ``highlight_word_local_index``
1075
+ out of range falls back to the plain line size -- exactly what the
1076
+ renderer's own guard ends up drawing.
1077
+ """
1078
+ words = line.split()
1079
+ if not words:
1080
+ return (0, 0)
1081
+ if not (0 <= highlight_word_local_index < len(words)):
1082
+ return self.get_text_dimensions(font_filename, font_size, line)
1083
+ placements = self._layout_highlighted_line(
1084
+ line, font_filename, font_size, highlight_word_local_index, highlight_size_multiplier, highlight_bold_font
1085
+ )
1086
+ width = max(p.dx + p.width for p in placements)
1087
+ # ``min(0, ...)`` / ``max(0, ...)`` stay defensive for a *shrinking*
1088
+ # highlight (multiplier < 1 -> negative baseline offset, the word
1089
+ # rides above the line). The subtitle measure path clamps the
1090
+ # multiplier to >= 1 so there ``top`` is always 0, but
1091
+ # ``write_text_box`` forwards the raw multiplier, so keep the floor.
1092
+ top = min([0, *(p.dy for p in placements)])
1093
+ bottom = max([0, *(p.dy + p.height for p in placements)])
1094
+ return (width, bottom - top)
1095
+
1096
+ def _highlighted_line_max_extent(
1097
+ self,
1098
+ line: str,
1099
+ font_filename: str | None,
1100
+ font_size: int,
1101
+ highlight_size_multiplier: float,
1102
+ highlight_bold_font: str | None,
1103
+ ) -> tuple[int, int]:
1104
+ """Worst-case (width, height) over *any* word being the enlarged one.
1105
+
1106
+ Equal to ``max`` of :meth:`_highlighted_line_size` across every word
1107
+ position -- the envelope an animated highlight needs -- but in a
1108
+ single O(words) pass instead of O(words^2): only *which* word is
1109
+ enlarged varies, so the base metrics are shared and the extremes are
1110
+ closed-form. Uses the same :meth:`_highlight_font` constants as the
1111
+ layout, so this envelope can never under-reserve what the renderer
1112
+ draws (it over-reserves only in the safe direction).
1113
+ """
1114
+ words = line.split()
1115
+ if not words:
1116
+ return self.get_text_dimensions(font_filename, font_size, line)
1117
+ hl_font_file, hl_font_size, baseline_offset, space_width = self._highlight_font(
1118
+ font_filename, font_size, highlight_size_multiplier, highlight_bold_font
1119
+ )
1120
+ base = [self.get_text_dimensions(font_filename, font_size, w) for w in words]
1121
+ enlarged = [self.get_text_dimensions(hl_font_file, hl_font_size, w) for w in words]
1122
+ # width_k = (sum of base widths + spaces) - base_w[k] + enlarged_w[k];
1123
+ # the worst k just maximizes the (enlarged - base) swap.
1124
+ base_total = sum(w for w, _ in base) + space_width * (len(words) - 1)
1125
+ width = base_total + max(ew - bw for (bw, _), (ew, _) in zip(base, enlarged))
1126
+ # Non-highlighted words sit at dy=0, the enlarged one at
1127
+ # dy=baseline_offset; the worst line is the tallest base word vs. the
1128
+ # tallest enlarged word lifted by the baseline offset.
1129
+ top = min(0, baseline_offset)
1130
+ bottom = max([0, *(h for _, h in base), baseline_offset + max(h for _, h in enlarged)])
1131
+ return (width, bottom - top)
1132
+
924
1133
  def _write_line_with_highlight(
925
1134
  self,
926
1135
  line: str,
@@ -936,7 +1145,11 @@ class ImageText:
936
1145
  y_top: int,
937
1146
  ) -> None:
938
1147
  """
939
- Write a line of text with one word highlighted using word-by-word rendering with baseline alignment.
1148
+ Write a line of text with one word highlighted, word-by-word with baseline alignment.
1149
+
1150
+ Draws the placements from the shared :meth:`_layout_highlighted_line`,
1151
+ so every pixel lands exactly where :meth:`measure_text_box` reserved
1152
+ room for it (measurement and rendering use the same geometry).
940
1153
 
941
1154
  Args:
942
1155
  line: The text line to render
@@ -951,58 +1164,22 @@ class ImageText:
951
1164
  x_left: Left x position for the line
952
1165
  y_top: Top y position for the line
953
1166
  """
954
- # Split line into words
955
1167
  words = line.split()
956
1168
  if highlight_word_local_index >= len(words):
957
- return # Safety check
958
-
959
- # Calculate highlighted font size and determine font files
960
- highlight_font_size = int(font_size * highlight_size_multiplier)
961
- highlight_font_file = highlight_bold_font if highlight_bold_font is not None else font_filename
1169
+ return # Safety check: nothing to draw (matches the measure fallback)
962
1170
 
963
- # Calculate baseline offset for highlighted words (using the appropriate font files)
964
- baseline_offset = self._get_font_baseline_offset(
965
- font_filename, font_size, highlight_font_file, highlight_font_size
966
- )
967
-
968
- # Render words one by one with proper spacing
969
- current_x = x_left
970
-
971
- for i, word in enumerate(words):
972
- # Determine if this is the highlighted word
973
- is_highlighted = i == highlight_word_local_index
974
-
975
- # Choose font file, size, and color based on highlighting
976
- word_font_file = highlight_font_file if is_highlighted else font_filename
977
- word_font_size = highlight_font_size if is_highlighted else font_size
978
- word_color = highlight_color if is_highlighted else text_color
979
-
980
- # Calculate y position with baseline alignment
981
- word_y = y_top
982
- if is_highlighted:
983
- word_y += baseline_offset
984
-
985
- # Render the word
1171
+ for p in self._layout_highlighted_line(
1172
+ line, font_filename, font_size, highlight_word_local_index, highlight_size_multiplier, highlight_bold_font
1173
+ ):
986
1174
  self.write_text(
987
- text=word,
988
- font_filename=word_font_file,
989
- xy=(current_x, word_y),
990
- font_size=word_font_size,
1175
+ text=p.word,
1176
+ font_filename=p.font_filename,
1177
+ xy=(x_left + p.dx, y_top + p.dy),
1178
+ font_size=p.font_size,
991
1179
  font_border_size=font_border_size,
992
- color=word_color,
1180
+ color=highlight_color if p.is_highlighted else text_color,
993
1181
  )
994
1182
 
995
- # Calculate the width of this word for spacing
996
- word_width = self.get_text_dimensions(word_font_file, word_font_size, word)[0]
997
-
998
- # Update current_x for next word (add word width plus space)
999
- current_x += word_width
1000
-
1001
- # Add space between words (except after the last word)
1002
- if i < len(words) - 1:
1003
- space_width = self.get_text_dimensions(font_filename, font_size, " ")[0]
1004
- current_x += space_width
1005
-
1006
1183
  def _find_smallest_bounding_rect(self, mask: np.ndarray) -> tuple[int, int, int, int]:
1007
1184
  """
1008
1185
  Find the smallest bounding rectangle containing non-zero values in the mask.
@@ -320,6 +320,11 @@ class TranscriptionOverlay(Effect):
320
320
  the fit search and the renderer, so they never diverge. Margin math
321
321
  comes from ``ImageText.available_region`` (one source of truth with
322
322
  ``measure_text_box``).
323
+
324
+ The highlight multiplier is threaded in so the measurement is
325
+ worst-case for the animated word enlargement: a cue that fits at base
326
+ size but overflows once a word is highlighted is rejected here (and
327
+ auto-shrunk by ``_resolve_layout``) instead of crashing mid-render.
323
328
  """
324
329
  rect = img_text.measure_text_box(
325
330
  text=text,
@@ -329,13 +334,19 @@ class TranscriptionOverlay(Effect):
329
334
  font_size=font_px,
330
335
  anchor=cfg.anchor,
331
336
  margin=cfg.margin,
337
+ highlight_size_multiplier=cfg.style.highlight_size_multiplier,
338
+ highlight_bold_font=self.highlight_bold_font,
332
339
  )
333
340
  if rect.height == 0:
334
341
  return None
335
342
  box_w = int(rect.width)
336
343
  box_h = rect.height
337
344
  left, top, avail_w, avail_h = img_text.available_region(cfg.margin)
338
- fits = box_w <= avail_w and box_h <= avail_h
345
+ # The box must fit the drawable area, AND the worst-case rendered line
346
+ # (incl. the enlarged highlighted word, or an unbreakable long word)
347
+ # must fit the box -- else the centered line spills off-frame at draw
348
+ # time. Failing this shrinks the font in ``_resolve_layout``.
349
+ fits = box_w <= avail_w and box_h <= avail_h and rect.content_width <= box_w
339
350
  x = min(max(int(round(rect.x)), left), left + avail_w - box_w)
340
351
  y = min(max(int(round(rect.y)), top), top + avail_h - box_h)
341
352
  return _CueBox(x=x, y=y, box_w=box_w, height=box_h, fits=fits)
File without changes
File without changes
File without changes