← Files PixVerseARCHIVED FILE

pvx/graphics.py

25.6 KB · Oct 2, 2026 · 00:28 UTC

↓ Download file

"""Deterministic Pillow graphics for word-anchored compositions.

Each component renders one or more transparent PNG states at the full canvas size, so the
composer can overlay them at (0,0) and switch states on script anchors. Components are data
driven: a JSON spec selects the component, the canvas and the states to render.

Components:
  tier-board     rows S/A/B/C/D (configurable) with icons placed per state
  ranked-column  numbered rows revealed per state
  comment-card   a social comment card (name, text, likes)
  reveal-strip   a horizontal strip of slots revealed left to right (emoji/icon/text)
  lower-third    name + subtitle plate
  split-frame    divider and rounded masks for a two-host split layout
  highlight-box  a rounded outline to point at a screen region
  label          a single text label plate (title, verdict, sticker)
"""
from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

from PIL import Image, ImageDraw, ImageFont

FONT_CANDIDATES = {
    "display": (
        "/System/Library/Fonts/Supplemental/Arial Black.ttf",
        "/System/Library/Fonts/Supplemental/Impact.ttf",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
        "C:/Windows/Fonts/ariblk.ttf",
        "C:/Windows/Fonts/impact.ttf",
    ),
    "text": (
        "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
        "/System/Library/Fonts/Helvetica.ttc",
        "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
        "C:/Windows/Fonts/arialbd.ttf",
    ),
    "cjk": (
        "/System/Library/Fonts/PingFang.ttc",
        "/System/Library/Fonts/Hiragino Sans GB.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
        "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
        "C:/Windows/Fonts/msyhbd.ttc",
        "C:/Windows/Fonts/msyh.ttc",
    ),
    "emoji": (
        "/System/Library/Fonts/Apple Color Emoji.ttc",
        "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
        "C:/Windows/Fonts/seguiemj.ttf",
    ),
}
TIER_COLORS = {
    "S": (255, 127, 127), "A": (255, 191, 127), "B": (255, 255, 127), "C": (191, 255, 127),
    "D": (127, 191, 255), "E": (191, 127, 255), "F": (255, 127, 191),
}


EMOJI_RE = re.compile(
    "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF\U00002B00-\U00002BFF\U0000FE0F\U0000200D]+"
)
_EMOJI_BITMAP_SIZE = 109  # Apple Color Emoji ships fixed bitmap strikes; 109 is always present


def _emoji_font():
    for candidate in FONT_CANDIDATES["emoji"]:
        for size in (_EMOJI_BITMAP_SIZE, 136, 96):
            try:
                return ImageFont.truetype(candidate, size)
            except OSError:
                continue
    return None


def _runs(text: str) -> list[tuple[str, bool]]:
    runs: list[tuple[str, bool]] = []
    pos = 0
    for match in EMOJI_RE.finditer(text):
        if match.start() > pos:
            runs.append((text[pos:match.start()], False))
        runs.append((match.group(0), True))
        pos = match.end()
    if pos < len(text):
        runs.append((text[pos:], False))
    return runs


def measure_mixed(draw: ImageDraw.ImageDraw, text: str, font) -> tuple[int, int]:
    """Width/height of a line that may mix emoji and text."""
    line_h = int(font.size * 1.25)
    width = 0
    for run, is_emoji in _runs(text):
        if is_emoji:
            width += int(font.size * 1.15) * max(1, len(EMOJI_RE.findall(run)))
        else:
            box = draw.textbbox((0, 0), run, font=font)
            width += box[2] - box[0]
    return width, line_h


def draw_mixed(canvas: Image.Image, xy: tuple[int, int], text: str, font, fill, *, stroke_width: int = 0, stroke_fill=None) -> int:
    """Draw text with colour emoji fallback. Returns the drawn width. `xy` is the top-left."""
    draw = ImageDraw.Draw(canvas)
    x, y = xy
    line_h = int(font.size * 1.25)
    emoji_font = None
    for run, is_emoji in _runs(text):
        if not is_emoji:
            draw.text((x, y), run, font=font, fill=fill, stroke_width=stroke_width, stroke_fill=stroke_fill)
            box = draw.textbbox((x, y), run, font=font)
            x = box[2]
            continue
        emoji_font = emoji_font or _emoji_font()
        for glyph in EMOJI_RE.findall(run) or [run]:
            cell = int(font.size * 1.15)
            if emoji_font is None:
                draw.text((x, y), glyph, font=font, fill=fill)
            else:
                tile = Image.new("RGBA", (_EMOJI_BITMAP_SIZE * 2, _EMOJI_BITMAP_SIZE * 2), (0, 0, 0, 0))
                try:
                    ImageDraw.Draw(tile).text((10, 10), glyph, font=emoji_font, embedded_color=True)
                except TypeError:  # pragma: no cover - older Pillow
                    ImageDraw.Draw(tile).text((10, 10), glyph, font=emoji_font)
                bbox = tile.getbbox()
                if bbox:
                    glyph_im = tile.crop(bbox)
                    scale = cell / max(glyph_im.width, glyph_im.height)
                    glyph_im = glyph_im.resize((max(1, int(glyph_im.width * scale)), max(1, int(glyph_im.height * scale))), Image.LANCZOS)
                    canvas.alpha_composite(glyph_im, (int(x), int(y + (line_h - glyph_im.height) / 2)))
            x += cell
    return int(x - xy[0])


def _has_cjk(text: str) -> bool:
    return any("\u3040" <= ch <= "\u30ff" or "\u3400" <= ch <= "\u9fff" or "\uac00" <= ch <= "\ud7af" for ch in text)


def load_font(size: int, *, kind: str = "display", text: str = "", explicit: str | None = None):
    candidates: list[str] = []
    if explicit:
        candidates.append(explicit)
    if text and _has_cjk(text):
        candidates.extend(FONT_CANDIDATES["cjk"])
    candidates.extend(FONT_CANDIDATES.get(kind, ()))
    candidates.extend(FONT_CANDIDATES["text"])
    for candidate in candidates:
        try:
            return ImageFont.truetype(candidate, size)
        except OSError:
            continue
    return ImageFont.load_default()


def _rgba(value: Any, default: tuple[int, int, int, int] = (0, 0, 0, 255)) -> tuple[int, int, int, int]:
    if value is None:
        return default
    if isinstance(value, (list, tuple)):
        parts = [int(v) for v in value]
        return tuple(parts + [255] * (4 - len(parts)))[:4]  # type: ignore[return-value]
    text = str(value).strip().lstrip("#")
    if len(text) == 6:
        text += "FF"
    if len(text) != 8:
        raise ValueError(f"colour must be #RRGGBB or #RRGGBBAA, got {value!r}")
    return tuple(int(text[i:i + 2], 16) for i in (0, 2, 4, 6))  # type: ignore[return-value]


def _icon(path: str | Path, size: int, *, shape: str = "circle") -> Image.Image:
    im = Image.open(path).convert("RGBA")
    side = min(im.size)
    im = im.crop(((im.width - side) // 2, (im.height - side) // 2, (im.width + side) // 2, (im.height + side) // 2))
    im = im.resize((size, size), Image.LANCZOS)
    mask = Image.new("L", (size, size), 0)
    if shape == "circle":
        ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255)
    else:
        ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=size // 6, fill=255)
    out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    out.paste(im, (0, 0), mask)
    ImageDraw.Draw(out).ellipse((0, 0, size - 1, size - 1), outline=(255, 255, 255, 255), width=max(2, size // 20)) if shape == "circle" else None
    return out


def _canvas(spec: dict[str, Any]) -> Image.Image:
    canvas = spec.get("canvas") or {}
    return Image.new("RGBA", (int(canvas.get("width", 1080)), int(canvas.get("height", 1920))), (0, 0, 0, 0))


def _text_size(draw: ImageDraw.ImageDraw, text: str, font) -> tuple[int, int]:
    box = draw.textbbox((0, 0), text, font=font)
    return box[2] - box[0], box[3] - box[1]


# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------


def tier_board(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """state: {"placed": {"S": ["coffee"], "D": ["tea"]}} using icon keys from spec["icons"]."""
    board = _canvas(spec)
    d = ImageDraw.Draw(board)
    tiers = list(spec.get("tiers") or ["S", "A", "B", "C", "D"])
    x0 = int(spec.get("x", 44)); y0 = int(spec.get("y", 1010))
    width = int(spec.get("width", 470)); row_h = int(spec.get("row_height", 96))
    gap = int(spec.get("gap", 8)); label_w = int(spec.get("label_width", 96))
    icon_size = int(spec.get("icon_size", max(40, row_h - gap - 20)))
    font = load_font(int(spec.get("font_size", 52)), kind="display", explicit=spec.get("font"))
    plate = _rgba(spec.get("plate_color"), (20, 20, 24, 215))
    icons: dict[str, Image.Image] = {}
    for key, path in (spec.get("icons") or {}).items():
        icons[key] = _icon(path, icon_size, shape=str(spec.get("icon_shape", "circle")))
    placed = state.get("placed") or {}
    for i, tier in enumerate(tiers):
        y = y0 + i * row_h
        colour = _rgba((spec.get("tier_colors") or {}).get(tier), TIER_COLORS.get(tier, (200, 200, 200)) + (255,))
        d.rounded_rectangle((x0, y, x0 + width, y + row_h - gap), radius=14, fill=plate)
        d.rounded_rectangle((x0, y, x0 + label_w, y + row_h - gap), radius=14, fill=colour)
        d.text((x0 + label_w / 2, y + (row_h - gap) / 2), tier, font=font, fill=(20, 20, 20, 255), anchor="mm")
        for k, key in enumerate(placed.get(tier) or []):
            cx = x0 + label_w + 16 + k * (icon_size + 10)
            cy = y + (row_h - gap - icon_size) // 2
            if key in icons:
                board.alpha_composite(icons[key], (cx, cy))
            else:
                small = load_font(int(icon_size * 0.55), kind="text", text=key)
                d.text((cx, cy + icon_size // 2), key, font=small, fill=(255, 255, 255, 255), anchor="lm")
    return board


def ranked_column(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """state: {"revealed": ["rank-1", "rank-3"]} against spec rows [{"id","rank","label","icon"}]."""
    board = _canvas(spec)
    d = ImageDraw.Draw(board)
    rows = sorted(spec.get("rows") or [], key=lambda r: int(r.get("rank", 0)))
    x0 = int(spec.get("x", 44)); y0 = int(spec.get("y", 900)); width = int(spec.get("width", 520))
    row_h = int(spec.get("row_height", 92)); gap = int(spec.get("gap", 10))
    font = load_font(int(spec.get("font_size", 40)), kind="display", explicit=spec.get("font"))
    num_font = load_font(int(spec.get("font_size", 40)), kind="display")
    revealed = set(state.get("revealed") or [])
    for i, row in enumerate(rows):
        y = y0 + i * row_h
        shown = row.get("id") in revealed or bool(row.get("preset"))
        d.rounded_rectangle((x0, y, x0 + width, y + row_h - gap), radius=14, fill=(20, 20, 24, 215 if shown else 120))
        d.text((x0 + 36, y + (row_h - gap) / 2), str(row.get("rank")), font=num_font, fill=(255, 224, 96, 255), anchor="mm")
        if shown:
            label = str(row.get("label") or "")
            d.text((x0 + 84, y + (row_h - gap) / 2), label, font=load_font(int(spec.get("font_size", 40)), kind="display", text=label, explicit=spec.get("font")), fill=(255, 255, 255, 255), anchor="lm")
        else:
            d.text((x0 + 84, y + (row_h - gap) / 2), "???", font=font, fill=(160, 160, 160, 255), anchor="lm")
    return board


def comment_card(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """A social-comment card. state: {"name","text","likes","avatar"} (falls back to spec)."""
    canvas = _canvas(spec)
    data = {**spec, **state}
    x = int(data.get("x", 60)); y = int(data.get("y", 420)); width = int(data.get("width", 760))
    name = str(data.get("name") or "user"); text = str(data.get("text") or "")
    likes = data.get("likes")
    name_font = load_font(int(data.get("name_size", 34)), kind="text", text=name)
    text_font = load_font(int(data.get("text_size", 40)), kind="text", text=text)
    d = ImageDraw.Draw(canvas)
    pad = 28; avatar = 72
    lines = _wrap_text(d, text, text_font, width - pad * 2 - avatar - 20)
    line_h = int(text_font.size * 1.3)
    height = pad * 2 + 44 + line_h * max(1, len(lines)) + (44 if likes is not None else 0)
    d.rounded_rectangle((x, y, x + width, y + height), radius=26, fill=_rgba(data.get("plate_color"), (255, 255, 255, 240)))
    ax, ay = x + pad, y + pad
    if data.get("avatar") and Path(str(data["avatar"])).is_file():
        canvas.alpha_composite(_icon(str(data["avatar"]), avatar), (ax, ay))
    else:
        d.ellipse((ax, ay, ax + avatar, ay + avatar), fill=_rgba(data.get("avatar_color"), (104, 81, 235, 255)))
        d.text((ax + avatar / 2, ay + avatar / 2), name[:1].upper(), font=load_font(36, kind="display"), fill=(255, 255, 255, 255), anchor="mm")
    tx = ax + avatar + 20
    draw_mixed(canvas, (tx, ay), name, name_font, (40, 40, 40, 255))
    ty = ay + 44
    for line in lines:
        draw_mixed(canvas, (tx, ty), line, text_font, (20, 20, 20, 255))
        ty += line_h
    if likes is not None:
        draw_mixed(canvas, (tx, ty + 6), f"\u2665 {likes}", load_font(30, kind="text"), (235, 64, 96, 255))
    return canvas


def reveal_strip(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """Slots revealed left to right. spec slots: [{"text": "<emoji>"} or {"icon": path}]; state: {"revealed": n}."""
    canvas = _canvas(spec)
    d = ImageDraw.Draw(canvas)
    slots = list(spec.get("slots") or [])
    n = max(1, len(slots))
    y = int(spec.get("y", 120)); size = int(spec.get("slot_size", 150)); gap = int(spec.get("gap", 24))
    total = n * size + (n - 1) * gap
    x0 = int(spec.get("x", (canvas.width - total) // 2))
    revealed = int(state.get("revealed", 0))
    for i, slot in enumerate(slots):
        x = x0 + i * (size + gap)
        shown = i < revealed
        d.rounded_rectangle((x, y, x + size, y + size), radius=size // 5,
                            fill=_rgba(spec.get("plate_color"), (255, 255, 255, 235)) if shown else (255, 255, 255, 90))
        if not shown:
            d.text((x + size / 2, y + size / 2), "?", font=load_font(int(size * 0.5), kind="display"), fill=(90, 90, 90, 255), anchor="mm")
            continue
        if slot.get("icon") and Path(str(slot["icon"])).is_file():
            canvas.alpha_composite(_icon(str(slot["icon"]), size - 24, shape="rounded"), (x + 12, y + 12))
        else:
            text = str(slot.get("text") or "")
            emoji = bool(EMOJI_RE.fullmatch(text.strip()))
            font = load_font(int(size * (0.62 if emoji else 0.34)), kind="display", text=text)
            tw, th = measure_mixed(d, text, font)
            draw_mixed(canvas, (int(x + (size - tw) / 2), int(y + (size - th) / 2)), text, font, (20, 20, 20, 255))
    return canvas


def lower_third(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    canvas = _canvas(spec)
    data = {**spec, **state}
    d = ImageDraw.Draw(canvas)
    name = str(data.get("name") or ""); subtitle = str(data.get("subtitle") or "")
    x = int(data.get("x", 60)); y = int(data.get("y", canvas.height - 560))
    name_font = load_font(int(data.get("name_size", 56)), kind="display", text=name, explicit=data.get("font"))
    sub_font = load_font(int(data.get("subtitle_size", 34)), kind="text", text=subtitle)
    nw, nh = measure_mixed(d, name, name_font)
    sw, sh = measure_mixed(d, subtitle, sub_font) if subtitle else (0, 0)
    width = max(nw, sw) + 64
    height = nh + (sh + 8 if subtitle else 0) + 40
    d.rounded_rectangle((x, y, x + width, y + height), radius=18, fill=_rgba(data.get("plate_color"), (16, 16, 20, 220)))
    d.rectangle((x, y, x + 12, y + height), fill=_rgba(data.get("accent_color"), (104, 81, 235, 255)))
    draw_mixed(canvas, (x + 36, y + 20), name, name_font, (255, 255, 255, 255))
    if subtitle:
        draw_mixed(canvas, (x + 36, y + 20 + nh + 8), subtitle, sub_font, (220, 220, 220, 255))
    return canvas


def split_frame(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """A divider (and optional speaker highlight) for a top/bottom or left/right split layout."""
    canvas = _canvas(spec)
    d = ImageDraw.Draw(canvas)
    orientation = str(spec.get("orientation", "horizontal"))
    thickness = int(spec.get("thickness", 10))
    colour = _rgba(spec.get("color"), (255, 255, 255, 255))
    active = state.get("active")  # "top" | "bottom" | "left" | "right" | None
    hl = _rgba(spec.get("highlight_color"), (255, 224, 96, 255))
    w, h = canvas.size
    if orientation == "horizontal":
        y = int(spec.get("at", h // 2))
        d.rectangle((0, y - thickness // 2, w, y + thickness // 2), fill=colour)
        if active == "top":
            d.rectangle((0, 0, w, y - thickness // 2), outline=hl, width=thickness)
        elif active == "bottom":
            d.rectangle((0, y + thickness // 2, w, h), outline=hl, width=thickness)
    else:
        x = int(spec.get("at", w // 2))
        d.rectangle((x - thickness // 2, 0, x + thickness // 2, h), fill=colour)
        if active == "left":
            d.rectangle((0, 0, x - thickness // 2, h), outline=hl, width=thickness)
        elif active == "right":
            d.rectangle((x + thickness // 2, 0, w, h), outline=hl, width=thickness)
    return canvas


def highlight_box(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    canvas = _canvas(spec)
    data = {**spec, **state}
    d = ImageDraw.Draw(canvas)
    x = int(data.get("x", 100)); y = int(data.get("y", 100)); w = int(data.get("w", 400)); h = int(data.get("h", 200))
    d.rounded_rectangle((x, y, x + w, y + h), radius=int(data.get("radius", 18)),
                        outline=_rgba(data.get("color"), (255, 64, 64, 255)), width=int(data.get("thickness", 8)))
    if data.get("dim_outside"):
        overlay = Image.new("RGBA", canvas.size, (0, 0, 0, int(data.get("dim_alpha", 110))))
        hole = Image.new("L", canvas.size, 255)
        ImageDraw.Draw(hole).rounded_rectangle((x, y, x + w, y + h), radius=int(data.get("radius", 18)), fill=0)
        overlay.putalpha(hole.point(lambda v: int(data.get("dim_alpha", 110)) if v else 0))
        canvas = Image.alpha_composite(overlay, canvas)
    return canvas


def label(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """A single text plate: title, verdict word, sticker. Rotation and colours are spec choices."""
    canvas = _canvas(spec)
    data = {**spec, **state}
    text = str(data.get("text") or "")
    font = load_font(int(data.get("font_size", 120)), kind="display", text=text, explicit=data.get("font"))
    tmp = Image.new("RGBA", (10, 10))
    tw, th = _text_size(ImageDraw.Draw(tmp), text, font)
    pad = int(data.get("padding", 28))
    plate = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0))
    pd = ImageDraw.Draw(plate)
    if data.get("plate_color"):
        pd.rounded_rectangle((0, 0, plate.width - 1, plate.height - 1), radius=int(data.get("radius", 22)), fill=_rgba(data["plate_color"]))
    stroke = int(data.get("stroke", 0))
    pd.text((pad, pad - th * 0.15), text, font=font, fill=_rgba(data.get("color"), (255, 255, 255, 255)),
            stroke_width=stroke, stroke_fill=_rgba(data.get("stroke_color"), (0, 0, 0, 255)))
    angle = float(data.get("rotate", 0))
    if angle:
        plate = plate.rotate(angle, expand=True, resample=Image.BICUBIC)
    cx = int(data.get("x", canvas.width // 2)); cy = int(data.get("y", canvas.height // 5))
    canvas.alpha_composite(plate, (cx - plate.width // 2, cy - plate.height // 2))
    return canvas


def kinetic_text(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """Styled lettering from a style pack (see pvx.kinetic.STYLE_PACKS), placed by centre.

    spec: pack, overrides, canvas; state: text, x, y (centre; px or %), seed, pack/overrides overrides.
    """
    from . import kinetic

    canvas = _canvas(spec)
    data = {**spec, **state}
    text = str(data.get("text") or "")
    overrides = {**(spec.get("overrides") or {}), **(state.get("overrides") or {})}
    for key in ("size", "rotate", "jitter", "fill", "font"):
        if key in state:
            overrides[key] = state[key]
    element = kinetic.render_kinetic_text(text, str(data.get("pack") or "variety-title"), overrides=overrides, seed=int(data.get("seed", 7)))
    cx = _coord(data.get("x", "50%"), canvas.width); cy = _coord(data.get("y", "20%"), canvas.height)
    canvas.alpha_composite(element, (cx - element.width // 2, cy - element.height // 2))
    return canvas


def sticker(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """A cut-out sticker from a flat-background image (generated or supplied), with outline and shadow."""
    from . import kinetic

    canvas = _canvas(spec)
    data = {**spec, **state}
    element = kinetic.cutout_sticker(
        str(data["source"]), key_color=data.get("key_color"), tolerance=int(data.get("tolerance", 40)),
        outline=int(data.get("outline", 14)), outline_color=data.get("outline_color", "#FFFFFF"),
        shadow=bool(data.get("shadow", True)), size=int(data.get("size", 420)),
    )
    if data.get("flip"):
        element = element.transpose(Image.FLIP_LEFT_RIGHT)
    angle = float(data.get("rotate", 0))
    if angle:
        element = element.rotate(angle, resample=Image.BICUBIC, expand=True)
    cx = _coord(data.get("x", "80%"), canvas.width); cy = _coord(data.get("y", "30%"), canvas.height)
    canvas.alpha_composite(element, (cx - element.width // 2, cy - element.height // 2))
    return canvas


def emoji_sticker(spec: dict[str, Any], state: dict[str, Any]) -> Image.Image:
    """A large colour emoji with a white sticker outline and shadow."""
    from . import kinetic

    canvas = _canvas(spec)
    data = {**spec, **state}
    element = kinetic.emoji_sticker(str(data.get("text") or "\U0001F602"), size=int(data.get("size", 220)), outline=int(data.get("outline", 10)))
    angle = float(data.get("rotate", 0))
    if angle:
        element = element.rotate(angle, resample=Image.BICUBIC, expand=True)
    cx = _coord(data.get("x", "80%"), canvas.width); cy = _coord(data.get("y", "30%"), canvas.height)
    canvas.alpha_composite(element, (cx - element.width // 2, cy - element.height // 2))
    return canvas


def _coord(value: Any, total: int) -> int:
    if isinstance(value, str) and value.strip().endswith("%"):
        return int(round(total * float(value.strip()[:-1]) / 100.0))
    return int(round(float(value)))


COMPONENTS = {
    "tier-board": tier_board,
    "ranked-column": ranked_column,
    "comment-card": comment_card,
    "reveal-strip": reveal_strip,
    "lower-third": lower_third,
    "split-frame": split_frame,
    "highlight-box": highlight_box,
    "label": label,
    "kinetic-text": kinetic_text,
    "sticker": sticker,
    "emoji-sticker": emoji_sticker,
}


def _wrap_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
    if _has_cjk(text):
        lines: list[str] = []
        current = ""
        for ch in text:
            if _text_size(draw, current + ch, font)[0] > max_width and current:
                lines.append(current)
                current = ch
            else:
                current += ch
        if current:
            lines.append(current)
        return lines or [""]
    words = text.split()
    lines = []
    current = ""
    for word in words:
        candidate = f"{current} {word}".strip()
        if measure_mixed(draw, candidate, font)[0] > max_width and current:
            lines.append(current)
            current = word
        else:
            current = candidate
    if current:
        lines.append(current)
    return lines or [""]


def render_spec(spec: dict[str, Any], to_dir: str | Path) -> list[dict[str, Any]]:
    """Render every state of a component spec. Returns [{"id", "path", "state"}]."""
    component = str(spec.get("component") or "")
    if component not in COMPONENTS:
        raise ValueError(f"unknown component {component!r}; choose from {', '.join(sorted(COMPONENTS))}")
    states = spec.get("states")
    if not isinstance(states, list) or not states:
        states = [{"id": "default"}]
    out_dir = Path(to_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    rows: list[dict[str, Any]] = []
    for index, state in enumerate(states):
        state = dict(state or {})
        state_id = str(state.pop("id", None) or f"state-{index}")
        animate = state.pop("animate", None) or spec.get("animate")
        image = COMPONENTS[component](spec, state)
        path = out_dir / f"{spec.get('id') or component}-{state_id}.png"
        image.save(path)
        row: dict[str, Any] = {"id": state_id, "path": str(path), "state": state}
        if animate:
            from . import kinetic

            settings = animate if isinstance(animate, dict) else {"entrance": str(animate)}
            bbox = image.getbbox()
            if bbox:
                element = image.crop(bbox)
                center = ((bbox[0] + bbox[2]) // 2, (bbox[1] + bbox[3]) // 2)
                manifest = kinetic.animate_element(
                    element, canvas_size=image.size, center=center,
                    entrance=str(settings.get("entrance", "pop")), loop=str(settings.get("loop", "none")),
                    fps=int(settings.get("fps", 24)), entrance_seconds=float(settings.get("seconds", 0.35)),
                    loop_seconds=float(settings.get("loop_seconds", 0.5)),
                    to_dir=out_dir / f"{spec.get('id') or component}-{state_id}-seq", seed=int(settings.get("seed", 3)),
                )
                row["sequence"] = manifest["pattern"]
                row["sequence_dir"] = str(out_dir / f"{spec.get('id') or component}-{state_id}-seq")
                row["fps"] = manifest["fps"]
                row["frames"] = manifest["frames"]
                row["entrance"] = manifest["entrance"]
                row["loop"] = manifest["loop"]
        rows.append(row)
    return rows


def render_spec_file(path: str | Path, to_dir: str | Path) -> list[dict[str, Any]]:
    spec = json.loads(Path(path).read_text(encoding="utf-8"))
    return render_spec(spec, to_dir)

SHA-256: 78a164dbddaa7ad407cf2a2a3faa9618aa30472b726d8a98971cb51251df3638