All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m39s
- Add gradient functions: linear, radial, and multi-stop color maps - Add RGBA strip rotation with bilinear interpolation - Add shadow compositing with optional Gaussian blur - Add combined place_text_strip_fx_jax pipeline (gradient + rotation + shadow) - Add 7 new S-expression bindings for all FX primitives - Extract shared _composite_strip_onto_frame helper - Fix rotation precision: snap trig values near 0/±1 to exact values, use pixel-center convention (dim-1)/2, and parity-matched output buffers - All 99 tests pass with zero pixel differences Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1643 lines
59 KiB
Python
1643 lines
59 KiB
Python
"""
|
|
JAX Typography Primitives
|
|
|
|
Two approaches for text rendering, both compile to JAX/GPU:
|
|
|
|
## 1. TextStrip - Pixel-perfect static text
|
|
Pre-render entire strings at compile time using PIL.
|
|
Perfect sub-pixel anti-aliasing, exact match with PIL.
|
|
Use for: static titles, labels, any text without per-character effects.
|
|
|
|
S-expression:
|
|
(let ((strip (render-text-strip "Hello World" 48)))
|
|
(place-text-strip frame strip x y :color white))
|
|
|
|
## 2. Glyph-by-glyph - Dynamic text effects
|
|
Individual glyph placement for wave, arc, audio-reactive effects.
|
|
Each character can have independent position, color, opacity.
|
|
Note: slight anti-aliasing differences vs PIL due to integer positioning.
|
|
|
|
S-expression:
|
|
; Wave text - y oscillates with character index
|
|
(let ((glyphs (text-glyphs "Wavy" 48)))
|
|
(first
|
|
(fold glyphs (list frame 0)
|
|
(lambda (acc g)
|
|
(let ((frm (first acc))
|
|
(cursor (second acc))
|
|
(i (length acc))) ; approximate index
|
|
(list
|
|
(place-glyph frm (glyph-image g)
|
|
(+ x cursor)
|
|
(+ y (* amplitude (sin (* i frequency))))
|
|
(glyph-bearing-x g) (glyph-bearing-y g)
|
|
white 1.0)
|
|
(+ cursor (glyph-advance g))))))))
|
|
|
|
; Audio-reactive spacing
|
|
(let ((glyphs (text-glyphs "Bass" 48))
|
|
(bass (audio-band 0 200)))
|
|
(first
|
|
(fold glyphs (list frame 0)
|
|
(lambda (acc g)
|
|
(let ((frm (first acc))
|
|
(cursor (second acc)))
|
|
(list
|
|
(place-glyph frm (glyph-image g)
|
|
(+ x cursor) y
|
|
(glyph-bearing-x g) (glyph-bearing-y g)
|
|
white 1.0)
|
|
(+ cursor (glyph-advance g) (* bass 20))))))))
|
|
|
|
Kerning support:
|
|
; With kerning adjustment
|
|
(+ cursor (glyph-advance g) (glyph-kerning g next-g font-size))
|
|
"""
|
|
|
|
import math
|
|
import numpy as np
|
|
import jax
|
|
import jax.numpy as jnp
|
|
from jax import lax
|
|
from typing import Tuple, Dict, Any, List, Optional
|
|
from dataclasses import dataclass
|
|
|
|
|
|
# =============================================================================
|
|
# Glyph Data (computed at compile time)
|
|
# =============================================================================
|
|
|
|
@dataclass
|
|
class GlyphData:
|
|
"""Glyph data computed at compile time.
|
|
|
|
Attributes:
|
|
char: The character
|
|
image: RGBA image as numpy array (H, W, 4) - converted to JAX at runtime
|
|
advance: Horizontal advance (distance to next glyph origin)
|
|
bearing_x: Left side bearing (x offset from origin to first pixel)
|
|
bearing_y: Top bearing (y offset from baseline to top of glyph)
|
|
width: Image width
|
|
height: Image height
|
|
"""
|
|
char: str
|
|
image: np.ndarray # (H, W, 4) RGBA uint8
|
|
advance: float
|
|
bearing_x: float
|
|
bearing_y: float
|
|
width: int
|
|
height: int
|
|
|
|
|
|
# Font cache: (font_name, font_size) -> {char: GlyphData}
|
|
_GLYPH_CACHE: Dict[Tuple, Dict[str, GlyphData]] = {}
|
|
|
|
# Font metrics cache: (font_name, font_size) -> (ascent, descent)
|
|
_METRICS_CACHE: Dict[Tuple, Tuple[float, float]] = {}
|
|
|
|
# Kerning cache: (font_name, font_size) -> {(char1, char2): adjustment}
|
|
# Kerning adjustment is added to advance: new_advance = advance + kerning
|
|
# Typically negative (characters move closer together)
|
|
_KERNING_CACHE: Dict[Tuple, Dict[Tuple[str, str], float]] = {}
|
|
|
|
|
|
def _load_font(font_name: str = None, font_size: int = 32):
|
|
"""Load a font. Called at compile time."""
|
|
from PIL import ImageFont
|
|
|
|
candidates = [
|
|
font_name,
|
|
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
|
|
'/usr/share/fonts/truetype/freefont/FreeSans.ttf',
|
|
]
|
|
|
|
for path in candidates:
|
|
if path is None:
|
|
continue
|
|
try:
|
|
return ImageFont.truetype(path, font_size)
|
|
except (IOError, OSError):
|
|
continue
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _get_glyph_cache(font_name: str = None, font_size: int = 32) -> Dict[str, GlyphData]:
|
|
"""Get or create glyph cache for a font. Called at compile time."""
|
|
cache_key = (font_name, font_size)
|
|
|
|
if cache_key in _GLYPH_CACHE:
|
|
return _GLYPH_CACHE[cache_key]
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
font = _load_font(font_name, font_size)
|
|
ascent, descent = font.getmetrics()
|
|
_METRICS_CACHE[cache_key] = (ascent, descent)
|
|
|
|
glyphs = {}
|
|
charset = ''.join(chr(i) for i in range(32, 127))
|
|
|
|
temp_img = Image.new('RGBA', (200, 200), (0, 0, 0, 0))
|
|
temp_draw = ImageDraw.Draw(temp_img)
|
|
|
|
for char in charset:
|
|
# Get metrics
|
|
bbox = temp_draw.textbbox((0, 0), char, font=font)
|
|
advance = font.getlength(char)
|
|
|
|
x_min, y_min, x_max, y_max = bbox
|
|
|
|
# Create glyph image with padding
|
|
padding = 2
|
|
img_w = max(int(x_max - x_min) + padding * 2, 1)
|
|
img_h = max(int(y_max - y_min) + padding * 2, 1)
|
|
|
|
glyph_img = Image.new('RGBA', (img_w, img_h), (0, 0, 0, 0))
|
|
glyph_draw = ImageDraw.Draw(glyph_img)
|
|
|
|
# Draw at position accounting for bbox offset
|
|
draw_x = padding - x_min
|
|
draw_y = padding - y_min
|
|
glyph_draw.text((draw_x, draw_y), char, fill=(255, 255, 255, 255), font=font)
|
|
|
|
glyphs[char] = GlyphData(
|
|
char=char,
|
|
image=np.array(glyph_img, dtype=np.uint8),
|
|
advance=float(advance),
|
|
bearing_x=float(x_min),
|
|
bearing_y=float(-y_min), # Distance from baseline to top
|
|
width=img_w,
|
|
height=img_h,
|
|
)
|
|
|
|
_GLYPH_CACHE[cache_key] = glyphs
|
|
return glyphs
|
|
|
|
|
|
def _get_kerning_cache(font_name: str = None, font_size: int = 32) -> Dict[Tuple[str, str], float]:
|
|
"""Get or create kerning cache for a font. Called at compile time.
|
|
|
|
Kerning is computed as:
|
|
kerning(a, b) = getlength(a + b) - getlength(a) - getlength(b)
|
|
|
|
This gives the adjustment needed when placing 'b' after 'a'.
|
|
Typically negative (characters move closer together).
|
|
"""
|
|
cache_key = (font_name, font_size)
|
|
|
|
if cache_key in _KERNING_CACHE:
|
|
return _KERNING_CACHE[cache_key]
|
|
|
|
font = _load_font(font_name, font_size)
|
|
kerning = {}
|
|
|
|
# Compute kerning for all printable ASCII pairs
|
|
charset = ''.join(chr(i) for i in range(32, 127))
|
|
|
|
# Pre-compute individual character lengths
|
|
char_lengths = {c: font.getlength(c) for c in charset}
|
|
|
|
# Compute kerning for each pair
|
|
for c1 in charset:
|
|
for c2 in charset:
|
|
pair_length = font.getlength(c1 + c2)
|
|
individual_sum = char_lengths[c1] + char_lengths[c2]
|
|
kern = pair_length - individual_sum
|
|
|
|
# Only store non-zero kerning to save memory
|
|
if abs(kern) > 0.01:
|
|
kerning[(c1, c2)] = kern
|
|
|
|
_KERNING_CACHE[cache_key] = kerning
|
|
return kerning
|
|
|
|
|
|
def get_kerning(char1: str, char2: str, font_name: str = None, font_size: int = 32) -> float:
|
|
"""Get kerning adjustment between two characters. Compile-time.
|
|
|
|
Returns the adjustment to add to char1's advance when char2 follows.
|
|
Typically negative (characters move closer).
|
|
|
|
Usage in S-expression:
|
|
(+ (glyph-advance g1) (kerning g1 g2))
|
|
"""
|
|
kerning_cache = _get_kerning_cache(font_name, font_size)
|
|
return kerning_cache.get((char1, char2), 0.0)
|
|
|
|
|
|
@dataclass
|
|
class TextStrip:
|
|
"""Pre-rendered text strip with proper sub-pixel anti-aliasing.
|
|
|
|
Rendered at compile time using PIL for exact matching.
|
|
At runtime, just composite onto frame at integer positions.
|
|
|
|
Attributes:
|
|
text: The original text
|
|
image: RGBA image as numpy array (H, W, 4)
|
|
width: Strip width
|
|
height: Strip height
|
|
baseline_y: Y position of baseline within the strip
|
|
bearing_x: Left side bearing of first character
|
|
anchor_x: X offset for anchor point (0 for left, width/2 for center, width for right)
|
|
anchor_y: Y offset for anchor point (depends on anchor type)
|
|
stroke_width: Stroke width used when rendering
|
|
"""
|
|
text: str
|
|
image: np.ndarray
|
|
width: int
|
|
height: int
|
|
baseline_y: int
|
|
bearing_x: float
|
|
anchor_x: float = 0.0
|
|
anchor_y: float = 0.0
|
|
stroke_width: int = 0
|
|
|
|
|
|
# Text strip cache: cache_key -> TextStrip
|
|
_TEXT_STRIP_CACHE: Dict[Tuple, TextStrip] = {}
|
|
|
|
|
|
def render_text_strip(
|
|
text: str,
|
|
font_name: str = None,
|
|
font_size: int = 32,
|
|
stroke_width: int = 0,
|
|
stroke_fill: tuple = None,
|
|
anchor: str = "la", # left-ascender (PIL default is "la")
|
|
multiline: bool = False,
|
|
line_spacing: int = 4,
|
|
align: str = "left",
|
|
) -> TextStrip:
|
|
"""Render text to a strip at compile time. Perfect sub-pixel anti-aliasing.
|
|
|
|
Args:
|
|
text: Text to render
|
|
font_name: Path to font file (None for default)
|
|
font_size: Font size in pixels
|
|
stroke_width: Outline width in pixels (0 for no outline)
|
|
stroke_fill: Outline color as (R,G,B) or (R,G,B,A), default black
|
|
anchor: PIL anchor code - first char: h=left, m=middle, r=right
|
|
second char: a=ascender, t=top, m=middle, s=baseline, d=descender
|
|
multiline: If True, handle newlines in text
|
|
line_spacing: Extra pixels between lines (for multiline)
|
|
align: 'left', 'center', 'right' (for multiline)
|
|
|
|
Returns:
|
|
TextStrip with pre-rendered text
|
|
"""
|
|
# Build cache key from all parameters
|
|
cache_key = (text, font_name, font_size, stroke_width, stroke_fill, anchor, multiline, line_spacing, align)
|
|
if cache_key in _TEXT_STRIP_CACHE:
|
|
return _TEXT_STRIP_CACHE[cache_key]
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
font = _load_font(font_name, font_size)
|
|
ascent, descent = font.getmetrics()
|
|
|
|
# Default stroke fill to black
|
|
if stroke_fill is None:
|
|
stroke_fill = (0, 0, 0, 255)
|
|
elif len(stroke_fill) == 3:
|
|
stroke_fill = (*stroke_fill, 255)
|
|
|
|
# Get text bbox (accounting for stroke)
|
|
temp = Image.new('RGBA', (1, 1))
|
|
temp_draw = ImageDraw.Draw(temp)
|
|
|
|
if multiline:
|
|
bbox = temp_draw.multiline_textbbox((0, 0), text, font=font, spacing=line_spacing,
|
|
stroke_width=stroke_width)
|
|
else:
|
|
bbox = temp_draw.textbbox((0, 0), text, font=font, stroke_width=stroke_width)
|
|
|
|
# bbox is (left, top, right, bottom) relative to origin
|
|
x_min, y_min, x_max, y_max = bbox
|
|
|
|
# Create image with padding (extra for stroke)
|
|
padding = 2 + stroke_width
|
|
img_width = max(int(x_max - x_min) + padding * 2, 1)
|
|
img_height = max(int(y_max - y_min) + padding * 2, 1)
|
|
|
|
# Create RGBA image
|
|
img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Draw text at position that puts it in the image
|
|
draw_x = padding - x_min
|
|
draw_y = padding - y_min
|
|
|
|
if multiline:
|
|
draw.multiline_text((draw_x, draw_y), text, fill=(255, 255, 255, 255), font=font,
|
|
spacing=line_spacing, align=align,
|
|
stroke_width=stroke_width, stroke_fill=stroke_fill)
|
|
else:
|
|
draw.text((draw_x, draw_y), text, fill=(255, 255, 255, 255), font=font,
|
|
stroke_width=stroke_width, stroke_fill=stroke_fill)
|
|
|
|
# Baseline is at y=0 in text coordinates, which is at draw_y in image
|
|
baseline_y = draw_y
|
|
|
|
# Convert to numpy for pixel analysis
|
|
img_array = np.array(img, dtype=np.uint8)
|
|
|
|
# Calculate anchor offsets
|
|
# For 'm' (middle) anchors, compute from actual rendered pixels for pixel-perfect matching
|
|
h_anchor = anchor[0] if len(anchor) > 0 else 'l'
|
|
v_anchor = anchor[1] if len(anchor) > 1 else 'a'
|
|
|
|
# Find actual pixel bounds (for middle anchor calculations)
|
|
alpha = img_array[:, :, 3]
|
|
nonzero_cols = np.where(alpha.max(axis=0) > 0)[0]
|
|
nonzero_rows = np.where(alpha.max(axis=1) > 0)[0]
|
|
|
|
if len(nonzero_cols) > 0:
|
|
pixel_x_min = nonzero_cols.min()
|
|
pixel_x_max = nonzero_cols.max()
|
|
pixel_x_center = (pixel_x_min + pixel_x_max) / 2.0
|
|
else:
|
|
pixel_x_center = img_width / 2.0
|
|
|
|
if len(nonzero_rows) > 0:
|
|
pixel_y_min = nonzero_rows.min()
|
|
pixel_y_max = nonzero_rows.max()
|
|
pixel_y_center = (pixel_y_min + pixel_y_max) / 2.0
|
|
else:
|
|
pixel_y_center = img_height / 2.0
|
|
|
|
# Horizontal offset
|
|
text_width = x_max - x_min
|
|
if h_anchor == 'l': # left edge of text
|
|
anchor_x = float(draw_x)
|
|
elif h_anchor == 'm': # middle - use actual pixel center for perfect matching
|
|
anchor_x = pixel_x_center
|
|
elif h_anchor == 'r': # right edge of text
|
|
anchor_x = float(draw_x + text_width)
|
|
else:
|
|
anchor_x = float(draw_x)
|
|
|
|
# Vertical offset
|
|
# PIL anchor positions are based on font metrics (ascent/descent):
|
|
# - 'a' (ascender): at the ascender line → draw_y in strip
|
|
# - 't' (top): at top of text bounding box → padding in strip
|
|
# - 'm' (middle): center of em-square = (ascent + descent) / 2 below ascender
|
|
# - 's' (baseline): at baseline = ascent below ascender
|
|
# - 'd' (descender): at descender line = ascent + descent below ascender
|
|
|
|
if v_anchor == 'a': # ascender
|
|
anchor_y = float(draw_y)
|
|
elif v_anchor == 't': # top of bbox
|
|
anchor_y = float(padding)
|
|
elif v_anchor == 'm': # middle (center of em-square, per PIL's calculation)
|
|
anchor_y = float(draw_y + (ascent + descent) / 2.0)
|
|
elif v_anchor == 's': # baseline
|
|
anchor_y = float(draw_y + ascent)
|
|
elif v_anchor == 'd': # descender
|
|
anchor_y = float(draw_y + ascent + descent)
|
|
else:
|
|
anchor_y = float(draw_y) # default to ascender
|
|
|
|
strip = TextStrip(
|
|
text=text,
|
|
image=img_array,
|
|
width=img_width,
|
|
height=img_height,
|
|
baseline_y=baseline_y,
|
|
bearing_x=float(x_min),
|
|
anchor_x=anchor_x,
|
|
anchor_y=anchor_y,
|
|
stroke_width=stroke_width,
|
|
)
|
|
|
|
_TEXT_STRIP_CACHE[cache_key] = strip
|
|
return strip
|
|
|
|
|
|
# =============================================================================
|
|
# Compile-time functions (called during S-expression compilation)
|
|
# =============================================================================
|
|
|
|
def get_glyph(char: str, font_name: str = None, font_size: int = 32) -> GlyphData:
|
|
"""Get glyph data for a single character. Compile-time."""
|
|
cache = _get_glyph_cache(font_name, font_size)
|
|
return cache.get(char, cache.get(' '))
|
|
|
|
|
|
def get_glyphs(text: str, font_name: str = None, font_size: int = 32) -> list:
|
|
"""Get glyph data for a string. Compile-time."""
|
|
cache = _get_glyph_cache(font_name, font_size)
|
|
space = cache.get(' ')
|
|
return [cache.get(c, space) for c in text]
|
|
|
|
|
|
def get_font_ascent(font_name: str = None, font_size: int = 32) -> float:
|
|
"""Get font ascent. Compile-time."""
|
|
_get_glyph_cache(font_name, font_size) # Ensure cache exists
|
|
return _METRICS_CACHE[(font_name, font_size)][0]
|
|
|
|
|
|
def get_font_descent(font_name: str = None, font_size: int = 32) -> float:
|
|
"""Get font descent. Compile-time."""
|
|
_get_glyph_cache(font_name, font_size)
|
|
return _METRICS_CACHE[(font_name, font_size)][1]
|
|
|
|
|
|
# =============================================================================
|
|
# JAX Runtime Primitives
|
|
# =============================================================================
|
|
|
|
def place_glyph_jax(
|
|
frame: jnp.ndarray,
|
|
glyph_image: jnp.ndarray, # (H, W, 4) RGBA
|
|
x: float,
|
|
y: float,
|
|
bearing_x: float,
|
|
bearing_y: float,
|
|
color: jnp.ndarray, # (3,) RGB 0-255
|
|
opacity: float = 1.0,
|
|
) -> jnp.ndarray:
|
|
"""
|
|
Place a glyph onto a frame. This is the core JAX primitive.
|
|
|
|
All positioning math can use traced values (x, y from audio, time, etc.)
|
|
The glyph_image is static (determined at compile time).
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB frame
|
|
glyph_image: (gh, gw, 4) RGBA glyph (pre-converted to JAX array)
|
|
x: X position of glyph origin (baseline point)
|
|
y: Y position of baseline
|
|
bearing_x: Left side bearing
|
|
bearing_y: Top bearing (from baseline to top)
|
|
color: RGB color array
|
|
opacity: Opacity 0-1
|
|
|
|
Returns:
|
|
Frame with glyph composited
|
|
"""
|
|
h, w = frame.shape[:2]
|
|
gh, gw = glyph_image.shape[:2]
|
|
|
|
# Calculate destination position
|
|
# bearing_x: how far right of origin the glyph starts (can be negative)
|
|
# bearing_y: how far up from baseline the glyph extends
|
|
padding = 2 # Must match padding used in glyph creation
|
|
dst_x = x + bearing_x - padding
|
|
dst_y = y - bearing_y - padding
|
|
|
|
# Extract glyph RGB and alpha
|
|
glyph_rgb = glyph_image[:, :, :3].astype(jnp.float32) / 255.0
|
|
# Match PIL's integer alpha math: (coverage * int(opacity*255) + 127) // 255
|
|
opacity_int = jnp.round(opacity * 255)
|
|
glyph_a_raw = glyph_image[:, :, 3:4].astype(jnp.float32)
|
|
glyph_alpha = jnp.floor(glyph_a_raw * opacity_int / 255.0 + 0.5) / 255.0
|
|
|
|
# Apply color tint (glyph is white, multiply by color)
|
|
color_normalized = color.astype(jnp.float32) / 255.0
|
|
tinted = glyph_rgb * color_normalized
|
|
|
|
from jax.lax import dynamic_update_slice
|
|
|
|
# Use padded buffer to avoid XLA's dynamic_update_slice clamping
|
|
buf_h = h + 2 * gh
|
|
buf_w = w + 2 * gw
|
|
rgb_buf = jnp.zeros((buf_h, buf_w, 3), dtype=jnp.float32)
|
|
alpha_buf = jnp.zeros((buf_h, buf_w, 1), dtype=jnp.float32)
|
|
|
|
dst_x_int = dst_x.astype(jnp.int32)
|
|
dst_y_int = dst_y.astype(jnp.int32)
|
|
place_y = jnp.maximum(dst_y_int + gh, 0).astype(jnp.int32)
|
|
place_x = jnp.maximum(dst_x_int + gw, 0).astype(jnp.int32)
|
|
|
|
rgb_buf = dynamic_update_slice(rgb_buf, tinted, (place_y, place_x, 0))
|
|
alpha_buf = dynamic_update_slice(alpha_buf, glyph_alpha, (place_y, place_x, 0))
|
|
|
|
rgb_layer = rgb_buf[gh:gh + h, gw:gw + w, :]
|
|
alpha_layer = alpha_buf[gh:gh + h, gw:gw + w, :]
|
|
|
|
# Alpha composite using PIL-compatible integer arithmetic
|
|
src_int = jnp.floor(rgb_layer * 255 + 0.5).astype(jnp.int32)
|
|
alpha_int = jnp.floor(alpha_layer * 255 + 0.5).astype(jnp.int32)
|
|
dst_int = frame.astype(jnp.int32)
|
|
result = (src_int * alpha_int + dst_int * (255 - alpha_int) + 127) // 255
|
|
|
|
return jnp.clip(result, 0, 255).astype(jnp.uint8)
|
|
|
|
|
|
def place_text_strip_jax(
|
|
frame: jnp.ndarray,
|
|
strip_image: jnp.ndarray, # (H, W, 4) RGBA
|
|
x: float,
|
|
y: float,
|
|
baseline_y: int,
|
|
bearing_x: float,
|
|
color: jnp.ndarray,
|
|
opacity: float = 1.0,
|
|
anchor_x: float = 0.0,
|
|
anchor_y: float = 0.0,
|
|
stroke_width: int = 0,
|
|
) -> jnp.ndarray:
|
|
"""
|
|
Place a pre-rendered text strip onto a frame.
|
|
|
|
The strip was rendered at compile time with proper sub-pixel anti-aliasing.
|
|
This just composites it at the specified position.
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB frame
|
|
strip_image: (sh, sw, 4) RGBA text strip
|
|
x: X position for anchor point
|
|
y: Y position for anchor point
|
|
baseline_y: Y position of baseline within the strip
|
|
bearing_x: Left side bearing
|
|
color: RGB color
|
|
opacity: Opacity 0-1
|
|
anchor_x: X offset of anchor point within strip
|
|
anchor_y: Y offset of anchor point within strip
|
|
stroke_width: Stroke width used when rendering (affects padding)
|
|
|
|
Returns:
|
|
Frame with text composited
|
|
"""
|
|
h, w = frame.shape[:2]
|
|
sh, sw = strip_image.shape[:2]
|
|
|
|
# Calculate destination position
|
|
# Anchor point (anchor_x, anchor_y) in strip should be at (x, y) in frame
|
|
# anchor_x/anchor_y already account for the anchor position within the strip
|
|
# Use floor(x + 0.5) for consistent rounding (jnp.round uses banker's rounding)
|
|
dst_x = jnp.floor(x - anchor_x + 0.5).astype(jnp.int32)
|
|
dst_y = jnp.floor(y - anchor_y + 0.5).astype(jnp.int32)
|
|
|
|
# Extract strip RGB and alpha
|
|
strip_rgb = strip_image[:, :, :3].astype(jnp.float32) / 255.0
|
|
# Match PIL's integer alpha math: (coverage * int(opacity*255) + 127) // 255
|
|
# Use jnp.round (banker's rounding) to match Python's round() used by PIL
|
|
opacity_int = jnp.round(opacity * 255)
|
|
strip_a_raw = strip_image[:, :, 3:4].astype(jnp.float32)
|
|
strip_alpha = jnp.floor(strip_a_raw * opacity_int / 255.0 + 0.5) / 255.0
|
|
|
|
# Apply color tint
|
|
color_normalized = color.astype(jnp.float32) / 255.0
|
|
tinted = strip_rgb * color_normalized
|
|
|
|
from jax.lax import dynamic_update_slice
|
|
|
|
# Use a padded buffer to avoid XLA's dynamic_update_slice clamping behavior.
|
|
# XLA clamps indices so the update fits, which silently shifts the strip.
|
|
# By placing into a buffer padded by strip dimensions, then extracting the
|
|
# frame-sized region, we get correct clipping for both overflow and underflow.
|
|
buf_h = h + 2 * sh
|
|
buf_w = w + 2 * sw
|
|
rgb_buf = jnp.zeros((buf_h, buf_w, 3), dtype=jnp.float32)
|
|
alpha_buf = jnp.zeros((buf_h, buf_w, 1), dtype=jnp.float32)
|
|
|
|
# Offset by (sh, sw) so dst=0 maps to (sh, sw) in buffer
|
|
place_y = jnp.maximum(dst_y + sh, 0).astype(jnp.int32)
|
|
place_x = jnp.maximum(dst_x + sw, 0).astype(jnp.int32)
|
|
|
|
rgb_buf = dynamic_update_slice(rgb_buf, tinted, (place_y, place_x, 0))
|
|
alpha_buf = dynamic_update_slice(alpha_buf, strip_alpha, (place_y, place_x, 0))
|
|
|
|
# Extract frame-sized region (sh, sw are compile-time constants from strip shape)
|
|
rgb_layer = rgb_buf[sh:sh + h, sw:sw + w, :]
|
|
alpha_layer = alpha_buf[sh:sh + h, sw:sw + w, :]
|
|
|
|
# Alpha composite using PIL-compatible integer arithmetic:
|
|
# result = (src * alpha + dst * (255 - alpha) + 127) // 255
|
|
src_int = jnp.floor(rgb_layer * 255 + 0.5).astype(jnp.int32)
|
|
alpha_int = jnp.floor(alpha_layer * 255 + 0.5).astype(jnp.int32)
|
|
dst_int = frame.astype(jnp.int32)
|
|
result = (src_int * alpha_int + dst_int * (255 - alpha_int) + 127) // 255
|
|
|
|
return jnp.clip(result, 0, 255).astype(jnp.uint8)
|
|
|
|
|
|
def place_glyph_simple(
|
|
frame: jnp.ndarray,
|
|
glyph: GlyphData,
|
|
x: float,
|
|
y: float,
|
|
color: tuple = (255, 255, 255),
|
|
opacity: float = 1.0,
|
|
) -> jnp.ndarray:
|
|
"""
|
|
Convenience wrapper that takes GlyphData directly.
|
|
Converts glyph image to JAX array.
|
|
|
|
For S-expression use, prefer place_glyph_jax with pre-converted arrays.
|
|
"""
|
|
glyph_jax = jnp.asarray(glyph.image)
|
|
color_jax = jnp.array(color, dtype=jnp.float32)
|
|
|
|
return place_glyph_jax(
|
|
frame, glyph_jax, x, y,
|
|
glyph.bearing_x, glyph.bearing_y,
|
|
color_jax, opacity
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Gradient Functions (compile-time: generate color maps from strip dimensions)
|
|
# =============================================================================
|
|
|
|
def make_linear_gradient(
|
|
width: int,
|
|
height: int,
|
|
color1: tuple,
|
|
color2: tuple,
|
|
angle: float = 0.0,
|
|
) -> np.ndarray:
|
|
"""Create a linear gradient color map.
|
|
|
|
Args:
|
|
width, height: Dimensions of the gradient (match strip dimensions)
|
|
color1: Start color (R, G, B) 0-255
|
|
color2: End color (R, G, B) 0-255
|
|
angle: Gradient angle in degrees (0 = left-to-right, 90 = top-to-bottom)
|
|
|
|
Returns:
|
|
(height, width, 3) float32 array with values in [0, 1]
|
|
"""
|
|
c1 = np.array(color1[:3], dtype=np.float32) / 255.0
|
|
c2 = np.array(color2[:3], dtype=np.float32) / 255.0
|
|
|
|
# Create coordinate grid
|
|
ys = np.arange(height, dtype=np.float32)
|
|
xs = np.arange(width, dtype=np.float32)
|
|
yy, xx = np.meshgrid(ys, xs, indexing='ij')
|
|
|
|
# Normalize to [0, 1]
|
|
nx = xx / max(width - 1, 1)
|
|
ny = yy / max(height - 1, 1)
|
|
|
|
# Project onto gradient axis
|
|
theta = angle * np.pi / 180.0
|
|
cos_t = np.cos(theta)
|
|
sin_t = np.sin(theta)
|
|
|
|
# Project (nx - 0.5, ny - 0.5) onto direction vector, then remap to [0, 1]
|
|
proj = (nx - 0.5) * cos_t + (ny - 0.5) * sin_t
|
|
# Normalize: max projection is 0.5*|cos|+0.5*|sin| = 0.5*(|cos|+|sin|)
|
|
max_proj = 0.5 * (abs(cos_t) + abs(sin_t))
|
|
if max_proj > 0:
|
|
t = (proj / max_proj + 1.0) / 2.0
|
|
else:
|
|
t = np.full_like(proj, 0.5)
|
|
t = np.clip(t, 0.0, 1.0)
|
|
|
|
# Interpolate
|
|
gradient = c1[None, None, :] * (1 - t[:, :, None]) + c2[None, None, :] * t[:, :, None]
|
|
return gradient
|
|
|
|
|
|
def make_radial_gradient(
|
|
width: int,
|
|
height: int,
|
|
color1: tuple,
|
|
color2: tuple,
|
|
center_x: float = 0.5,
|
|
center_y: float = 0.5,
|
|
) -> np.ndarray:
|
|
"""Create a radial gradient color map.
|
|
|
|
Args:
|
|
width, height: Dimensions
|
|
color1: Inner color (R, G, B)
|
|
color2: Outer color (R, G, B)
|
|
center_x, center_y: Center position in [0, 1] (0.5 = center)
|
|
|
|
Returns:
|
|
(height, width, 3) float32 array with values in [0, 1]
|
|
"""
|
|
c1 = np.array(color1[:3], dtype=np.float32) / 255.0
|
|
c2 = np.array(color2[:3], dtype=np.float32) / 255.0
|
|
|
|
ys = np.arange(height, dtype=np.float32)
|
|
xs = np.arange(width, dtype=np.float32)
|
|
yy, xx = np.meshgrid(ys, xs, indexing='ij')
|
|
|
|
# Normalize to [0, 1]
|
|
nx = xx / max(width - 1, 1)
|
|
ny = yy / max(height - 1, 1)
|
|
|
|
# Distance from center, normalized so corners are ~1.0
|
|
dx = nx - center_x
|
|
dy = ny - center_y
|
|
# Max possible distance from center to a corner
|
|
max_dist = np.sqrt(max(center_x, 1 - center_x)**2 + max(center_y, 1 - center_y)**2)
|
|
if max_dist > 0:
|
|
t = np.sqrt(dx**2 + dy**2) / max_dist
|
|
else:
|
|
t = np.zeros_like(dx)
|
|
t = np.clip(t, 0.0, 1.0)
|
|
|
|
gradient = c1[None, None, :] * (1 - t[:, :, None]) + c2[None, None, :] * t[:, :, None]
|
|
return gradient
|
|
|
|
|
|
def make_multi_stop_gradient(
|
|
width: int,
|
|
height: int,
|
|
stops: list,
|
|
angle: float = 0.0,
|
|
radial: bool = False,
|
|
center_x: float = 0.5,
|
|
center_y: float = 0.5,
|
|
) -> np.ndarray:
|
|
"""Create a multi-stop gradient color map.
|
|
|
|
Args:
|
|
width, height: Dimensions
|
|
stops: List of (position, (R, G, B)) tuples, position in [0, 1]
|
|
angle: Gradient angle in degrees (for linear mode)
|
|
radial: If True, use radial gradient
|
|
center_x, center_y: Center for radial gradient
|
|
|
|
Returns:
|
|
(height, width, 3) float32 array with values in [0, 1]
|
|
"""
|
|
if len(stops) < 2:
|
|
if len(stops) == 1:
|
|
c = np.array(stops[0][1][:3], dtype=np.float32) / 255.0
|
|
return np.broadcast_to(c, (height, width, 3)).copy()
|
|
return np.zeros((height, width, 3), dtype=np.float32)
|
|
|
|
# Sort stops by position
|
|
stops = sorted(stops, key=lambda s: s[0])
|
|
|
|
ys = np.arange(height, dtype=np.float32)
|
|
xs = np.arange(width, dtype=np.float32)
|
|
yy, xx = np.meshgrid(ys, xs, indexing='ij')
|
|
|
|
nx = xx / max(width - 1, 1)
|
|
ny = yy / max(height - 1, 1)
|
|
|
|
if radial:
|
|
dx = nx - center_x
|
|
dy = ny - center_y
|
|
max_dist = np.sqrt(max(center_x, 1 - center_x)**2 + max(center_y, 1 - center_y)**2)
|
|
t = np.sqrt(dx**2 + dy**2) / max(max_dist, 1e-6)
|
|
else:
|
|
theta = angle * np.pi / 180.0
|
|
cos_t = np.cos(theta)
|
|
sin_t = np.sin(theta)
|
|
proj = (nx - 0.5) * cos_t + (ny - 0.5) * sin_t
|
|
max_proj = 0.5 * (abs(cos_t) + abs(sin_t))
|
|
if max_proj > 0:
|
|
t = (proj / max_proj + 1.0) / 2.0
|
|
else:
|
|
t = np.full_like(proj, 0.5)
|
|
|
|
t = np.clip(t, 0.0, 1.0)
|
|
|
|
# Build gradient from stops using piecewise linear interpolation
|
|
colors = np.array([np.array(s[1][:3], dtype=np.float32) / 255.0 for s in stops])
|
|
positions = np.array([s[0] for s in stops], dtype=np.float32)
|
|
|
|
# Start with first color
|
|
gradient = np.broadcast_to(colors[0], (height, width, 3)).copy()
|
|
|
|
for i in range(len(stops) - 1):
|
|
p0, p1 = positions[i], positions[i + 1]
|
|
c0, c1 = colors[i], colors[i + 1]
|
|
|
|
if p1 <= p0:
|
|
continue
|
|
|
|
# Segment interpolation factor
|
|
seg_t = np.clip((t - p0) / (p1 - p0), 0.0, 1.0)
|
|
# Only apply where t >= p0
|
|
mask = (t >= p0)[:, :, None]
|
|
seg_color = c0[None, None, :] * (1 - seg_t[:, :, None]) + c1[None, None, :] * seg_t[:, :, None]
|
|
gradient = np.where(mask, seg_color, gradient)
|
|
|
|
return gradient
|
|
|
|
|
|
def _composite_strip_onto_frame(
|
|
frame: jnp.ndarray,
|
|
strip_rgb: jnp.ndarray,
|
|
strip_alpha: jnp.ndarray,
|
|
dst_x: jnp.ndarray,
|
|
dst_y: jnp.ndarray,
|
|
sh: int,
|
|
sw: int,
|
|
) -> jnp.ndarray:
|
|
"""Core compositing: place tinted+alpha strip onto frame using padded buffer.
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB uint8
|
|
strip_rgb: (sh, sw, 3) float32 in [0, 1] - pre-tinted strip RGB
|
|
strip_alpha: (sh, sw, 1) float32 in [0, 1] - effective alpha
|
|
dst_x, dst_y: int32 destination position
|
|
sh, sw: strip dimensions (compile-time constants)
|
|
|
|
Returns:
|
|
Composited frame (H, W, 3) uint8
|
|
"""
|
|
h, w = frame.shape[:2]
|
|
|
|
buf_h = h + 2 * sh
|
|
buf_w = w + 2 * sw
|
|
rgb_buf = jnp.zeros((buf_h, buf_w, 3), dtype=jnp.float32)
|
|
alpha_buf = jnp.zeros((buf_h, buf_w, 1), dtype=jnp.float32)
|
|
|
|
place_y = jnp.maximum(dst_y + sh, 0).astype(jnp.int32)
|
|
place_x = jnp.maximum(dst_x + sw, 0).astype(jnp.int32)
|
|
|
|
rgb_buf = lax.dynamic_update_slice(rgb_buf, strip_rgb, (place_y, place_x, 0))
|
|
alpha_buf = lax.dynamic_update_slice(alpha_buf, strip_alpha, (place_y, place_x, 0))
|
|
|
|
rgb_layer = rgb_buf[sh:sh + h, sw:sw + w, :]
|
|
alpha_layer = alpha_buf[sh:sh + h, sw:sw + w, :]
|
|
|
|
# PIL-compatible integer alpha blending
|
|
src_int = jnp.floor(rgb_layer * 255 + 0.5).astype(jnp.int32)
|
|
alpha_int = jnp.floor(alpha_layer * 255 + 0.5).astype(jnp.int32)
|
|
dst_int = frame.astype(jnp.int32)
|
|
result = (src_int * alpha_int + dst_int * (255 - alpha_int) + 127) // 255
|
|
|
|
return jnp.clip(result, 0, 255).astype(jnp.uint8)
|
|
|
|
|
|
def place_text_strip_gradient_jax(
|
|
frame: jnp.ndarray,
|
|
strip_image: jnp.ndarray,
|
|
x: float,
|
|
y: float,
|
|
baseline_y: int,
|
|
bearing_x: float,
|
|
gradient_map: jnp.ndarray,
|
|
opacity: float = 1.0,
|
|
anchor_x: float = 0.0,
|
|
anchor_y: float = 0.0,
|
|
stroke_width: int = 0,
|
|
) -> jnp.ndarray:
|
|
"""Place text strip with gradient coloring instead of solid color.
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB frame
|
|
strip_image: (sh, sw, 4) RGBA text strip
|
|
gradient_map: (sh, sw, 3) float32 color map in [0, 1]
|
|
Other args same as place_text_strip_jax
|
|
|
|
Returns:
|
|
Composited frame
|
|
"""
|
|
sh, sw = strip_image.shape[:2]
|
|
|
|
dst_x = jnp.floor(x - anchor_x + 0.5).astype(jnp.int32)
|
|
dst_y = jnp.floor(y - anchor_y + 0.5).astype(jnp.int32)
|
|
|
|
# Extract alpha with opacity
|
|
strip_rgb = strip_image[:, :, :3].astype(jnp.float32) / 255.0
|
|
opacity_int = jnp.round(opacity * 255)
|
|
strip_a_raw = strip_image[:, :, 3:4].astype(jnp.float32)
|
|
strip_alpha = jnp.floor(strip_a_raw * opacity_int / 255.0 + 0.5) / 255.0
|
|
|
|
# Apply gradient instead of solid color
|
|
tinted = strip_rgb * gradient_map
|
|
|
|
return _composite_strip_onto_frame(frame, tinted, strip_alpha, dst_x, dst_y, sh, sw)
|
|
|
|
|
|
# =============================================================================
|
|
# Strip Rotation (RGBA bilinear interpolation)
|
|
# =============================================================================
|
|
|
|
def _sample_rgba(strip, x, y):
|
|
"""Bilinear sample all 4 RGBA channels from a strip.
|
|
|
|
Args:
|
|
strip: (H, W, 4) RGBA float32
|
|
x, y: coordinate arrays (flattened)
|
|
|
|
Returns:
|
|
(r, g, b, a) each same shape as x
|
|
"""
|
|
h, w = strip.shape[:2]
|
|
|
|
x0 = jnp.floor(x).astype(jnp.int32)
|
|
y0 = jnp.floor(y).astype(jnp.int32)
|
|
x1 = x0 + 1
|
|
y1 = y0 + 1
|
|
|
|
fx = x - x0.astype(jnp.float32)
|
|
fy = y - y0.astype(jnp.float32)
|
|
|
|
valid00 = (x0 >= 0) & (x0 < w) & (y0 >= 0) & (y0 < h)
|
|
valid10 = (x1 >= 0) & (x1 < w) & (y0 >= 0) & (y0 < h)
|
|
valid01 = (x0 >= 0) & (x0 < w) & (y1 >= 0) & (y1 < h)
|
|
valid11 = (x1 >= 0) & (x1 < w) & (y1 >= 0) & (y1 < h)
|
|
|
|
x0_safe = jnp.clip(x0, 0, w - 1)
|
|
x1_safe = jnp.clip(x1, 0, w - 1)
|
|
y0_safe = jnp.clip(y0, 0, h - 1)
|
|
y1_safe = jnp.clip(y1, 0, h - 1)
|
|
|
|
channels = []
|
|
for c in range(4):
|
|
c00 = jnp.where(valid00, strip[y0_safe, x0_safe, c], 0.0)
|
|
c10 = jnp.where(valid10, strip[y0_safe, x1_safe, c], 0.0)
|
|
c01 = jnp.where(valid01, strip[y1_safe, x0_safe, c], 0.0)
|
|
c11 = jnp.where(valid11, strip[y1_safe, x1_safe, c], 0.0)
|
|
|
|
val = (c00 * (1 - fx) * (1 - fy) +
|
|
c10 * fx * (1 - fy) +
|
|
c01 * (1 - fx) * fy +
|
|
c11 * fx * fy)
|
|
channels.append(val)
|
|
|
|
return channels[0], channels[1], channels[2], channels[3]
|
|
|
|
|
|
def rotate_strip_jax(
|
|
strip_image: jnp.ndarray,
|
|
angle: float,
|
|
) -> jnp.ndarray:
|
|
"""Rotate an RGBA strip by angle (degrees), counter-clockwise.
|
|
|
|
Output buffer is sized to contain the full rotated strip.
|
|
The output size is ceil(sqrt(w^2 + h^2)), computed at trace time
|
|
from the strip's static shape.
|
|
|
|
Args:
|
|
strip_image: (H, W, 4) RGBA uint8
|
|
angle: Rotation angle in degrees
|
|
|
|
Returns:
|
|
(out_h, out_w, 4) RGBA uint8 - rotated strip
|
|
"""
|
|
sh, sw = strip_image.shape[:2]
|
|
|
|
# Output size: diagonal of original strip (compile-time constant).
|
|
# Ensure output dimensions have same parity as source so that the
|
|
# center offset (out - src) / 2 is always an integer. Otherwise
|
|
# identity rotations would place content at half-pixel offsets.
|
|
diag = int(math.ceil(math.sqrt(sw * sw + sh * sh)))
|
|
out_w = diag + ((diag % 2) != (sw % 2))
|
|
out_h = diag + ((diag % 2) != (sh % 2))
|
|
|
|
# Center of input strip and output buffer (pixel-center convention).
|
|
# Using (dim-1)/2 ensures integer coords map to integer coords for
|
|
# identity rotation regardless of even/odd dimension parity.
|
|
src_cx = (sw - 1) / 2.0
|
|
src_cy = (sh - 1) / 2.0
|
|
dst_cx = (out_w - 1) / 2.0
|
|
dst_cy = (out_h - 1) / 2.0
|
|
|
|
# Convert to radians and snap trig values near 0/±1 to exact values.
|
|
# Without snapping, e.g. sin(360°) ≈ 1.7e-7 instead of 0, causing
|
|
# bilinear blending at pixel edges and 1-value differences.
|
|
theta = angle * jnp.pi / 180.0
|
|
cos_t = jnp.cos(theta)
|
|
sin_t = jnp.sin(theta)
|
|
cos_t = jnp.where(jnp.abs(cos_t) < 1e-6, 0.0, cos_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t) < 1e-6, 0.0, sin_t)
|
|
cos_t = jnp.where(jnp.abs(cos_t - 1.0) < 1e-6, 1.0, cos_t)
|
|
cos_t = jnp.where(jnp.abs(cos_t + 1.0) < 1e-6, -1.0, cos_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t - 1.0) < 1e-6, 1.0, sin_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t + 1.0) < 1e-6, -1.0, sin_t)
|
|
|
|
# Create output coordinate grid
|
|
y_coords = jnp.repeat(jnp.arange(out_h), out_w).reshape(out_h, out_w)
|
|
x_coords = jnp.tile(jnp.arange(out_w), out_h).reshape(out_h, out_w)
|
|
|
|
# Inverse rotation: map output coords to source coords
|
|
x_centered = x_coords.astype(jnp.float32) - dst_cx
|
|
y_centered = y_coords.astype(jnp.float32) - dst_cy
|
|
|
|
src_x = cos_t * x_centered - sin_t * y_centered + src_cx
|
|
src_y = sin_t * x_centered + cos_t * y_centered + src_cy
|
|
|
|
# Sample all 4 channels
|
|
strip_f = strip_image.astype(jnp.float32)
|
|
r, g, b, a = _sample_rgba(strip_f, src_x.flatten(), src_y.flatten())
|
|
|
|
return jnp.stack([
|
|
jnp.clip(r, 0, 255).reshape(out_h, out_w).astype(jnp.uint8),
|
|
jnp.clip(g, 0, 255).reshape(out_h, out_w).astype(jnp.uint8),
|
|
jnp.clip(b, 0, 255).reshape(out_h, out_w).astype(jnp.uint8),
|
|
jnp.clip(a, 0, 255).reshape(out_h, out_w).astype(jnp.uint8),
|
|
], axis=2)
|
|
|
|
|
|
# =============================================================================
|
|
# Shadow Compositing
|
|
# =============================================================================
|
|
|
|
def _blur_alpha_channel(alpha: jnp.ndarray, radius: int) -> jnp.ndarray:
|
|
"""Blur a single-channel alpha array using Gaussian convolution.
|
|
|
|
Args:
|
|
alpha: (H, W) float32 alpha channel
|
|
radius: Blur radius (compile-time constant)
|
|
|
|
Returns:
|
|
(H, W) float32 blurred alpha
|
|
"""
|
|
size = radius * 2 + 1
|
|
x = jnp.arange(size, dtype=jnp.float32) - radius
|
|
sigma = max(radius / 2.0, 0.5)
|
|
gaussian_1d = jnp.exp(-x**2 / (2 * sigma**2))
|
|
gaussian_1d = gaussian_1d / gaussian_1d.sum()
|
|
kernel = jnp.outer(gaussian_1d, gaussian_1d)
|
|
|
|
# Use JAX conv with SAME padding
|
|
h, w = alpha.shape
|
|
data_4d = alpha.reshape(1, h, w, 1)
|
|
kernel_4d = kernel.reshape(size, size, 1, 1)
|
|
|
|
result = lax.conv_general_dilated(
|
|
data_4d, kernel_4d,
|
|
window_strides=(1, 1),
|
|
padding='SAME',
|
|
dimension_numbers=('NHWC', 'HWIO', 'NHWC'),
|
|
)
|
|
return result.reshape(h, w)
|
|
|
|
|
|
def place_text_strip_shadow_jax(
|
|
frame: jnp.ndarray,
|
|
strip_image: jnp.ndarray,
|
|
x: float,
|
|
y: float,
|
|
baseline_y: int,
|
|
bearing_x: float,
|
|
color: jnp.ndarray,
|
|
opacity: float = 1.0,
|
|
anchor_x: float = 0.0,
|
|
anchor_y: float = 0.0,
|
|
stroke_width: int = 0,
|
|
shadow_offset_x: float = 3.0,
|
|
shadow_offset_y: float = 3.0,
|
|
shadow_color: jnp.ndarray = None,
|
|
shadow_opacity: float = 0.5,
|
|
shadow_blur_radius: int = 0,
|
|
) -> jnp.ndarray:
|
|
"""Place text strip with a drop shadow.
|
|
|
|
Composites the strip twice: first as shadow (offset, colored, optionally blurred),
|
|
then the text itself on top.
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB frame
|
|
strip_image: (sh, sw, 4) RGBA text strip
|
|
shadow_offset_x/y: Shadow offset in pixels
|
|
shadow_color: (3,) RGB color for shadow (default black)
|
|
shadow_opacity: Shadow opacity 0-1
|
|
shadow_blur_radius: Gaussian blur radius for shadow (0 = sharp, compile-time)
|
|
Other args same as place_text_strip_jax
|
|
|
|
Returns:
|
|
Composited frame
|
|
"""
|
|
if shadow_color is None:
|
|
shadow_color = jnp.array([0, 0, 0], dtype=jnp.float32)
|
|
|
|
sh, sw = strip_image.shape[:2]
|
|
|
|
# --- Shadow pass ---
|
|
shadow_dst_x = jnp.floor(x - anchor_x + shadow_offset_x + 0.5).astype(jnp.int32)
|
|
shadow_dst_y = jnp.floor(y - anchor_y + shadow_offset_y + 0.5).astype(jnp.int32)
|
|
|
|
# Shadow alpha from strip alpha
|
|
shadow_opacity_int = jnp.round(shadow_opacity * 255)
|
|
strip_a_raw = strip_image[:, :, 3].astype(jnp.float32)
|
|
|
|
if shadow_blur_radius > 0:
|
|
# Blur the alpha channel for soft shadow
|
|
blurred_alpha = _blur_alpha_channel(strip_a_raw / 255.0, shadow_blur_radius)
|
|
shadow_alpha = jnp.floor(blurred_alpha * shadow_opacity_int + 0.5) / 255.0
|
|
else:
|
|
shadow_alpha = jnp.floor(strip_a_raw * shadow_opacity_int / 255.0 + 0.5) / 255.0
|
|
shadow_alpha = shadow_alpha[:, :, None] # (sh, sw, 1)
|
|
|
|
# Shadow RGB: solid shadow color
|
|
shadow_color_norm = shadow_color.astype(jnp.float32) / 255.0
|
|
shadow_rgb = jnp.broadcast_to(shadow_color_norm[None, None, :], (sh, sw, 3))
|
|
|
|
frame = _composite_strip_onto_frame(frame, shadow_rgb, shadow_alpha, shadow_dst_x, shadow_dst_y, sh, sw)
|
|
|
|
# --- Text pass ---
|
|
dst_x = jnp.floor(x - anchor_x + 0.5).astype(jnp.int32)
|
|
dst_y = jnp.floor(y - anchor_y + 0.5).astype(jnp.int32)
|
|
|
|
strip_rgb = strip_image[:, :, :3].astype(jnp.float32) / 255.0
|
|
opacity_int = jnp.round(opacity * 255)
|
|
text_alpha = jnp.floor(strip_a_raw[:, :, None] * opacity_int / 255.0 + 0.5) / 255.0
|
|
|
|
color_norm = color.astype(jnp.float32) / 255.0
|
|
tinted = strip_rgb * color_norm
|
|
|
|
frame = _composite_strip_onto_frame(frame, tinted, text_alpha, dst_x, dst_y, sh, sw)
|
|
|
|
return frame
|
|
|
|
|
|
# =============================================================================
|
|
# Combined FX Pipeline
|
|
# =============================================================================
|
|
|
|
def place_text_strip_fx_jax(
|
|
frame: jnp.ndarray,
|
|
strip_image: jnp.ndarray,
|
|
x: float,
|
|
y: float,
|
|
baseline_y: int = 0,
|
|
bearing_x: float = 0.0,
|
|
color: jnp.ndarray = None,
|
|
opacity: float = 1.0,
|
|
anchor_x: float = 0.0,
|
|
anchor_y: float = 0.0,
|
|
stroke_width: int = 0,
|
|
gradient_map: jnp.ndarray = None,
|
|
angle: float = 0.0,
|
|
shadow_offset_x: float = 0.0,
|
|
shadow_offset_y: float = 0.0,
|
|
shadow_color: jnp.ndarray = None,
|
|
shadow_opacity: float = 0.0,
|
|
shadow_blur_radius: int = 0,
|
|
) -> jnp.ndarray:
|
|
"""Combined text placement with gradient, rotation, and shadow.
|
|
|
|
Pipeline order:
|
|
1. Build color layer (solid color or gradient map)
|
|
2. Rotate strip + color layer if angle != 0
|
|
3. Composite shadow if shadow_opacity > 0
|
|
4. Composite text
|
|
|
|
Note: angle and shadow_blur_radius should be compile-time constants
|
|
for optimal JIT performance (they affect buffer shapes/kernel sizes).
|
|
|
|
Args:
|
|
frame: (H, W, 3) RGB frame
|
|
strip_image: (sh, sw, 4) RGBA text strip
|
|
x, y: Anchor point position
|
|
color: (3,) RGB color (ignored if gradient_map provided)
|
|
opacity: Text opacity
|
|
gradient_map: (sh, sw, 3) float32 color map in [0,1], or None for solid color
|
|
angle: Rotation angle in degrees (0 = no rotation)
|
|
shadow_offset_x/y: Shadow offset
|
|
shadow_color: (3,) RGB shadow color
|
|
shadow_opacity: Shadow opacity (0 = no shadow)
|
|
shadow_blur_radius: Shadow blur radius
|
|
|
|
Returns:
|
|
Composited frame
|
|
"""
|
|
if color is None:
|
|
color = jnp.array([255, 255, 255], dtype=jnp.float32)
|
|
if shadow_color is None:
|
|
shadow_color = jnp.array([0, 0, 0], dtype=jnp.float32)
|
|
|
|
sh, sw = strip_image.shape[:2]
|
|
|
|
# --- Step 1: Build color layer ---
|
|
if gradient_map is not None:
|
|
color_layer = gradient_map # (sh, sw, 3) float32 [0, 1]
|
|
else:
|
|
color_norm = color.astype(jnp.float32) / 255.0
|
|
color_layer = jnp.broadcast_to(color_norm[None, None, :], (sh, sw, 3))
|
|
|
|
# --- Step 2: Rotate if needed ---
|
|
# angle is expected to be a compile-time constant or static value
|
|
# We check at Python level to avoid tracing issues with dynamic shapes
|
|
use_rotation = not isinstance(angle, (int, float)) or angle != 0.0
|
|
|
|
if use_rotation:
|
|
# Rotate the strip
|
|
rotated_strip = rotate_strip_jax(strip_image, angle)
|
|
rh, rw = rotated_strip.shape[:2]
|
|
|
|
# Rotate the color layer by building a 4-channel color+dummy image
|
|
# Actually, just re-create color layer at rotated size
|
|
if gradient_map is not None:
|
|
# Rotate gradient map: pack into 3-channel "image", rotate via sampling
|
|
grad_uint8 = jnp.clip(gradient_map * 255, 0, 255).astype(jnp.uint8)
|
|
# Create RGBA from gradient (alpha=255 everywhere)
|
|
grad_rgba = jnp.concatenate([grad_uint8, jnp.full((sh, sw, 1), 255, dtype=jnp.uint8)], axis=2)
|
|
rotated_grad_rgba = rotate_strip_jax(grad_rgba, angle)
|
|
color_layer = rotated_grad_rgba[:, :, :3].astype(jnp.float32) / 255.0
|
|
else:
|
|
# Solid color: just broadcast to rotated size
|
|
color_norm = color.astype(jnp.float32) / 255.0
|
|
color_layer = jnp.broadcast_to(color_norm[None, None, :], (rh, rw, 3))
|
|
|
|
# Update anchor point for rotation (pixel-center convention)
|
|
# Rotate the anchor offset around the strip center
|
|
theta = angle * jnp.pi / 180.0
|
|
cos_t = jnp.cos(theta)
|
|
sin_t = jnp.sin(theta)
|
|
cos_t = jnp.where(jnp.abs(cos_t) < 1e-6, 0.0, cos_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t) < 1e-6, 0.0, sin_t)
|
|
cos_t = jnp.where(jnp.abs(cos_t - 1.0) < 1e-6, 1.0, cos_t)
|
|
cos_t = jnp.where(jnp.abs(cos_t + 1.0) < 1e-6, -1.0, cos_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t - 1.0) < 1e-6, 1.0, sin_t)
|
|
sin_t = jnp.where(jnp.abs(sin_t + 1.0) < 1e-6, -1.0, sin_t)
|
|
|
|
# Original anchor relative to strip pixel center
|
|
src_cx = (sw - 1) / 2.0
|
|
src_cy = (sh - 1) / 2.0
|
|
dst_cx = (rw - 1) / 2.0
|
|
dst_cy = (rh - 1) / 2.0
|
|
|
|
ax_rel = anchor_x - src_cx
|
|
ay_rel = anchor_y - src_cy
|
|
|
|
# Rotate anchor point (forward rotation, not inverse)
|
|
new_ax = -sin_t * ay_rel + cos_t * ax_rel + dst_cx
|
|
new_ay = cos_t * ay_rel + sin_t * ax_rel + dst_cy
|
|
|
|
strip_image = rotated_strip
|
|
anchor_x = new_ax
|
|
anchor_y = new_ay
|
|
sh, sw = rh, rw
|
|
|
|
# --- Step 3: Shadow ---
|
|
has_shadow = not isinstance(shadow_opacity, (int, float)) or shadow_opacity > 0
|
|
if has_shadow:
|
|
shadow_dst_x = jnp.floor(x - anchor_x + shadow_offset_x + 0.5).astype(jnp.int32)
|
|
shadow_dst_y = jnp.floor(y - anchor_y + shadow_offset_y + 0.5).astype(jnp.int32)
|
|
|
|
shadow_opacity_int = jnp.round(shadow_opacity * 255)
|
|
strip_a_raw = strip_image[:, :, 3].astype(jnp.float32)
|
|
|
|
if shadow_blur_radius > 0:
|
|
blurred_alpha = _blur_alpha_channel(strip_a_raw / 255.0, shadow_blur_radius)
|
|
shadow_alpha = jnp.floor(blurred_alpha * shadow_opacity_int + 0.5) / 255.0
|
|
else:
|
|
shadow_alpha = jnp.floor(strip_a_raw * shadow_opacity_int / 255.0 + 0.5) / 255.0
|
|
shadow_alpha = shadow_alpha[:, :, None]
|
|
|
|
shadow_color_norm = shadow_color.astype(jnp.float32) / 255.0
|
|
shadow_rgb = jnp.broadcast_to(shadow_color_norm[None, None, :], (sh, sw, 3))
|
|
|
|
frame = _composite_strip_onto_frame(frame, shadow_rgb, shadow_alpha, shadow_dst_x, shadow_dst_y, sh, sw)
|
|
|
|
# --- Step 4: Composite text ---
|
|
dst_x = jnp.floor(x - anchor_x + 0.5).astype(jnp.int32)
|
|
dst_y = jnp.floor(y - anchor_y + 0.5).astype(jnp.int32)
|
|
|
|
strip_rgb = strip_image[:, :, :3].astype(jnp.float32) / 255.0
|
|
opacity_int = jnp.round(opacity * 255)
|
|
strip_a_raw = strip_image[:, :, 3:4].astype(jnp.float32)
|
|
text_alpha = jnp.floor(strip_a_raw * opacity_int / 255.0 + 0.5) / 255.0
|
|
|
|
tinted = strip_rgb * color_layer
|
|
|
|
frame = _composite_strip_onto_frame(frame, tinted, text_alpha, dst_x, dst_y, sh, sw)
|
|
|
|
return frame
|
|
|
|
|
|
# =============================================================================
|
|
# S-Expression Primitive Bindings
|
|
# =============================================================================
|
|
|
|
def bind_typography_primitives(env: dict) -> dict:
|
|
"""
|
|
Add typography primitives to an S-expression environment.
|
|
|
|
Primitives added:
|
|
(text-glyphs text font-size) -> list of glyph data
|
|
(glyph-image g) -> JAX array (H, W, 4)
|
|
(glyph-advance g) -> float
|
|
(glyph-bearing-x g) -> float
|
|
(glyph-bearing-y g) -> float
|
|
(glyph-width g) -> int
|
|
(glyph-height g) -> int
|
|
(font-ascent font-size) -> float
|
|
(font-descent font-size) -> float
|
|
(place-glyph frame glyph-img x y bearing-x bearing-y color opacity) -> frame
|
|
"""
|
|
|
|
def prim_text_glyphs(text, font_size=32, font_name=None):
|
|
"""Get list of glyph data for text. Compile-time."""
|
|
return get_glyphs(str(text), font_name, int(font_size))
|
|
|
|
def prim_glyph_image(glyph):
|
|
"""Get glyph image as JAX array."""
|
|
return jnp.asarray(glyph.image)
|
|
|
|
def prim_glyph_advance(glyph):
|
|
"""Get glyph advance width."""
|
|
return glyph.advance
|
|
|
|
def prim_glyph_bearing_x(glyph):
|
|
"""Get glyph left side bearing."""
|
|
return glyph.bearing_x
|
|
|
|
def prim_glyph_bearing_y(glyph):
|
|
"""Get glyph top bearing."""
|
|
return glyph.bearing_y
|
|
|
|
def prim_glyph_width(glyph):
|
|
"""Get glyph image width."""
|
|
return glyph.width
|
|
|
|
def prim_glyph_height(glyph):
|
|
"""Get glyph image height."""
|
|
return glyph.height
|
|
|
|
def prim_font_ascent(font_size=32, font_name=None):
|
|
"""Get font ascent."""
|
|
return get_font_ascent(font_name, int(font_size))
|
|
|
|
def prim_font_descent(font_size=32, font_name=None):
|
|
"""Get font descent."""
|
|
return get_font_descent(font_name, int(font_size))
|
|
|
|
def prim_place_glyph(frame, glyph_img, x, y, bearing_x, bearing_y,
|
|
color=(255, 255, 255), opacity=1.0):
|
|
"""Place glyph on frame. Runtime JAX operation."""
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
return place_glyph_jax(frame, glyph_img, x, y, bearing_x, bearing_y,
|
|
color_arr, opacity)
|
|
|
|
def prim_glyph_kerning(glyph1, glyph2, font_size=32, font_name=None):
|
|
"""Get kerning adjustment between two glyphs. Compile-time.
|
|
|
|
Returns adjustment to add to glyph1's advance when glyph2 follows.
|
|
Typically negative (characters move closer).
|
|
|
|
Usage: (+ (glyph-advance g) (glyph-kerning g next-g font-size))
|
|
"""
|
|
return get_kerning(glyph1.char, glyph2.char, font_name, int(font_size))
|
|
|
|
def prim_char_kerning(char1, char2, font_size=32, font_name=None):
|
|
"""Get kerning adjustment between two characters. Compile-time."""
|
|
return get_kerning(str(char1), str(char2), font_name, int(font_size))
|
|
|
|
# TextStrip primitives for pre-rendered text with proper anti-aliasing
|
|
def prim_render_text_strip(text, font_size=32, font_name=None):
|
|
"""Render text to a strip at compile time. Perfect anti-aliasing."""
|
|
return render_text_strip(str(text), font_name, int(font_size))
|
|
|
|
def prim_render_text_strip_styled(
|
|
text, font_size=32, font_name=None,
|
|
stroke_width=0, stroke_fill=None,
|
|
anchor="la", multiline=False, line_spacing=4, align="left"
|
|
):
|
|
"""Render styled text to a strip. Supports stroke, anchors, multiline.
|
|
|
|
Args:
|
|
text: Text to render
|
|
font_size: Size in pixels
|
|
font_name: Path to font file
|
|
stroke_width: Outline width (0 = no outline)
|
|
stroke_fill: Outline color as (R,G,B) or (R,G,B,A)
|
|
anchor: 2-char anchor code (e.g., "mm" for center, "la" for left-ascender)
|
|
multiline: If True, handle newlines
|
|
line_spacing: Extra pixels between lines
|
|
align: "left", "center", "right" for multiline
|
|
"""
|
|
return render_text_strip(
|
|
str(text), font_name, int(font_size),
|
|
stroke_width=int(stroke_width),
|
|
stroke_fill=stroke_fill,
|
|
anchor=str(anchor),
|
|
multiline=bool(multiline),
|
|
line_spacing=int(line_spacing),
|
|
align=str(align),
|
|
)
|
|
|
|
def prim_text_strip_image(strip):
|
|
"""Get text strip image as JAX array."""
|
|
return jnp.asarray(strip.image)
|
|
|
|
def prim_text_strip_width(strip):
|
|
"""Get text strip width."""
|
|
return strip.width
|
|
|
|
def prim_text_strip_height(strip):
|
|
"""Get text strip height."""
|
|
return strip.height
|
|
|
|
def prim_text_strip_baseline_y(strip):
|
|
"""Get text strip baseline Y position."""
|
|
return strip.baseline_y
|
|
|
|
def prim_text_strip_bearing_x(strip):
|
|
"""Get text strip left bearing."""
|
|
return strip.bearing_x
|
|
|
|
def prim_text_strip_anchor_x(strip):
|
|
"""Get text strip anchor X offset."""
|
|
return strip.anchor_x
|
|
|
|
def prim_text_strip_anchor_y(strip):
|
|
"""Get text strip anchor Y offset."""
|
|
return strip.anchor_y
|
|
|
|
def prim_place_text_strip(frame, strip, x, y, color=(255, 255, 255), opacity=1.0):
|
|
"""Place pre-rendered text strip on frame. Runtime JAX operation."""
|
|
strip_img = jnp.asarray(strip.image)
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
return place_text_strip_jax(
|
|
frame, strip_img, x, y,
|
|
strip.baseline_y, strip.bearing_x,
|
|
color_arr, opacity,
|
|
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
|
|
stroke_width=strip.stroke_width
|
|
)
|
|
|
|
# --- Gradient primitives ---
|
|
|
|
def prim_linear_gradient(strip, color1, color2, angle=0.0):
|
|
"""Create linear gradient color map for a text strip. Compile-time."""
|
|
grad = make_linear_gradient(strip.width, strip.height,
|
|
tuple(int(c) for c in color1),
|
|
tuple(int(c) for c in color2),
|
|
float(angle))
|
|
return jnp.asarray(grad)
|
|
|
|
def prim_radial_gradient(strip, color1, color2, center_x=0.5, center_y=0.5):
|
|
"""Create radial gradient color map for a text strip. Compile-time."""
|
|
grad = make_radial_gradient(strip.width, strip.height,
|
|
tuple(int(c) for c in color1),
|
|
tuple(int(c) for c in color2),
|
|
float(center_x), float(center_y))
|
|
return jnp.asarray(grad)
|
|
|
|
def prim_multi_stop_gradient(strip, stops, angle=0.0, radial=False,
|
|
center_x=0.5, center_y=0.5):
|
|
"""Create multi-stop gradient for a text strip. Compile-time.
|
|
|
|
stops: list of (position, (R, G, B)) tuples
|
|
"""
|
|
parsed_stops = []
|
|
for s in stops:
|
|
pos = float(s[0])
|
|
color_tuple = tuple(int(c) for c in s[1])
|
|
parsed_stops.append((pos, color_tuple))
|
|
grad = make_multi_stop_gradient(strip.width, strip.height,
|
|
parsed_stops, float(angle),
|
|
bool(radial),
|
|
float(center_x), float(center_y))
|
|
return jnp.asarray(grad)
|
|
|
|
def prim_place_text_strip_gradient(frame, strip, x, y, gradient_map, opacity=1.0):
|
|
"""Place text strip with gradient coloring. Runtime JAX operation."""
|
|
strip_img = jnp.asarray(strip.image)
|
|
return place_text_strip_gradient_jax(
|
|
frame, strip_img, x, y,
|
|
strip.baseline_y, strip.bearing_x,
|
|
gradient_map, opacity,
|
|
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
|
|
stroke_width=strip.stroke_width
|
|
)
|
|
|
|
# --- Rotation primitive ---
|
|
|
|
def prim_place_text_strip_rotated(frame, strip, x, y, color=(255, 255, 255),
|
|
opacity=1.0, angle=0.0):
|
|
"""Place text strip with rotation. Runtime JAX operation."""
|
|
strip_img = jnp.asarray(strip.image)
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
return place_text_strip_fx_jax(
|
|
frame, strip_img, x, y,
|
|
baseline_y=strip.baseline_y, bearing_x=strip.bearing_x,
|
|
color=color_arr, opacity=opacity,
|
|
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
|
|
stroke_width=strip.stroke_width,
|
|
angle=float(angle),
|
|
)
|
|
|
|
# --- Shadow primitive ---
|
|
|
|
def prim_place_text_strip_shadow(frame, strip, x, y,
|
|
color=(255, 255, 255), opacity=1.0,
|
|
shadow_offset_x=3.0, shadow_offset_y=3.0,
|
|
shadow_color=(0, 0, 0), shadow_opacity=0.5,
|
|
shadow_blur_radius=0):
|
|
"""Place text strip with shadow. Runtime JAX operation."""
|
|
strip_img = jnp.asarray(strip.image)
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
shadow_color_arr = jnp.array(shadow_color, dtype=jnp.float32)
|
|
return place_text_strip_shadow_jax(
|
|
frame, strip_img, x, y,
|
|
strip.baseline_y, strip.bearing_x,
|
|
color_arr, opacity,
|
|
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
|
|
stroke_width=strip.stroke_width,
|
|
shadow_offset_x=float(shadow_offset_x),
|
|
shadow_offset_y=float(shadow_offset_y),
|
|
shadow_color=shadow_color_arr,
|
|
shadow_opacity=float(shadow_opacity),
|
|
shadow_blur_radius=int(shadow_blur_radius),
|
|
)
|
|
|
|
# --- Combined FX primitive ---
|
|
|
|
def prim_place_text_strip_fx(frame, strip, x, y,
|
|
color=(255, 255, 255), opacity=1.0,
|
|
gradient=None, angle=0.0,
|
|
shadow_offset_x=0.0, shadow_offset_y=0.0,
|
|
shadow_color=(0, 0, 0), shadow_opacity=0.0,
|
|
shadow_blur=0):
|
|
"""Place text strip with all effects. Runtime JAX operation."""
|
|
strip_img = jnp.asarray(strip.image)
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
shadow_color_arr = jnp.array(shadow_color, dtype=jnp.float32)
|
|
return place_text_strip_fx_jax(
|
|
frame, strip_img, x, y,
|
|
baseline_y=strip.baseline_y, bearing_x=strip.bearing_x,
|
|
color=color_arr, opacity=opacity,
|
|
anchor_x=strip.anchor_x, anchor_y=strip.anchor_y,
|
|
stroke_width=strip.stroke_width,
|
|
gradient_map=gradient,
|
|
angle=float(angle),
|
|
shadow_offset_x=float(shadow_offset_x),
|
|
shadow_offset_y=float(shadow_offset_y),
|
|
shadow_color=shadow_color_arr,
|
|
shadow_opacity=float(shadow_opacity),
|
|
shadow_blur_radius=int(shadow_blur),
|
|
)
|
|
|
|
# Add to environment
|
|
env.update({
|
|
# Glyph-by-glyph primitives (for wave, arc, audio-reactive effects)
|
|
'text-glyphs': prim_text_glyphs,
|
|
'glyph-image': prim_glyph_image,
|
|
'glyph-advance': prim_glyph_advance,
|
|
'glyph-bearing-x': prim_glyph_bearing_x,
|
|
'glyph-bearing-y': prim_glyph_bearing_y,
|
|
'glyph-width': prim_glyph_width,
|
|
'glyph-height': prim_glyph_height,
|
|
'glyph-kerning': prim_glyph_kerning,
|
|
'char-kerning': prim_char_kerning,
|
|
'font-ascent': prim_font_ascent,
|
|
'font-descent': prim_font_descent,
|
|
'place-glyph': prim_place_glyph,
|
|
# TextStrip primitives (for pixel-perfect static text)
|
|
'render-text-strip': prim_render_text_strip,
|
|
'render-text-strip-styled': prim_render_text_strip_styled,
|
|
'text-strip-image': prim_text_strip_image,
|
|
'text-strip-width': prim_text_strip_width,
|
|
'text-strip-height': prim_text_strip_height,
|
|
'text-strip-baseline-y': prim_text_strip_baseline_y,
|
|
'text-strip-bearing-x': prim_text_strip_bearing_x,
|
|
'text-strip-anchor-x': prim_text_strip_anchor_x,
|
|
'text-strip-anchor-y': prim_text_strip_anchor_y,
|
|
'place-text-strip': prim_place_text_strip,
|
|
# Gradient primitives
|
|
'linear-gradient': prim_linear_gradient,
|
|
'radial-gradient': prim_radial_gradient,
|
|
'multi-stop-gradient': prim_multi_stop_gradient,
|
|
'place-text-strip-gradient': prim_place_text_strip_gradient,
|
|
# Rotation
|
|
'place-text-strip-rotated': prim_place_text_strip_rotated,
|
|
# Shadow
|
|
'place-text-strip-shadow': prim_place_text_strip_shadow,
|
|
# Combined FX
|
|
'place-text-strip-fx': prim_place_text_strip_fx,
|
|
})
|
|
|
|
return env
|
|
|
|
|
|
# =============================================================================
|
|
# Example: Render text using primitives (for testing)
|
|
# =============================================================================
|
|
|
|
def render_text_primitives(
|
|
frame: jnp.ndarray,
|
|
text: str,
|
|
x: float,
|
|
y: float,
|
|
font_size: int = 32,
|
|
color: tuple = (255, 255, 255),
|
|
opacity: float = 1.0,
|
|
use_kerning: bool = True,
|
|
) -> jnp.ndarray:
|
|
"""
|
|
Render text using the primitives.
|
|
This is what an S-expression would compile to.
|
|
|
|
Args:
|
|
use_kerning: If True, apply kerning adjustments between characters
|
|
"""
|
|
glyphs = get_glyphs(text, None, font_size)
|
|
color_arr = jnp.array(color, dtype=jnp.float32)
|
|
|
|
cursor = x
|
|
for i, g in enumerate(glyphs):
|
|
glyph_jax = jnp.asarray(g.image)
|
|
frame = place_glyph_jax(
|
|
frame, glyph_jax, cursor, y,
|
|
g.bearing_x, g.bearing_y,
|
|
color_arr, opacity
|
|
)
|
|
# Advance cursor with optional kerning
|
|
advance = g.advance
|
|
if use_kerning and i + 1 < len(glyphs):
|
|
advance += get_kerning(g.char, glyphs[i + 1].char, None, font_size)
|
|
cursor = cursor + advance
|
|
|
|
return frame
|