"""TikTok-style static text overlay for video frames (2026 edition). Four styles based on the current TikTok landscape: viral_hook — Bebas Neue, ALL CAPS, white on opaque black rounded rectangle aesthetic_label — Courier Prime, wide letter-spacing, cream on full-width label-tape strip modern_minimal — Lora Bold serif, white, transparent background tiktok — Inter Bold, white with black outline, no background (native TikTok caption look) PIL is used ONCE at construction to pre-render the caption into a BGRA numpy array. Per-frame work is pure numpy (alpha blend) — ~0.3 ms/frame, no PIL in the hot path. Vertical placement follows the TikTok 2026 "Action Zone" (40%–55% from top): top → text block starts at 40% (upper action zone) bottom → text block ends at 55% (lower action zone, above the 60% danger zone) """ from __future__ import annotations import re import sys from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np try: from PIL import Image, ImageDraw, ImageFont except ImportError as exc: raise ImportError( "Pillow is required for caption rendering. Install with: uv add pillow" ) from exc # Unicode ranges that require an emoji font _EMOJI_RE = re.compile( "[" "\U0001F300-\U0001FAFF" # emoticons, symbols, pictographs, flags, supplemental "\u2600-\u27BF" # misc symbols, dingbats "\uFE00-\uFE0F" # variation selectors (emoji vs text presentation) "\u200D" # ZWJ (zero-width joiner — part of compound emoji) "]+", flags=re.UNICODE, ) # Apple Color Emoji (macOS) is a bitmap-only font at these fixed ppem values. # Requesting any other size raises "invalid pixel size" from FreeType. _APPLE_EMOJI_BITMAP_SIZES: tuple[int, ...] = (20, 32, 40, 48, 64, 96, 160) _APPLE_EMOJI_PATH = Path("/System/Library/Fonts/Apple Color Emoji.ttc") # Bundled Noto Color Emoji (CBDT format) — authoritative for Docker/Linux production. # This file has exactly ONE bitmap strike at 109 ppem. We load it at that size and # LANCZOS-downscale to font_size in the pre-render step (zero per-frame cost). # File: assets/fonts/NotoColorEmoji.ttf (committed to the repo) _NOTO_EMOJI_NATIVE_SIZE = 109 # the only CBDT strike in our bundled file # System Noto Color Emoji paths (bonus: apt-installed version may have multiple strikes # and can be loaded at the exact font_size without scaling) _NOTO_COLOR_EMOJI_SYSTEM_PATHS: list[Path] = [ Path("/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf"), Path("/usr/share/fonts/noto-emoji/NotoColorEmoji.ttf"), Path("/usr/share/fonts/google-noto-color-emoji-fonts/NotoColorEmoji.ttf"), ] def _split_emoji_runs(text: str) -> list[tuple[str, bool]]: """Split *text* into (segment, is_emoji) pairs for mixed-font rendering.""" result: list[tuple[str, bool]] = [] last = 0 for m in _EMOJI_RE.finditer(text): if m.start() > last: result.append((text[last : m.start()], False)) result.append((m.group(), True)) last = m.end() if last < len(text): result.append((text[last:], False)) return result or [(text, False)] _FONTS_DIR = Path(__file__).parent / "assets" / "fonts" _NOTO_COLOR_EMOJI_PATH = _FONTS_DIR / "NotoColorEmoji.ttf" # bundled CBDT, 109 ppem _NOTO_EMOJI_PATH = _FONTS_DIR / "NotoEmoji-Regular.ttf" # bundled outline fallback _FONT_PATHS: dict[str, Path] = { "viral_hook": _FONTS_DIR / "BebasNeue-Regular.ttf", "aesthetic_label": _FONTS_DIR / "CourierPrime-Regular.ttf", "modern_minimal": _FONTS_DIR / "Lora-VF.ttf", "tiktok": _FONTS_DIR / "Inter-VF.ttf", } # Weight axis value for variable fonts (None = static font, no axis needed) _FONT_WEIGHTS: dict[str, int | None] = { "viral_hook": None, # Bebas Neue is a static font "aesthetic_label": None, # Courier Prime is a static font "modern_minimal": 700, # Lora variable — Bold weight "tiktok": 700, # Inter variable — Bold weight } # Font size as a fraction of frame height _FONT_SIZE_DIVISOR: dict[str, int] = { "viral_hook": 18, "aesthetic_label": 26, "modern_minimal": 22, "tiktok": 32, # frame_height / 32 matches native TikTok caption proportions } # Letter-spacing multiplier for aesthetic_label (1.0 = normal, >1.0 = wider) _LETTER_SPACING: float = 1.35 # Horizontal safe zone — keep text within this fraction of frame width _SAFE_WIDTH_RATIO: float = 0.88 @dataclass(frozen=True) class CaptionConfig: """Immutable config passed to CaptionRenderer.""" text: str style: str # "viral_hook" | "aesthetic_label" | "modern_minimal" | "tiktok" position: str # "top" | "bottom" | "center" frame_width: int frame_height: int class CaptionRenderer: """Pre-renders a styled text overlay and composites it onto BGR frames. Construction (once per clip): - Loads TTF font via Pillow - Wraps text to fit safe zone - Renders styled caption to a transparent BGRA numpy array Per-frame (hot path): - Pure numpy alpha blend: ~0.3 ms at 720×1280 """ def __init__(self, config: CaptionConfig) -> None: self.config = config font_path = _FONT_PATHS[config.style] if not font_path.exists(): raise FileNotFoundError( f"Font file not found: {font_path}\n" f"Expected fonts in: {_FONTS_DIR}" ) font_size = config.frame_height // _FONT_SIZE_DIVISOR[config.style] font = ImageFont.truetype(str(font_path), size=font_size) weight = _FONT_WEIGHTS[config.style] if weight is not None: try: font.set_variation_by_axes([weight]) except (OSError, AttributeError): pass # Emoji font for tiktok style. # _emoji_scale: if < 1.0 the font was loaded at a larger native size and # rendered emoji must be LANCZOS-downscaled to match text size (pre-render only). self._emoji_font: ImageFont.FreeTypeFont | None = None self._emoji_is_color: bool = False self._emoji_scale: float = 1.0 if config.style == "tiktok": # 1. Apple Color Emoji (macOS dev): bitmap-only at fixed sizes — snap to nearest. if _APPLE_EMOJI_PATH.exists(): snapped = min(_APPLE_EMOJI_BITMAP_SIZES, key=lambda s: abs(s - font_size)) try: f = ImageFont.truetype(str(_APPLE_EMOJI_PATH), size=snapped) if f.getbbox("\U0001F600")[3] > 0: # verify the font renders self._emoji_font = f self._emoji_is_color = True self._emoji_scale = font_size / snapped except (OSError, IOError): pass # 2. Noto Color Emoji — bundled file first (always available in Docker), # then system paths (apt-installed version, may support direct font_size). if self._emoji_font is None: noto_candidates = [_NOTO_COLOR_EMOJI_PATH] + _NOTO_COLOR_EMOJI_SYSTEM_PATHS for noto_path in noto_candidates: if not noto_path.exists(): continue # Try at native strike (109 ppem); system versions may also work # at other sizes but 109 is the safe common denominator. try: f = ImageFont.truetype(str(noto_path), size=_NOTO_EMOJI_NATIVE_SIZE) if f.getbbox("\U0001F600")[3] > 0: self._emoji_font = f self._emoji_is_color = True self._emoji_scale = font_size / _NOTO_EMOJI_NATIVE_SIZE break except (OSError, IOError): pass # 3. Bundled outline fallback (B&W, but better than □ boxes) if self._emoji_font is None and _NOTO_EMOJI_PATH.exists(): try: self._emoji_font = ImageFont.truetype(str(_NOTO_EMOJI_PATH), size=font_size) except (OSError, IOError): pass max_text_width = int(config.frame_width * _SAFE_WIDTH_RATIO) # aesthetic_label uses wide letter spacing — reduce wrap budget accordingly wrap_width = ( int(max_text_width / _LETTER_SPACING) if config.style == "aesthetic_label" else max_text_width ) text = self._apply_case(config.text, config.style) lines = self._wrap_text( text, font, wrap_width, emoji_font=self._emoji_font if config.style == "tiktok" else None, emoji_scale=self._emoji_scale, ) # v_pad must be computed here (same formula as in the renderers) so that # boxed styles can use the actual box row height as the y-advance. v_pad = max(6, font_size // 8) if config.style in ("viral_hook", "aesthetic_label"): # Use the real rendered glyph pixel height instead of font metrics # (ascent+descent inflates the advance for ALL-CAPS / no-descender text). # Row advance = box height = glyph_h + 2*v_pad, with zero gap between rows # so boxes stack flush — matching the TikTok look. sample = "A" if config.style == "viral_hook" else "Ag" bb_s = font.getbbox(sample, anchor="mt") glyph_h = bb_s[3] - bb_s[1] line_height = glyph_h + 2 * v_pad line_gap = 0 else: line_height = self._line_height(font, font_size) line_gap = int(font_size * 0.12) total_h = len(lines) * line_height + max(0, len(lines) - 1) * line_gap anchor_y = self._compute_anchor_y(total_h) # Pre-render caption to RGBA PIL canvas canvas = Image.new("RGBA", (config.frame_width, config.frame_height), (0, 0, 0, 0)) dispatch = { "viral_hook": self._render_viral_hook, "aesthetic_label": self._render_aesthetic_label, "modern_minimal": self._render_modern_minimal, "tiktok": self._render_tiktok, } dispatch[config.style](canvas, lines, font, font_size, line_height, line_gap, anchor_y) # RGBA PIL → BGRA numpy → split alpha and BGR layers bgra = cv2.cvtColor(np.array(canvas), cv2.COLOR_RGBA2BGRA) self._alpha = bgra[:, :, 3:4].astype(np.float32) / 255.0 # (H, W, 1) self._caption_bgr = bgra[:, :, :3].astype(np.float32) # (H, W, 3) print( f"Caption: '{config.text[:40]}{'…' if len(config.text) > 40 else ''}' " f"| style={config.style} | position={config.position} " f"| {len(lines)} line(s) | font_size={font_size}px", file=sys.stderr, ) # ------------------------------------------------------------------ # Hot path — called for every frame # ------------------------------------------------------------------ def render(self, frame_bgr: np.ndarray) -> np.ndarray: """Alpha-composite the pre-rendered caption onto a BGR frame. Args: frame_bgr: OpenCV BGR uint8 frame, shape (H, W, 3) Returns: BGR uint8 frame with caption composited """ f = frame_bgr.astype(np.float32) return (f * (1.0 - self._alpha) + self._caption_bgr * self._alpha).astype(np.uint8) # ------------------------------------------------------------------ # Style renderers (called once in __init__) # ------------------------------------------------------------------ def _render_viral_hook( self, canvas: Image.Image, lines: list[str], font: ImageFont.FreeTypeFont, font_size: int, line_height: int, line_gap: int, anchor_y: int, ) -> None: """ALL CAPS white text on opaque rounded-rect black background + drop shadow.""" draw = ImageDraw.Draw(canvas) cx = self.config.frame_width // 2 # Padding relative to font size so it scales with text h_pad = max(16, font_size // 3) # ~24px at 71px — doubled sides v_pad = max(6, font_size // 8) # ~9px at 71px — doubled top/bottom radius = max(4, font_size // 12) shadow_offset = max(2, font_size // 32) for i, line in enumerate(lines): y = anchor_y + i * (line_height + line_gap) # getbbox with anchor="mt" returns pixel offsets relative to (cx, y), # matching exactly what draw.text(..., anchor="mt") will render. # This avoids the font's descent metric inflating the bottom of the box # for ALL CAPS text that has no descenders. bb = font.getbbox(line, anchor="mt") # bb = (left, top, right, bottom) relative to draw origin (cx, y) px_left = cx + bb[0] px_top = y + bb[1] px_right = cx + bb[2] px_bottom = y + bb[3] box = [px_left - h_pad, px_top - v_pad, px_right + h_pad, px_bottom + v_pad] draw.rounded_rectangle(box, radius=radius, fill=(0, 0, 0, 217)) # Drop shadow draw.text( (cx + shadow_offset, y + shadow_offset), line, font=font, fill=(0, 0, 0, 255), anchor="mt", ) # White text draw.text((cx, y), line, font=font, fill=(255, 255, 255, 255), anchor="mt") def _render_aesthetic_label( self, canvas: Image.Image, lines: list[str], font: ImageFont.FreeTypeFont, font_size: int, line_height: int, line_gap: int, anchor_y: int, ) -> None: """Cream text with wide letter-spacing on a label-tape strip sized to the text.""" draw = ImageDraw.Draw(canvas) cx = self.config.frame_width // 2 v_pad = max(6, font_size // 8) # same rhythm as viral_hook h_pad = max(8, font_size // 6) # same as viral_hook — tape hugs the text tape_color = (18, 18, 18, 230) # near-black, slightly transparent text_color = (245, 245, 245, 255) # #F5F5F5 soft cream for i, line in enumerate(lines): y = anchor_y + i * (line_height + line_gap) # Measure actual spaced-text width for this line so the tape fits it chars = list(line) char_widths = [font.getbbox(c)[2] - font.getbbox(c)[0] for c in chars] spaced_w = sum(int(w * _LETTER_SPACING) for w in char_widths) if char_widths: spaced_w -= int(char_widths[-1] * (_LETTER_SPACING - 1.0)) # Flat tape strip (no rounded corners) — width follows the text. # line_height = glyph_h + 2*v_pad for aesthetic_label, so strip bottom # is y + line_height - v_pad (= y + glyph_h + v_pad). strip = [ cx - spaced_w // 2 - h_pad, y - v_pad, cx + spaced_w // 2 + h_pad, y + line_height - v_pad, ] draw.rectangle(strip, fill=tape_color) # Text with wide letter-spacing, drawn character-by-character self._draw_spaced_text(draw, line, font, cx, y, text_color) def _render_modern_minimal( self, canvas: Image.Image, lines: list[str], font: ImageFont.FreeTypeFont, font_size: int, line_height: int, line_gap: int, anchor_y: int, ) -> None: """Bold serif, white, no background, no shadow — text on transparent footage.""" draw = ImageDraw.Draw(canvas) cx = self.config.frame_width // 2 for i, line in enumerate(lines): y = anchor_y + i * (line_height + line_gap) draw.text((cx, y), line, font=font, fill=(255, 255, 255, 255), anchor="mt") def _render_tiktok( self, canvas: Image.Image, lines: list[str], font: ImageFont.FreeTypeFont, font_size: int, line_height: int, line_gap: int, anchor_y: int, ) -> None: """Inter Bold, white with black outline, no background — native TikTok caption look. Emoji are rendered with bundled NotoEmoji-Regular (outline font) using the same white-fill + black-stroke as regular text, so they look cohesive. When the font is unavailable the whole line falls back to a single draw.text call (emoji show as □ boxes rather than crashing). """ draw = ImageDraw.Draw(canvas) cx = self.config.frame_width // 2 outline = max(2, font_size // 14) emoji_font = self._emoji_font emoji_is_color = self._emoji_is_color emoji_scale = self._emoji_scale for i, line in enumerate(lines): y = anchor_y + i * (line_height + line_gap) runs = _split_emoji_runs(line) has_emoji = emoji_font is not None and any(is_e for _, is_e in runs) if not has_emoji: # Fast path: no emoji (or emoji font unavailable) — single draw call draw.text( (cx, y), line, font=font, fill=(255, 255, 255, 255), stroke_width=outline, stroke_fill=(0, 0, 0, 255), anchor="mt", ) continue # Mixed path: measure total width (accounting for emoji scale) then render # each span left-to-right. total_w = sum( round((emoji_font if is_e else font).getlength(seg) * (emoji_scale if is_e else 1.0)) for seg, is_e in runs if seg ) x = cx - total_w // 2 for segment, is_emoji in runs: if not segment: continue if is_emoji and emoji_font is not None: if emoji_is_color: x += self._draw_color_emoji(canvas, segment, x, y, font.getmetrics()[0]) else: # Outline fallback: match text stroke style draw.text((x, y), segment, font=emoji_font, fill=(255, 255, 255, 255), stroke_width=outline, stroke_fill=(0, 0, 0, 255), anchor="lt") x += round(emoji_font.getlength(segment)) else: draw.text( (x, y), segment, font=font, fill=(255, 255, 255, 255), stroke_width=outline, stroke_fill=(0, 0, 0, 255), anchor="lt", ) x += round(font.getlength(segment)) def _draw_color_emoji( self, canvas: Image.Image, segment: str, x: int, y: int, text_ascent: int = 0, ) -> int: """Render a color emoji run onto *canvas* at (x, y). Returns pixel advance. *text_ascent* is the ascent of the surrounding text font (from getmetrics()). When non-zero the emoji is shifted vertically so its center aligns with the text cap-height center — matching how text editors baseline-align inline emoji. When *_emoji_scale* is close to 1.0 (Apple Color Emoji snapped to the right bitmap size) the emoji is drawn directly. Otherwise (CBDT at native 109 ppem) it is rendered to a temporary canvas and LANCZOS-downscaled to match the text size — this happens only in the pre-render step, not per frame. """ ef = self._emoji_font scale = self._emoji_scale if abs(scale - 1.0) < 0.02: # Direct path: font is already at (approximately) the right size bb_e = ef.getbbox(segment, anchor="lt") emoji_h = (bb_e[3] - bb_e[1]) if bb_e else 0 y_offset = (text_ascent - emoji_h) // 2 if text_ascent > 0 and emoji_h > 0 else 0 ImageDraw.Draw(canvas).text((x, y + y_offset), segment, font=ef, embedded_color=True, anchor="lt") return round(ef.getlength(segment)) # Scaled path: render at native size, then downscale with LANCZOS bb = ef.getbbox(segment) # e.g. (0, 0, 136, 128) for CBDT at 109px if bb[2] <= bb[0] or bb[3] <= bb[1]: return round(ef.getlength(segment) * scale) temp = Image.new("RGBA", (bb[2] + 1, bb[3] + 1), (0, 0, 0, 0)) ImageDraw.Draw(temp).text((-bb[0], -bb[1]), segment, font=ef, embedded_color=True) sw = max(1, round(temp.width * scale)) sh = max(1, round(temp.height * scale)) scaled = temp.resize((sw, sh), Image.LANCZOS) # Center emoji on text cap-height (same as inline emoji in text editors) y_offset = (text_ascent - sh) // 2 if text_ascent > 0 else 0 dx = x + round(bb[0] * scale) dy = y + round(bb[1] * scale) + y_offset # Clip region to canvas bounds before compositing px, py = max(0, dx), max(0, dy) if px < canvas.width and py < canvas.height: sx, sy = px - dx, py - dy sw_clip = min(sw - sx, canvas.width - px) sh_clip = min(sh - sy, canvas.height - py) if sw_clip > 0 and sh_clip > 0: canvas.alpha_composite( scaled.crop((sx, sy, sx + sw_clip, sy + sh_clip)), dest=(px, py), ) return round(ef.getlength(segment) * scale) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @staticmethod def _draw_spaced_text( draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont, center_x: int, top_y: int, fill: tuple[int, int, int, int], spacing: float = _LETTER_SPACING, ) -> None: """Render text character-by-character with wide letter spacing, centered.""" chars = list(text) widths = [font.getbbox(c)[2] - font.getbbox(c)[0] for c in chars] # Total rendered width including spacing total_w = sum(int(w * spacing) for w in widths) if widths: # Last char doesn't get trailing spacing total_w -= int(widths[-1] * (spacing - 1.0)) x = center_x - total_w // 2 for char, w in zip(chars, widths): draw.text((x, top_y), char, font=font, fill=fill, anchor="lt") x += int(w * spacing) @staticmethod def _apply_case(text: str, style: str) -> str: if style == "viral_hook": return text.upper() if style == "aesthetic_label": return text.lower() if style == "tiktok": return text # preserve verbatim — TikTok captions are as-typed # modern_minimal: sentence case return text.capitalize() @staticmethod def _line_height(font: ImageFont.FreeTypeFont, font_size: int) -> int: try: ascent, descent = font.getmetrics() return ascent + descent except AttributeError: return int(font_size * 1.2) @staticmethod def _wrap_text( text: str, font: ImageFont.FreeTypeFont, max_width: int, emoji_font: ImageFont.FreeTypeFont | None = None, emoji_scale: float = 1.0, ) -> list[str]: """Word-wrap text so no line exceeds max_width pixels. When *emoji_font* is provided, emoji characters are measured with it so that their advance width (scaled by *emoji_scale*) is accounted for during wrapping. """ def measure(t: str) -> int: if emoji_font is None: bb = font.getbbox(t) return bb[2] - bb[0] return sum( round((emoji_font if is_e else font).getlength(seg) * (emoji_scale if is_e else 1.0)) for seg, is_e in _split_emoji_runs(t) if seg ) words = text.split() lines: list[str] = [] current: list[str] = [] for word in words: candidate = " ".join(current + [word]) if measure(candidate) > max_width and current: lines.append(" ".join(current)) current = [word] else: current.append(word) if current: lines.append(" ".join(current)) return lines or [text] def _compute_anchor_y(self, total_text_height: int) -> int: """Y-coordinate of the top of the text block. Positions: top → block starts at 40% of frame height (upper TikTok action zone) bottom → block ends at 55% of frame height (lower TikTok action zone) center → block is vertically centered in the frame """ if self.config.position == "bottom": return int(self.config.frame_height * 0.55) - total_text_height if self.config.position == "center": return (self.config.frame_height - total_text_height) // 2 # top return int(self.config.frame_height * 0.40)