# -*- coding: utf-8 -*-
"""Build a fully self-contained v4 director-mode doodle animation HTML.

v4 upgrades (over v3):
  1. Visual: removed feDisplacementMap/feTurbulence hand-drawn filters — clean crisp lines.
  2. Flat design: gradient sky (sky_top→sky_bottom), ground shade fill, palette_v4 prop colors.
  3. Props renderer: per-primitive fill/stroke/stroke_width from props.json schema (palette_v4).
  4. Character detail: hand=filled circle r9, foot=short horizontal line 26, neck line,
     upgraded face expressions with eyebrows (angry frown, sad tear, surprised arch).
  5. Parallax: prop.parallax<1 → cam_x multiplied; cloud type gets ±60u 12s drift.
  6. Subtitle frameless: no border/background — ink color + multi-layer white glow text-shadow.
  7. New FX 6 kinds: trail, flash, shake, ambient, light_rays, glow_pulse (+ original 8).
  8. Cinematography v4: look-ahead follow, closeup rule-of-thirds offset, 0.8s smoothstep entry.

Usage:
    py -3.14 scripts/build_html2.py <resolved.json> <plan.json> <rig_spec.json> <props.json> <voice.mp3> <out.html>
"""
import base64
import json
import math
import subprocess
import sys
import tempfile
from pathlib import Path


# ── Python-side helpers (camera pre-sampler) ──────────────────────────────────

def lerp(a, b, u):
    return a + (b - a) * u


def clamp(v, lo, hi):
    return max(lo, min(hi, v))


def ease_in_out(u):
    u = clamp(u, 0, 1)
    return u * u * (3 - 2 * u)


def actor_x_at_py(resolved: dict, name: str, t: float) -> float:
    if name == "coin":
        return coin_x_at_py(resolved, t)
    actor = resolved["actors"].get(name)
    if not actor:
        return 0.0
    x = actor["x0"]
    for pl in actor.get("places", []):
        if pl["t"] <= t:
            x = pl["x"]
    active_move = None
    for mv in actor.get("moves", []):
        if mv["t0"] <= t <= mv["t1"]:
            active_move = mv
        elif mv["t1"] <= t:
            pass
    if active_move:
        dur = active_move["t1"] - active_move["t0"]
        if dur <= 0:
            return active_move["to"]
        frac = clamp((t - active_move["t0"]) / dur, 0, 1)
        from_x = _actor_x_before_move(actor, active_move)
        return lerp(from_x, active_move["to"], frac)
    return _actor_resting_x(actor, t)


def _actor_x_before_move(actor: dict, move: dict) -> float:
    x = actor["x0"]
    for pl in actor.get("places", []):
        if pl["t"] < move["t0"]:
            x = pl["x"]
    for mv in actor.get("moves", []):
        if mv is move:
            break
        if mv["t1"] <= move["t0"]:
            x = mv["to"]
    return x


def _actor_resting_x(actor: dict, t: float) -> float:
    x = actor["x0"]
    for pl in actor.get("places", []):
        if pl["t"] <= t:
            x = pl["x"]
    for mv in actor.get("moves", []):
        if mv["t1"] <= t:
            x = mv["to"]
        elif mv["t0"] <= t:
            dur = mv["t1"] - mv["t0"]
            if dur <= 0:
                x = mv["to"]
            else:
                frac = clamp((t - mv["t0"]) / dur, 0, 1)
                from_x = _actor_x_before_move(actor, mv)
                x = lerp(from_x, mv["to"], frac)
    return x


def _get_facing_at(actor: dict, t: float) -> float:
    facing = 1.0
    for mv in actor.get("moves", []):
        if mv["t0"] <= t:
            from_x = _actor_x_before_move(actor, mv)
            dx = mv["to"] - from_x
            if abs(dx) > 1:
                facing = 1.0 if dx > 0 else -1.0
    return facing


def coin_x_at_py(resolved: dict, t: float) -> float:
    segs = resolved["items"]["coin"]["segments"]
    seg = None
    for s in segs:
        if s["t0"] <= t <= s["t1"]:
            seg = s
            break
    if seg is None:
        last = segs[-1]
        if last["mode"] == "static":
            return last["x"]
        if last["mode"] == "hops":
            return last["to"]
        return 0.0
    mode = seg["mode"]
    dur = seg["t1"] - seg["t0"]
    u = clamp((t - seg["t0"]) / dur, 0, 1) if dur > 0 else 1.0
    if mode == "carried":
        carrier = seg["carrier"]
        cx = actor_x_at_py(resolved, carrier, t)
        facing = _get_facing_at(resolved["actors"].get(carrier, {}), t)
        return cx + facing * 70
    elif mode == "arc":
        fx = seg["from_x"] if "from_x" in seg else actor_x_at_py(resolved, seg.get("from_carrier", ""), seg["t0"])
        tx = actor_x_at_py(resolved, seg["to_carrier"], seg["t1"]) if "to_carrier" in seg else seg.get("to_x", 0)
        return lerp(fx, tx, u)
    elif mode == "orbit":
        cx = seg["center_x"]
        total_loops = seg.get("loops", 1)
        angle = -math.pi / 2 + total_loops * 2 * math.pi * u
        r = seg["radius"]
        return cx + r * math.cos(angle)
    elif mode == "hops":
        return lerp(seg["from"], seg["to"], u)
    elif mode == "shrink":
        carrier = seg.get("carrier", "")
        if carrier:
            cx = actor_x_at_py(resolved, carrier, t)
            facing = _get_facing_at(resolved["actors"].get(carrier, {}), t)
            return cx + facing * 70
        return 0.0
    elif mode == "hidden":
        return -9999.0
    elif mode == "static":
        return seg["x"]
    return 0.0


def _raw_cam_x_at(resolved: dict, seg: dict, t: float) -> float:
    """Return raw (pre-look-ahead) cam_x for a segment at time t."""
    if seg["mode"] == "fixed":
        return seg["x"]
    raw_x = actor_x_at_py(resolved, seg["target"], t)
    return clamp(raw_x, -1700.0, 4700.0)


def _look_ahead_offset(resolved: dict, seg: dict, t: float) -> float:
    """Compute look-ahead offset for follow segments (v4 spec: (x(t+0.3)-x(t))*0.8, clamp ±150)."""
    if seg["mode"] != "follow":
        return 0.0
    target = seg["target"]
    x_now = actor_x_at_py(resolved, target, t)
    x_ahead = actor_x_at_py(resolved, target, t + 0.3)
    offset = (x_ahead - x_now) * 0.8
    return clamp(offset, -150.0, 150.0)


def build_camera_table_full(resolved: dict, duration: float, fps: float = 20.0) -> list:
    """Build full camera sample table as [[cam_x, cam_y, zoom], ...].

    v4: hard_cut → zero transition + flash; normal boundary → 0.8s smoothstep.
    follow segments apply look-ahead offset (clamped ±150, smoothed 0.3s internally).
    """
    camera_segs = resolved["camera"]
    n = int(math.ceil(duration * fps)) + 2
    table = []

    # Pre-compute entry values for each segment (the value AT the start of this seg = end of previous)
    entry_vals = {}  # si -> (cam_x, cam_y, zoom)
    for si, seg in enumerate(camera_segs):
        if si == 0:
            cam_y = seg.get("cam_y", 0.0)
            raw_x = _raw_cam_x_at(resolved, seg, seg["t0"])
            entry_vals[si] = (raw_x, cam_y, seg["zoom"])
        else:
            prev_seg = camera_segs[si - 1]
            t_prev_end = prev_seg["t1"]
            prev_cam_y = prev_seg.get("cam_y", 0.0)
            ex = _raw_cam_x_at(resolved, prev_seg, t_prev_end)
            # add look-ahead offset that was active at segment end
            ex += _look_ahead_offset(resolved, prev_seg, t_prev_end)
            entry_vals[si] = (ex, prev_cam_y, prev_seg["zoom"])

    # Smoothed look-ahead: maintain a per-sample smoothed offset (IIR, τ=0.3s)
    smooth_la = 0.0
    dt_fps = 1.0 / fps

    for i in range(n):
        t = i / fps

        owning_si = None
        for si, seg in enumerate(camera_segs):
            if seg["t0"] <= t <= seg["t1"]:
                owning_si = si
                break

        if owning_si is None:
            if t < camera_segs[0]["t0"]:
                owning_si = 0
            else:
                owning_si = len(camera_segs) - 1

        seg = camera_segs[owning_si]
        target_zoom = seg["zoom"]
        target_cam_y = seg.get("cam_y", 0.0)
        target_cam_x_raw = _raw_cam_x_at(resolved, seg, t)

        # Smooth look-ahead (IIR low-pass, time constant 0.3s)
        la_target = _look_ahead_offset(resolved, seg, t)
        alpha = 1.0 - math.exp(-dt_fps / 0.3)
        smooth_la = smooth_la + alpha * (la_target - smooth_la)
        target_cam_x = target_cam_x_raw + smooth_la

        seg_start = seg["t0"]
        time_in_seg = t - seg_start
        TRANS = 0.8
        is_hard_cut = seg.get("hard_cut", False)

        if not is_hard_cut and time_in_seg < TRANS and owning_si in entry_vals:
            entry_x, entry_y, entry_zoom = entry_vals[owning_si]
            u = ease_in_out(time_in_seg / TRANS)
            cam_x = lerp(entry_x, target_cam_x, u)
            cam_y = lerp(entry_y, target_cam_y, u)
            zoom = lerp(entry_zoom, target_zoom, u)
        else:
            cam_x = target_cam_x
            cam_y = target_cam_y
            zoom = target_zoom

        table.append([round(cam_x, 2), round(cam_y, 2), round(zoom, 4)])

    return table


def collect_characters(resolved: dict, plan: dict) -> str:
    """Collect all Chinese/special characters used in the animation for font subsetting."""
    chars = set()
    # Subtitles and keywords from plan
    for sc in plan.get("scenes", []):
        sub = sc.get("subtitle", "")
        for ch in sub:
            chars.add(ch)
        for kw in sc.get("keywords", []):
            for ch in kw.get("text", ""):
                chars.add(ch)
    # Prop labels
    for prop in resolved.get("props", []):
        lbl = prop.get("label", "")
        for ch in lbl:
            chars.add(ch)
    # Bank text symbol and emphasis glyphs
    for ch in "¥银行面包店理发店面粉坊0123456789!?":
        chars.add(ch)
    # FX emphasis glyphs
    for fx in resolved.get("fx", []):
        g = fx.get("glyph", "")
        for ch in g:
            chars.add(ch)
    return "".join(sorted(chars))


def subset_font(ttf_path: Path, chars: str) -> bytes:
    """Subset the TTF to only the needed characters and return woff2 bytes."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmp = Path(tmpdir)
        char_file = tmp / "chars.txt"
        char_file.write_text(chars, encoding="utf-8")
        out_woff2 = tmp / "subset.woff2"
        cmd = [
            "py", "-3.14", "-m", "fontTools.subset",
            str(ttf_path),
            f"--text-file={char_file}",
            f"--output-file={out_woff2}",
            "--flavor=woff2",
            "--no-hinting",
        ]
        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            print(f"[font subset warning] {result.stderr[:200]}", file=sys.stderr)
            # fallback: return empty bytes, font will fall back to system fonts
            return b""
        if out_woff2.exists():
            data = out_woff2.read_bytes()
            print(f"  Font subset: {len(data)/1024:.1f} KB for {len(chars)} chars")
            return data
        return b""


# ── HTML template ──────────────────────────────────────────────────────────────

TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>doodle v4</title>
<style>
__FONT_FACE__
html, body { margin:0; height:100%; background:#2A2722; overflow:hidden; }
#stage {
  position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
  width:min(100vw,177.7778vh); height:min(56.25vw,100vh);
  background:#FBF8F2; overflow:hidden;
  font-family:"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei","PingFang SC",sans-serif;
}
#worldsvg { position:absolute; inset:0; width:100%; height:100%; }
#fxlayer  { position:absolute; inset:0; pointer-events:none; z-index:4; }
/* v4: frameless subtitle — ink text + white glow halos */
#subtitle {
  position:absolute; left:50%; bottom:4.5%; transform:translateX(-50%);
  max-width:92%; padding:0.2em 0;
  color:#1A1A1A;
  font-family:"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei","PingFang SC",sans-serif;
  font-size:min(3vw,48px); font-weight:700;
  white-space:nowrap; z-index:5;
  text-shadow:
    0 0 8px rgba(255,255,255,0.95),
    0 0 16px rgba(255,255,255,0.80),
    0 0 28px rgba(255,255,255,0.60),
    0 2px 4px rgba(255,255,255,0.90);
  background:none; border:none; border-radius:0; box-shadow:none;
}
#subtitle:empty { visibility:hidden; }
.kw-box {
  position:absolute; color:#D4231C;
  font-family:"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei","PingFang SC",sans-serif;
  font-size:min(7.6vw,13.5vh); font-weight:900;
  z-index:6; visibility:hidden; transform-origin:50% 50%;
  -webkit-text-stroke: 0.07em rgba(253,250,245,0.95);
  paint-order: stroke fill;
  text-shadow: 0 0 0.18em rgba(253,250,245,0.9), 0.05em 0.05em 0 rgba(26,26,26,0.15);
  white-space:nowrap;
  rotate:-3deg;
}
#playbtn {
  position:absolute; inset:0; z-index:10; cursor:pointer;
  display:flex; align-items:center; justify-content:center;
  background:rgba(251,248,242,0.55);
  color:#1A1A1A; font-size:min(4vw,7vh); font-weight:700;
}
#playbtn span {
  border:4px solid #1A1A1A; border-radius:20px; padding:0.35em 1.1em;
  background:#FBF8F2; box-shadow:0.15em 0.15em 0 rgba(26,26,26,0.25);
}
/* vignette overlay — very light */
#vignette {
  position:absolute; inset:0; pointer-events:none; z-index:3;
  background: radial-gradient(ellipse 90% 85% at 50% 50%,
    transparent 60%, rgba(26,22,18,0.05) 100%);
}
/* flash overlay */
#flash_overlay {
  position:absolute; inset:0; pointer-events:none; z-index:7;
  background:white; opacity:0;
}
/* shake wrapper */
#shake_wrapper {
  position:absolute; inset:0; overflow:hidden;
}
</style>
</head>
<body>
<div id="stage">
  <div id="shake_wrapper">
  <svg id="worldsvg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid meet">
    <defs>
      <!-- v4: NO feTurbulence, NO feDisplacementMap — clean lines only -->
      <!-- Shadow blur -->
      <filter id="f_shadow" x="-50%" y="-50%" width="200%" height="200%">
        <feGaussianBlur stdDeviation="8"/>
      </filter>
      <!-- Glow filter for glow_pulse FX -->
      <filter id="f_glow" x="-60%" y="-60%" width="220%" height="220%">
        <feGaussianBlur stdDeviation="18" result="blur"/>
        <feComposite in="SourceGraphic" in2="blur" operator="over"/>
      </filter>
      <!-- Sky gradient (v4 palette_v4) -->
      <linearGradient id="sky_grad" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0%" stop-color="#FBF8F2"/>
        <stop offset="100%" stop-color="#EFE8DC"/>
      </linearGradient>
    </defs>
    <!-- Sky background gradient -->
    <rect id="sky_bg" x="0" y="0" width="1920" height="1080" fill="url(#sky_grad)"/>
    <!-- Ground shade fill (below ground line) -->
    <rect id="ground_shade" x="0" y="800" width="1920" height="280" fill="#E9E2D5" opacity="0.06"/>
    <!-- World group (v4: no wobble filter) -->
    <g id="world_g">
      <!-- Shadows layer (below everything) -->
      <g id="shadows_layer"></g>
      <!-- Ground line -->
      <line id="ground_line" x1="0" y1="800" x2="1920" y2="800"
        stroke="#1A1A1A" stroke-width="5" stroke-linecap="round" opacity="0.4"/>
      <!-- Props and actors populated by JS -->
    </g>
  </svg>
  </div>
  <div id="fxlayer"></div>
  <div id="kwcontainer"></div>
  <div id="subtitle"></div>
  <div id="vignette"></div>
  <div id="flash_overlay"></div>
  <div id="playbtn"><span>&#9654; 播放</span></div>
</div>
<audio id="narration" preload="auto" src="data:audio/mpeg;base64,__AUDIO_B64__"></audio>
<script>__GSAP__</script>
<script>__GSAP_PLUGINS__</script>
<script>
"use strict";

// ── Data ──────────────────────────────────────────────────────────────────────
const RESOLVED  = __RESOLVED__;
const PLAN      = __PLAN__;
const RIG       = __RIG__;
const PROPS_DEF = __PROPS_DEF__;
const CAM_TABLE = __CAM_TABLE__;   // [[cam_x, cam_y, zoom], ...] at 20 Hz
const CAM_FPS   = 20;
const DURATION  = RESOLVED.meta.audio_duration + 1.0;
const K         = RESOLVED.meta.K || 0.9;
const GROUND_Y  = RESOLVED.meta.ground_y;  // world y = 200

// ── SVG helpers ───────────────────────────────────────────────────────────────
const SVG_NS = "http://www.w3.org/2000/svg";
function svgEl(tag, attrs) {
  const el = document.createElementNS(SVG_NS, tag);
  for (const [k,v] of Object.entries(attrs||{})) el.setAttribute(k, v);
  return el;
}
function svgG(attrs) { return svgEl("g", attrs); }

// ── Coordinate systems ────────────────────────────────────────────────────────
// SVG viewBox is 1920x1080; stage CSS is min(100vw,177.78vh) x min(56.25vw,100vh)
// At 1280x720 viewport the stage is exactly 1280x720 → scale = 1280/1920 = 2/3
// HTML FX layer (fxlayer div) maps 1:1 to CSS stage pixels
// worldToScreen() returns SVG coords 0-1920/0-1080
// svgToCSS(sx,sy) → CSS px (for HTML-layer FX divs, emphasis, confetti, etc.)
function svgToCSS(sx, sy) {
  // Compute dynamically from actual stage size (handles any viewport)
  const stage = document.getElementById("stage");
  const scaleX = stage.offsetWidth  / 1920;
  const scaleY = stage.offsetHeight / 1080;
  return [sx * scaleX, sy * scaleY];
}

// ── World→Screen mapping ──────────────────────────────────────────────────────
function camAt(t) {
  const idx = Math.min(Math.floor(t * CAM_FPS), CAM_TABLE.length - 1);
  const nxt = Math.min(idx + 1, CAM_TABLE.length - 1);
  const frac = (t * CAM_FPS) - idx;
  const [cx0, cy0, z0] = CAM_TABLE[idx];
  const [cx1, cy1, z1] = CAM_TABLE[nxt];
  return [
    cx0 + (cx1 - cx0) * frac,
    cy0 + (cy1 - cy0) * frac,
    z0  + (z1  - z0 ) * frac
  ];
}

// screen_x = (world_x - cam_x) * K * zoom + 960
// screen_y = (world_y - cam_y) * K * zoom + (800 - 180 * zoom)
function worldToScreen(wx, wy, camX, camY, zoom) {
  const sx = (wx - camX) * K * zoom + 960;
  const sy = (wy - camY) * K * zoom + (800 - 180 * zoom);
  return [sx, sy];
}

// ── Math helpers ──────────────────────────────────────────────────────────────
function lerp(a, b, u) { return a + (b-a)*u; }
function clamp(v,lo,hi) { return Math.max(lo,Math.min(hi,v)); }
function easeOut(u)    { u=clamp(u,0,1); return 1-(1-u)**2; }
function easeOut3(u)   { u=clamp(u,0,1); return 1-(1-u)**3; }
function easeInOut(u)  { u=clamp(u,0,1); return u*u*(3-2*u); }
function easeBackOut(u, s=1.7) {
  u=clamp(u,0,1);
  return 1 + (s+1)*(u-1)**3 + s*(u-1)**2;
}

// pose anticipation/rebound envelope
function poseBlend(t, tp) {
  const dt = t - tp;
  if (dt < -0.2) return 0;
  if (dt < 0)    return easeOut((dt + 0.2) / 0.2) * (-0.5);
  if (dt < 0.35) return easeBackOut(dt / 0.35);
  return 1;
}

// ── Actor x position ─────────────────────────────────────────────────────────
const POSES = RIG.pose_presets;

function actorXAt(actorName, t) {
  if (actorName === "coin") return coinXAt(t);
  const actor = RESOLVED.actors[actorName];
  if (!actor) return 0;
  let x = actor.x0;
  for (const pl of actor.places) {
    if (pl.t <= t) x = pl.x;
  }
  for (const mv of actor.moves) {
    if (mv.t1 <= t) {
      x = mv.to;
    } else if (mv.t0 <= t) {
      const dur = mv.t1 - mv.t0;
      const frac = dur > 0 ? clamp((t - mv.t0)/dur, 0, 1) : 1;
      const fromX = actorXAtBefore(actor, mv);
      x = lerp(fromX, mv.to, frac);
    }
  }
  return x;
}

function actorXAtBefore(actor, targetMove) {
  let x = actor.x0;
  for (const pl of actor.places) {
    if (pl.t < targetMove.t0) x = pl.x;
  }
  for (const mv of actor.moves) {
    if (mv === targetMove) break;
    if (mv.t1 <= targetMove.t0) x = mv.to;
  }
  return x;
}

function actorFacingAt(actor, t) {
  let facing = 1;
  for (const mv of actor.moves) {
    if (mv.t0 <= t) {
      const fromX = actorXAtBefore(actor, mv);
      const dx = mv.to - fromX;
      if (Math.abs(dx) > 1) facing = dx > 0 ? 1 : -1;
    }
  }
  return facing;
}

// ── Coin position ─────────────────────────────────────────────────────────────
function coinHandPos(carrierName, t) {
  const actor = RESOLVED.actors[carrierName];
  const cx = actorXAt(carrierName, t);
  const facing = actorFacingAt(actor, t);
  const hy = GROUND_Y - 45;
  return [cx + facing * 70, hy];
}

function coinXAt(t) {
  const segs = RESOLVED.items.coin.segments;
  let seg = null;
  for (const s of segs) {
    if (s.t0 <= t && t <= s.t1) { seg = s; break; }
  }
  if (!seg) {
    const last = segs[segs.length-1];
    if (last.mode === "static") return last.x;
    if (last.mode === "hops") return last.to;
    return 0;
  }
  const dur = seg.t1 - seg.t0;
  const u = dur > 0 ? clamp((t - seg.t0)/dur, 0, 1) : 1;
  switch (seg.mode) {
    case "carried": { const [cx] = coinHandPos(seg.carrier, t); return cx; }
    case "arc": {
      const fx = seg.from_x !== undefined ? seg.from_x : coinHandPos(seg.from_carrier, seg.t0)[0];
      const tx = seg.to_carrier ? coinHandPos(seg.to_carrier, seg.t1)[0] : (seg.to_x||0);
      return lerp(fx, tx, u);
    }
    case "orbit": {
      const angle = -Math.PI/2 + seg.loops * 2*Math.PI * u;
      return seg.center_x + seg.radius * Math.cos(angle);
    }
    case "hops": return lerp(seg.from, seg.to, u);
    case "shrink": {
      if (seg.carrier) { const [cx] = coinHandPos(seg.carrier, t); return cx; }
      return 0;
    }
    case "hidden": return -9999;
    case "static": return seg.x;
  }
  return 0;
}

function coinStateAt(t) {
  const segs = RESOLVED.items.coin.segments;
  let seg = null;
  for (const s of segs) {
    if (s.t0 <= t && t <= s.t1) { seg = s; break; }
  }

  let bounceOff = 0;
  const bounces = RESOLVED.items.coin.bounces || [];
  for (const b of bounces) {
    const hopDur = 0.45;
    const totalDur = b.times * hopDur;
    if (t >= b.t && t <= b.t + totalDur) {
      const phase = ((t - b.t) % hopDur) / hopDur;
      bounceOff = -b.height * Math.sin(Math.PI * phase);
    }
  }

  if (!seg) {
    const last = segs[segs.length-1];
    let lx = 0;
    if (last.mode === "static") lx = last.x;
    else if (last.mode === "hops") lx = last.to;
    return { x: lx, y: GROUND_Y + bounceOff, scale: 1, opacity: 1 };
  }

  const dur = seg.t1 - seg.t0;
  const u = dur > 0 ? clamp((t - seg.t0)/dur, 0, 1) : 1;

  switch (seg.mode) {
    case "carried": {
      const [cx, cy] = coinHandPos(seg.carrier, t);
      return { x: cx, y: cy + bounceOff, scale: 1, opacity: 1 };
    }
    case "arc": {
      const fx = seg.from_x !== undefined ? seg.from_x : coinHandPos(seg.from_carrier||"", seg.t0)[0];
      const fy = seg.from_x !== undefined ? (GROUND_Y - 45) : coinHandPos(seg.from_carrier||"", seg.t0)[1];
      const tx = seg.to_carrier ? coinHandPos(seg.to_carrier, seg.t1)[0] : (seg.to_x||0);
      const ty = seg.to_carrier ? coinHandPos(seg.to_carrier, seg.t1)[1] : (GROUND_Y - 45);
      const mx = lerp(fx, tx, u);
      const arcH = 250;
      const arcY = lerp(fy, ty, u) - arcH * 4 * u * (1-u);
      return { x: mx, y: arcY + bounceOff, scale: 1, opacity: 1 };
    }
    case "orbit": {
      const angle = -Math.PI/2 + seg.loops * 2*Math.PI * u;
      const cx = seg.center_x + seg.radius * Math.cos(angle);
      const cy = -350 + seg.radius * Math.sin(angle);
      return { x: cx, y: cy + bounceOff, scale: 1, opacity: 1 };
    }
    case "hops": {
      const hx = lerp(seg.from, seg.to, u);
      const hopDur = 0.45;
      const phase = ((t - seg.t0) % hopDur) / hopDur;
      const hopHeight = 120;
      const hy = GROUND_Y - hopHeight * Math.sin(Math.PI * phase);
      return { x: hx, y: hy + bounceOff, scale: 1, opacity: 1 };
    }
    case "shrink": {
      const scale = 1 - u;
      if (seg.carrier) {
        const [cx, cy] = coinHandPos(seg.carrier, t);
        return { x: cx, y: cy, scale, opacity: scale };
      }
      return { x: 0, y: GROUND_Y, scale, opacity: scale };
    }
    case "hidden":
      return { x: -9999, y: GROUND_Y, scale: 0, opacity: 0 };
    case "static":
      return { x: seg.x, y: GROUND_Y + bounceOff, scale: 1, opacity: 1 };
  }
  return { x: 0, y: GROUND_Y, scale: 1, opacity: 1 };
}

// ── Gesture joint overrides ───────────────────────────────────────────────────
// Returns delta joints to ADD to base pose joints
function gestureJoints(name, actor, t) {
  const delta = {};
  const gestures = actor.gestures || [];
  for (const g of gestures) {
    if (g.name !== name) continue;
    // already filtered by name in caller
    const dt = t - g.t;
    const dur = g.dur || 0.9;
    if (dt < 0 || dt > dur + 0.4) continue;
    return _gestureJointsForOne(g, dt, dur);
  }
  return delta;
}

function _gestureJointsForOne(g, dt, dur) {
  const delta = {};
  switch (g.name) {
    case "wave": {
      // arm raises to -140° then oscillates ±15° 3 times
      const liftDur = 0.15;
      const waveDur = dur; // 0.9s
      const dropDur = 0.25;
      if (dt < liftDur) {
        const u = dt / liftDur;
        delta.armR_shoulder = lerp(0, -140, easeOut(u));
      } else if (dt < liftDur + waveDur) {
        const phase = (dt - liftDur) / waveDur * 3 * 2 * Math.PI;
        delta.armR_shoulder = -140 + 15 * Math.sin(phase);
        delta.armR_elbow = -20 * Math.abs(Math.sin(phase * 0.5));
      } else {
        const u = clamp((dt - liftDur - waveDur) / dropDur, 0, 1);
        delta.armR_shoulder = lerp(-140, 0, easeOut(u));
      }
      break;
    }
    case "point": {
      // arm raises to ~-90° in direction dir, holds, then drops
      const dir = g.dir || 1;
      const side = dir > 0 ? "armR" : "armL";
      const liftDur = 0.2;
      const holdDur = dur * 0.6;
      const dropDur = 0.25;
      const targetAngle = dir > 0 ? -90 : 90;
      if (dt < liftDur) {
        const u = dt / liftDur;
        delta[side + "_shoulder"] = lerp(0, targetAngle, easeOut(u));
        delta[side + "_elbow"] = lerp(0, -10 * dir, easeOut(u));
      } else if (dt < liftDur + holdDur) {
        delta[side + "_shoulder"] = targetAngle;
        delta[side + "_elbow"] = -10 * dir;
      } else {
        const u = clamp((dt - liftDur - holdDur) / dropDur, 0, 1);
        delta[side + "_shoulder"] = lerp(targetAngle, 0, easeOut(u));
        delta[side + "_elbow"] = lerp(-10 * dir, 0, easeOut(u));
      }
      break;
    }
    case "nod": {
      // head +12° × 2 nods
      const period = dur / 2;
      if (dt < dur) {
        const phase = (dt / period) * Math.PI * 2;
        delta.head = 12 * Math.abs(Math.sin(phase));
      }
      break;
    }
    case "shake_head": {
      // head ±10° × 3
      const period = dur / 3;
      if (dt < dur) {
        const phase = (dt / period) * Math.PI * 2;
        delta.head = 10 * Math.sin(phase);
      }
      break;
    }
    case "jump": {
      // vertical handled by bounce; joints: scaleY compress/stretch
      // We output a jumpScaleY override for the body
      const height = g.height || 160;
      const airDur = dur * 0.55;
      const landDur = dur * 0.25;
      const squatDur = dur * 0.2;
      if (dt < squatDur) {
        // pre-jump squat: legL/R knee bend
        const u = dt / squatDur;
        delta.legL_knee = lerp(0, 25, u);
        delta.legR_knee = lerp(0, 25, u);
        delta._scaleY = lerp(1, 0.92, u);
      } else if (dt < squatDur + airDur) {
        // airborne: stretch
        const u2 = (dt - squatDur) / airDur;
        delta.legL_hip = lerp(0, -20, Math.sin(u2 * Math.PI));
        delta.legR_hip = lerp(0, -20, Math.sin(u2 * Math.PI));
        delta._scaleY = lerp(0.92, 1.06, easeOut(u2 * 0.5));
        if (u2 > 0.5) delta._scaleY = lerp(1.06, 1.0, easeOut((u2 - 0.5) * 2));
      } else {
        // landing: squat
        const u3 = clamp((dt - squatDur - airDur) / landDur, 0, 1);
        delta.legL_knee = lerp(0, 30, Math.sin(u3 * Math.PI));
        delta.legR_knee = lerp(0, 30, Math.sin(u3 * Math.PI));
        delta._scaleY = lerp(1, 0.88, Math.sin(u3 * Math.PI));
      }
      break;
    }
    case "spin": {
      // root group 360° rotation
      if (dt < dur) {
        const u = easeInOut(dt / dur);
        delta._rootRotate = u * 360;
      }
      break;
    }
  }
  return delta;
}

// ── give auto-arm (inferred from arc segments) ────────────────────────────────
function giveArmDelta(actor, actorName, t) {
  const delta = {};
  const segs = RESOLVED.items.coin.segments;
  for (const seg of segs) {
    if (seg.mode !== "arc") continue;
    // giver: from_carrier = actorName
    if (seg.from_carrier === actorName) {
      const dt = t - seg.t0;
      const dur = seg.t1 - seg.t0;
      if (dt >= -0.1 && dt <= dur + 0.3) {
        // raise arm 35° toward target
        const u = dt < 0 ? 0 : dt < dur ? dt / dur : clamp(1 - (dt - dur) / 0.3, 0, 1);
        const targetCarrier = RESOLVED.actors[seg.to_carrier];
        let dir = 1;
        if (targetCarrier) {
          const txAt = actorXAt(seg.to_carrier, seg.t0);
          const mxAt = actorXAt(actorName, seg.t0);
          dir = txAt > mxAt ? 1 : -1;
        }
        // adjust for facing
        const facing = actorFacingAt(actor, t);
        const side = (dir * facing > 0) ? "armR" : "armL";
        delta[side + "_shoulder"] = lerp(0, -35, easeOut(u));
      }
    }
    // receiver: to_carrier = actorName
    if (seg.to_carrier === actorName) {
      const dt = t - seg.t0;
      const dur = seg.t1 - seg.t0;
      const receiveAt = seg.t1;
      const rddt = t - receiveAt;
      if (rddt >= -0.2 && rddt <= 0.3) {
        const u2 = rddt < 0 ? clamp((rddt + 0.2) / 0.2, 0, 1) : clamp(1 - rddt / 0.3, 0, 1);
        const fromCarrier = RESOLVED.actors[seg.from_carrier];
        let dir2 = 1;
        if (fromCarrier) {
          const fxAt = actorXAt(seg.from_carrier, seg.t1);
          const mxAt = actorXAt(actorName, seg.t1);
          dir2 = fxAt > mxAt ? 1 : -1;
        }
        const facing = actorFacingAt(actor, t);
        const side = (dir2 * facing > 0) ? "armR" : "armL";
        delta[side + "_shoulder"] = lerp(0, -25, easeOut(u2));
      }
    }
  }
  return delta;
}

// ── Gait v2 ────────────────────────────────────────────────────────────────────
// Returns joints + bounceY for walking/running
function gaitJoints(inMove, t) {
  const gait = inMove.gait || "walk";
  const period = gait === "run" ? 0.25 : 0.4;
  const amp    = gait === "run" ? 1.2 : 1.0;
  const lean   = gait === "run" ? -8 : -3;  // lean forward (negative = lean forward)

  const phase = ((t - inMove.t0) % period) / period * 2 * Math.PI;

  // step cycle
  const hipAngle  = 22 * amp * Math.sin(phase);
  const kneeAng   = 15 * amp * Math.max(0, Math.sin(phase - 0.3));
  const kneeAngR  = 15 * amp * Math.max(0, Math.sin(phase + Math.PI - 0.3));
  const armAngle  = 25 * amp * Math.sin(phase + Math.PI);

  // vertical bob: 2× step frequency, ±8
  const bobFreq = 2;
  const bob = -8 * Math.abs(Math.sin(phase * bobFreq * 0.5));  // always negative (up)

  const joints = {
    legL_hip:    hipAngle,
    legL_knee:   Math.max(0, kneeAng),
    legR_hip:   -hipAngle,
    legR_knee:   Math.max(0, kneeAngR),
    armL_shoulder: -armAngle,
    armR_shoulder:  armAngle,
    armL_elbow: -15 * amp * Math.sin(phase + Math.PI),
    armR_elbow:  15 * amp * Math.sin(phase),
    body: lean,
    head: lean,
  };
  return { joints, bob };
}

// Stop-blend: 0.25s mix back to idle after move ends
function stopBlendJoints(actor, name, t) {
  const idleJ = Object.assign({}, POSES.idle);
  // find last finished move
  let lastMove = null;
  for (const mv of (actor.moves || [])) {
    if (mv.t1 <= t) lastMove = mv;
  }
  if (!lastMove) return null;
  const stopDt = t - lastMove.t1;
  if (stopDt > 0.25) return null;  // already settled

  // gait at very end of move
  const { joints: gj, bob } = gaitJoints(lastMove, lastMove.t1 - 0.01);
  const u = easeOut(stopDt / 0.25);
  const blended = {};
  for (const k of Object.keys(idleJ)) {
    blended[k] = lerp(gj[k] !== undefined ? gj[k] : 0, idleJ[k] || 0, u);
  }
  // head overshoot: lean forward 4° then recover
  const headOvShoot = 4 * (1 - easeOut(stopDt / 0.25)) * Math.sign(lastMove.gait === "run" ? -1 : -1);
  blended.head = (blended.head || 0) + headOvShoot * (1 - u);
  return { joints: blended, bob: bob * (1 - u) };
}

// ── Actor state evaluator ─────────────────────────────────────────────────────
function actorStateAt(name, actor, t) {
  const worldX = actorXAt(name, t);
  const facing = actorFacingAt(actor, t);

  // visibility
  let visible = !actor.hidden0;
  for (const v of (actor.vis || [])) {
    if (v.t <= t) visible = v.on;
  }

  // face
  let face = actor.face0 || "calm";
  for (const f of (actor.faces || [])) {
    if (f.t <= t) face = f.face;
  }

  // ── Joints: start from idle ──
  let joints = Object.assign({}, POSES.idle);

  // find active move → gait v2
  let inMove = null;
  for (const mv of (actor.moves || [])) {
    if (mv.t0 <= t && t <= mv.t1) { inMove = mv; break; }
  }

  let gaitBob = 0;
  let rootRotate = 0;
  let bodyScaleYOverride = null;

  if (inMove) {
    const { joints: gj, bob } = gaitJoints(inMove, t);
    Object.assign(joints, gj);
    gaitBob = bob;
  } else {
    // stop blend
    const sb = stopBlendJoints(actor, name, t);
    if (sb) {
      Object.assign(joints, sb.joints);
      gaitBob = sb.bob;
    } else {
      // static pose blend
      let latestPose = null;
      let latestPoseT = -Infinity;
      for (const p of (actor.poses || [])) {
        if (p.t <= t && p.t > latestPoseT) {
          latestPose = p;
          latestPoseT = p.t;
        }
      }
      if (latestPose) {
        const preset = POSES[latestPose.preset] || POSES.idle;
        const bl = clamp(poseBlend(t, latestPose.t), -0.5, 1);
        if (bl < 0) {
          for (const j in joints) {
            const tgt = preset[j] !== undefined ? preset[j] : 0;
            joints[j] = tgt * (0.5 + bl);
          }
        } else {
          for (const j in joints) {
            const idle = POSES.idle[j] !== undefined ? POSES.idle[j] : 0;
            const tgt  = preset[j] !== undefined ? preset[j] : 0;
            joints[j] = lerp(idle, tgt, bl);
          }
        }
      }
    }
  }

  // ── Gestures overlay ──────────────────────────────────────────────────────
  // Apply gestures on top of current joints
  for (const g of (actor.gestures || [])) {
    const dt = t - g.t;
    const dur = g.dur || 0.9;
    if (dt < -0.05 || dt > dur + 0.5) continue;
    const gDelta = _gestureJointsForOne(g, Math.max(0, dt), dur);
    for (const [k, v] of Object.entries(gDelta)) {
      if (k === "_rootRotate") {
        rootRotate = v;
      } else if (k === "_scaleY") {
        bodyScaleYOverride = v;
      } else {
        // For joint angles: replace (gesture overrides pose)
        joints[k] = v;
      }
    }
  }

  // ── give auto-arm ─────────────────────────────────────────────────────────
  const giveDelta = giveArmDelta(actor, name, t);
  for (const [k, v] of Object.entries(giveDelta)) {
    // only override if gesture is not already overriding
    if (joints[k] === undefined || Math.abs(v) > Math.abs(joints[k] || 0)) {
      joints[k] = v;
    }
  }

  // bounces: vertical offset (also handles jump gesture vertical)
  let bounceY = 0;
  for (const b of (actor.bounces || [])) {
    const hopDur = 0.45;
    const totalDur = b.times * hopDur;
    if (t >= b.t && t <= b.t + totalDur) {
      const phase = ((t - b.t) % hopDur) / hopDur;
      bounceY = -b.height * Math.sin(Math.PI * phase);
    }
  }

  // jump gesture vertical
  for (const g of (actor.gestures || [])) {
    if (g.name !== "jump") continue;
    const dt = t - g.t;
    const dur = g.dur || 0.7;
    if (dt < 0 || dt > dur) continue;
    const height = g.height || 160;
    const squatDur = dur * 0.2;
    const airDur   = dur * 0.55;
    if (dt >= squatDur && dt < squatDur + airDur) {
      const u = (dt - squatDur) / airDur;
      bounceY = Math.min(bounceY, -height * Math.sin(u * Math.PI));
    }
  }

  // breathing
  const breathScale = 1 + 0.012 * Math.sin(2 * Math.PI * t / 2.0);
  const headSway = 2 * Math.sin(2 * Math.PI * t / 1.7);

  // jump body scale (from gesture)
  const finalBodyScale = bodyScaleYOverride !== null
    ? bodyScaleYOverride
    : breathScale;

  return {
    worldX, facing, visible, face, joints,
    bounceY: bounceY + gaitBob,
    breathScale: finalBodyScale,
    headSway,
    rootRotate
  };
}

// ── Prop state evaluator ──────────────────────────────────────────────────────
function propStateAt(prop, t) {
  let opacity = prop.hidden ? 0 : 1;
  let scale = 1;
  let closed = false;

  let lastShowHide = null;
  let growEv = null;

  for (const ev of (prop.events || [])) {
    if (ev.t > t) continue;
    if (ev.kind === "show" || ev.kind === "hide") lastShowHide = ev;
    if (ev.kind === "close") closed = true;
    if (ev.kind === "open")  closed = false;
    if (ev.kind === "grow")  growEv = ev;
  }

  if (lastShowHide) {
    const dt = t - lastShowHide.t;
    if (lastShowHide.kind === "show") opacity = clamp(dt / 0.3, 0, 1);
    else opacity = 1 - clamp(dt / 0.3, 0, 1);
  } else {
    opacity = prop.hidden ? 0 : 1;
  }

  if (growEv) {
    const dur = growEv.t1 - growEv.t;
    const u = dur > 0 ? clamp((t - growEv.t) / dur, 0, 1) : 1;
    const gFrom = growEv.from !== undefined ? growEv.from : 1;
    const gTo   = growEv.to   !== undefined ? growEv.to   : 1;
    scale = lerp(gFrom, gTo, easeBackOut(u, 1.5));
  }

  return { opacity, scale, closed };
}

// ── Shadow helpers ────────────────────────────────────────────────────────────
// Each actor + prop gets an ellipse shadow
const shadowLayer = document.getElementById("shadows_layer");
const shadowElems = {};

function ensureShadow(id) {
  if (shadowElems[id]) return shadowElems[id];
  const el = svgEl("ellipse", {
    rx: 60, ry: 16, fill: "#1A1A1A",
    opacity: 0, filter: "url(#f_shadow)"
  });
  shadowLayer.appendChild(el);
  shadowElems[id] = el;
  return el;
}

function updateShadow(id, sx, sy_ground, zoom, extraOpacity, yOffset) {
  const el = ensureShadow(id);
  // shadow at ground level (sy_ground = screen y at ground)
  el.setAttribute("cx", sx.toFixed(1));
  el.setAttribute("cy", (sy_ground).toFixed(1));
  el.setAttribute("rx", (55 * zoom).toFixed(1));
  el.setAttribute("ry", (13 * zoom).toFixed(1));
  el.setAttribute("opacity", (0.08 * extraOpacity).toFixed(3));
}

// ── Build SVG scene ───────────────────────────────────────────────────────────
const worldG = document.getElementById("world_g");
const groundLine = document.getElementById("ground_line");

// v4 palette (from assets/props.json palette_v4 + classic)
const PALETTE_V4 = {
  white:   "#FFFFFF",
  shade:   "#E9E2D5",
  accent:  "#E8A33D",
  sky_top: "#FBF8F2",
  sky_bottom: "#EFE8DC",
  cloud:   "#F6F2EA",
  ink:     "#1A1A1A",
  red:     "#D4231C",
  paper:   "#FBF8F2",
  none:    "none"
};
function hexColor(name) {
  if (!name) return "#1A1A1A";
  if (name === "none") return "none";
  return PALETTE_V4[name] || name;
}

function buildPropSVG(prop) {
  const def = PROPS_DEF[prop.type];
  if (!def) return null;
  const g = svgG({ id: "prop_" + prop.id, class: "prop-root" });

  for (const prim of def.primitives) {
    if (prim.kind && prim.kind.startsWith("_")) continue; // skip meta keys
    let el;
    // v4 per-primitive schema: stroke/fill are palette keys or "none"
    const sw = prim.stroke_width !== undefined ? prim.stroke_width : 14;
    // stroke: explicit "none" → no stroke; otherwise use palette
    const strokeRaw = prim.stroke !== undefined ? prim.stroke : "ink";
    const stroke = strokeRaw === "none" ? "none" : hexColor(strokeRaw);
    // fill: for open polylines use "none"; closed use fill key; circle/other use fill key
    const fillRaw = prim.fill !== undefined ? prim.fill : "none";
    const fill = fillRaw === "none" ? "none" : hexColor(fillRaw);

    if (prim.kind === "polyline") {
      el = svgEl("polyline", {
        points: prim.pts.map(p=>p.join(",")).join(" "),
        stroke: stroke === "none" ? "none" : stroke,
        "stroke-width": stroke === "none" ? 0 : sw,
        "stroke-linecap": "round",
        fill: prim.closed ? fill : "none",
        "stroke-linejoin": "round"
      });
      if (prim._id === "sign") el.setAttribute("id", "prop_" + prop.id + "_sign");
    } else if (prim.kind === "circle") {
      el = svgEl("circle", {
        cx: prim.c[0], cy: prim.c[1], r: prim.r,
        stroke: stroke === "none" ? "none" : stroke,
        "stroke-width": stroke === "none" ? 0 : sw,
        fill
      });
    } else if (prim.kind === "text") {
      el = svgEl("text", {
        x: prim.pos[0], y: prim.pos[1],
        "text-anchor": "middle",
        "dominant-baseline": "central",
        "font-size": prim.size,
        "font-family": '"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei","PingFang SC",sans-serif',
        "font-weight": "700",
        fill: prim.color ? hexColor(prim.color) : "#1A1A1A",
        stroke: "none"
      });
      let txt = prim.str;
      if (txt === "{label}") txt = prop.label || "";
      el.textContent = txt;
      if (prim._id === "sign_text") el.setAttribute("id", "prop_" + prop.id + "_sign_text");
    }

    if (el) {
      if (prim._id === "sign") {
        const signG = svgG({ id: "prop_" + prop.id + "_sign_group" });
        signG.appendChild(el);
        g.appendChild(signG);
        continue;
      }
      g.appendChild(el);
    }
  }

  const signGroup = g.querySelector("#prop_" + prop.id + "_sign_group");
  const signTextEl = g.querySelector("#prop_" + prop.id + "_sign_text");
  if (signGroup && signTextEl && !signGroup.contains(signTextEl)) {
    signGroup.appendChild(signTextEl);
  }

  worldG.appendChild(g);
  return g;
}

function buildActorSVG(name) {
  const g = svgG({ id: "actor_" + name });
  const gBody = svgG({ id: "actor_" + name + "_body" });
  const INK = "#1A1A1A";
  const SKIN = "#FFFFFF";

  // neck line (v4 detail)
  gBody.appendChild(svgEl("line", {
    x1:0, y1:-145, x2:0, y2:-158,
    stroke:INK, "stroke-width":12, "stroke-linecap":"round"
  }));
  // torso
  gBody.appendChild(svgEl("line", {
    x1:0, y1:-145, x2:0, y2:30,
    stroke:INK, "stroke-width":18, "stroke-linecap":"round"
  }));

  // head group
  const gHead = svgG({ id: "actor_" + name + "_head" });
  const gHeadSway = svgG({ id: "actor_" + name + "_headSway" });
  gHeadSway.appendChild(svgEl("circle", {
    cx:0, cy:-200, r:55, fill:SKIN, stroke:INK, "stroke-width":18
  }));

  // v4 upgraded face expressions with eyebrows
  const faceData = {
    calm: `<!-- calm eyebrows: slight arch -->
      <path d="M -26 -228 Q -18 -234 -10 -228" stroke="${INK}" stroke-width="5" fill="none" stroke-linecap="round"/>
      <path d="M 10 -228 Q 18 -234 26 -228" stroke="${INK}" stroke-width="5" fill="none" stroke-linecap="round"/>
      <circle cx="-18" cy="-212" r="5.5" fill="${INK}"/>
      <circle cx="18" cy="-212" r="5.5" fill="${INK}"/>
      <path d="M -20 -182 Q 0 -166 20 -182" stroke="${INK}" stroke-width="7" fill="none" stroke-linecap="round"/>`,
    sad: `<!-- sad eyebrows: angled down-center, teardrop -->
      <path d="M -28 -232 L -10 -225" stroke="${INK}" stroke-width="5" fill="none" stroke-linecap="round"/>
      <path d="M 28 -232 L 10 -225" stroke="${INK}" stroke-width="5" fill="none" stroke-linecap="round"/>
      <circle cx="-18" cy="-212" r="5.5" fill="${INK}"/>
      <circle cx="18" cy="-212" r="5.5" fill="${INK}"/>
      <path d="M -16 -170 Q 0 -186 16 -170" stroke="${INK}" stroke-width="7" fill="none" stroke-linecap="round"/>
      <!-- teardrop -->
      <ellipse cx="-22" cy="-197" rx="4" ry="6" fill="#A8C8E8" stroke="none" opacity="0.85"/>`,
    angry: `<!-- angry eyebrows: inward frown V-shape, extra frown lines -->
      <path d="M -30 -238 L -10 -226" stroke="${INK}" stroke-width="6.5" fill="none" stroke-linecap="round"/>
      <path d="M 30 -238 L 10 -226" stroke="${INK}" stroke-width="6.5" fill="none" stroke-linecap="round"/>
      <!-- inner brow crease lines -->
      <path d="M -14 -228 L -8 -222" stroke="${INK}" stroke-width="4" fill="none" stroke-linecap="round"/>
      <path d="M 14 -228 L 8 -222" stroke="${INK}" stroke-width="4" fill="none" stroke-linecap="round"/>
      <circle cx="-18" cy="-211" r="5.5" fill="${INK}"/>
      <circle cx="18" cy="-211" r="5.5" fill="${INK}"/>
      <path d="M -16 -170 Q 0 -186 16 -170" stroke="${INK}" stroke-width="7" fill="none" stroke-linecap="round"/>`,
    surprised: `<!-- surprised eyebrows: high arched -->
      <path d="M -30 -240 Q -18 -248 -6 -240" stroke="${INK}" stroke-width="6" fill="none" stroke-linecap="round"/>
      <path d="M 6 -240 Q 18 -248 30 -240" stroke="${INK}" stroke-width="6" fill="none" stroke-linecap="round"/>
      <circle cx="-18" cy="-212" r="8.5" stroke="${INK}" stroke-width="6" fill="none"/>
      <circle cx="18" cy="-212" r="8.5" stroke="${INK}" stroke-width="6" fill="none"/>
      <ellipse cx="0" cy="-175" rx="9" ry="13" stroke="${INK}" stroke-width="6" fill="none"/>`
  };
  for (const [fn, fhtml] of Object.entries(faceData)) {
    const fg = svgG({ id: "actor_" + name + "_face_" + fn });
    fg.innerHTML = fhtml;
    gHeadSway.appendChild(fg);
  }
  gHead.appendChild(gHeadSway);
  gBody.appendChild(gHead);

  // arms (v4: hand = filled circle r9 at arm tip)
  for (const a of [
    { id:"armL", ux1:0,uy1:-100, ux2:-40,uy2:-60, fx1:-40,fy1:-60, fx2:-72,fy2:-15, foreId:"foreL", hx:-72, hy:-15 },
    { id:"armR", ux1:0,uy1:-100, ux2:40, uy2:-60, fx1:40, fy1:-60, fx2:72, fy2:-15, foreId:"foreR", hx:72,  hy:-15 },
  ]) {
    const gArm = svgG({ id: "actor_" + name + "_" + a.id });
    gArm.appendChild(svgEl("line", { x1:a.ux1,y1:a.uy1,x2:a.ux2,y2:a.uy2, stroke:INK,"stroke-width":18,"stroke-linecap":"round" }));
    const gFore = svgG({ id: "actor_" + name + "_" + a.foreId });
    gFore.appendChild(svgEl("line", { x1:a.fx1,y1:a.fy1,x2:a.fx2,y2:a.fy2, stroke:INK,"stroke-width":18,"stroke-linecap":"round" }));
    // v4: hand circle
    gFore.appendChild(svgEl("circle", { cx:a.hx, cy:a.hy, r:9, fill:INK, stroke:"none" }));
    gArm.appendChild(gFore);
    gBody.appendChild(gArm);
  }

  // legs (v4: foot = short horizontal line 26 at shin bottom)
  for (const l of [
    { id:"legL", tx1:0,ty1:30, tx2:-22,ty2:115, sx1:-22,sy1:115, sx2:-45,sy2:200, shinId:"shinL", fx:-45,fy:200 },
    { id:"legR", tx1:0,ty1:30, tx2:22, ty2:115, sx1:22, sy1:115, sx2:45, sy2:200, shinId:"shinR", fx:45,  fy:200 },
  ]) {
    const gLeg = svgG({ id: "actor_" + name + "_" + l.id });
    gLeg.appendChild(svgEl("line", { x1:l.tx1,y1:l.ty1,x2:l.tx2,y2:l.ty2, stroke:INK,"stroke-width":18,"stroke-linecap":"round" }));
    const gShin = svgG({ id: "actor_" + name + "_" + l.shinId });
    gShin.appendChild(svgEl("line", { x1:l.sx1,y1:l.sy1,x2:l.sx2,y2:l.sy2, stroke:INK,"stroke-width":18,"stroke-linecap":"round" }));
    // v4: foot horizontal line (length 26, centered at shin tip)
    gShin.appendChild(svgEl("line", { x1:l.fx-13, y1:l.fy, x2:l.fx+13, y2:l.fy, stroke:INK,"stroke-width":10,"stroke-linecap":"round" }));
    gLeg.appendChild(gShin);
    gBody.appendChild(gLeg);
  }

  g.appendChild(gBody);
  worldG.appendChild(g);
  return g;
}

function buildCoinSVG() {
  const g = svgG({ id: "coin_g" });
  g.appendChild(svgEl("circle", { cx:0,cy:0,r:45, fill:"#FDFAF5",stroke:"#D4231C","stroke-width":12 }));
  const t2 = svgEl("text", {
    x:0, y:22, "text-anchor":"middle", "font-size":60,
    "font-family":'"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei","PingFang SC",sans-serif',
    "font-weight":"700", fill:"#D4231C", stroke:"none"
  });
  t2.textContent = "¥";
  g.appendChild(t2);
  worldG.appendChild(g);
  return g;
}

// ── Init DOM ──────────────────────────────────────────────────────────────────
const propElems = {};
for (const prop of RESOLVED.props) {
  propElems[prop.id] = buildPropSVG(prop);
}
const actorElems = {};
for (const name of Object.keys(RESOLVED.actors)) {
  actorElems[name] = buildActorSVG(name);
}
const coinElem = buildCoinSVG();

// ── Joint pivot init ──────────────────────────────────────────────────────────
function initJointPivots() {
  const jMap = {
    body:          { suffix:"_body",     origin:"0 -145"  },
    head:          { suffix:"_head",     origin:"0 -145"  },
    headSway:      { suffix:"_headSway", origin:"0 -145"  },
    armL_shoulder: { suffix:"_armL",     origin:"0 -100"  },
    armL_elbow:    { suffix:"_foreL",    origin:"-40 -60" },
    armR_shoulder: { suffix:"_armR",     origin:"0 -100"  },
    armR_elbow:    { suffix:"_foreR",    origin:"40 -60"  },
    legL_hip:      { suffix:"_legL",     origin:"0 30"    },
    legL_knee:     { suffix:"_shinL",    origin:"-22 115" },
    legR_hip:      { suffix:"_legR",     origin:"0 30"    },
    legR_knee:     { suffix:"_shinR",    origin:"22 115"  },
  };
  for (const name of Object.keys(RESOLVED.actors)) {
    for (const [jk, jd] of Object.entries(jMap)) {
      const el = document.getElementById("actor_" + name + jd.suffix);
      if (el) gsap.set(el, { svgOrigin: jd.origin, rotation: 0 });
    }
  }
}

// ── FX system (screen-space, deterministic) ───────────────────────────────────
// All FX are purely deterministic: same t → same result.
// We store pre-generated particle params seeded by fx index.

// Seeded pseudo-random (LCG, deterministic)
function seededRand(seed) {
  let s = seed;
  return function() {
    s = (s * 1664525 + 1013904223) & 0xffffffff;
    return (s >>> 0) / 0xffffffff;
  };
}

// FX particle store: each fx entry gets a pool of pre-generated particle data
const _fxPools = {};

function getFxPool(fxIdx, n, seed) {
  const key = fxIdx + "_" + n;
  if (_fxPools[key]) return _fxPools[key];
  const rng = seededRand(seed + fxIdx * 37);
  const pool = [];
  for (let i = 0; i < n; i++) {
    pool.push({
      r: rng(), r2: rng(), r3: rng(), r4: rng(), r5: rng(),
      r6: rng(), r7: rng(), r8: rng(), r9: rng(), r10: rng()
    });
  }
  _fxPools[key] = pool;
  return pool;
}

// Get screen position for an FX entry
function fxScreenPos(fx, t, camX, camY, zoom) {
  let wx, wy;
  if (fx.actor) {
    const actor = RESOLVED.actors[fx.actor];
    if (!actor) { wx = 960; wy = 500; }
    else {
      wx = actorXAt(fx.actor, t);
      // anchor = "hand" → hand height; default = head top
      const anchor = fx.anchor || "head";
      if (anchor === "hand") {
        wy = GROUND_Y - 45;
      } else {
        wy = GROUND_Y - 260; // head top
      }
    }
  } else if (fx.x !== undefined) {
    wx = fx.x;
    wy = GROUND_Y;
  } else {
    wx = 960; wy = 500;
  }
  return worldToScreen(wx, wy, camX, camY, zoom);
}

// FX container (HTML layer for screen-space elements)
const fxLayer = document.getElementById("fxlayer");

// Pre-allocated pools for FX elements
const _fxDivPools = {};
function getFxDiv(key, i) {
  const pkey = key + "_" + i;
  if (!_fxDivPools[pkey]) {
    const d = document.createElement("div");
    d.style.cssText = "position:absolute;pointer-events:none;";
    fxLayer.appendChild(d);
    _fxDivPools[pkey] = d;
  }
  return _fxDivPools[pkey];
}

// SVG FX layer inside worldG (for world-space FX that need displacement)
const fxSvgLayer = svgG({ id: "fx_svg_layer" });
worldG.appendChild(fxSvgLayer);

const _fxSvgPools = {};
function getFxSvgEl(key, i, tag, attrs) {
  const pkey = key + "_" + i;
  if (!_fxSvgPools[pkey]) {
    const el = svgEl(tag, attrs || {});
    fxSvgLayer.appendChild(el);
    _fxSvgPools[pkey] = el;
  }
  return _fxSvgPools[pkey];
}

// Speed-lines state: persisted per fx key
const _speedLineData = {};

// ambient particle pool (pre-generated once, 12 particles)
const _ambientPool = (function() {
  const rng = (function() {
    let s = 0xABCD1234;
    return function() { s=(s*1664525+1013904223)&0xffffffff; return (s>>>0)/0xffffffff; };
  })();
  return Array.from({length:12}, () => ({
    r:rng(), r2:rng(), r3:rng(), r4:rng(), r5:rng(), r6:rng()
  }));
})();

// shake state
let _shakeActive = false;
let _shakeEndT = 0;
let _shakeAmp = 0;
let _shakeDur = 0.35;
let _shakeStartT = 0;

// Draw all FX at time t
function drawFX(t, camX, camY, zoom) {
  // Hide all reusable FX elements first
  for (const el of Object.values(_fxDivPools)) el.style.display = "none";
  for (const el of Object.values(_fxSvgPools)) el.setAttribute("opacity", "0");

  const fxList = RESOLVED.fx || [];

  // Check ambient (always-on) first
  for (let fi = 0; fi < fxList.length; fi++) {
    if (fxList[fi].kind === "ambient") {
      drawAmbient(fi, fxList[fi], t, camX, camY, zoom);
    }
  }

  for (let fi = 0; fi < fxList.length; fi++) {
    const fx = fxList[fi];
    const kind = fx.kind;
    const fxT = fx.t;
    const dt = t - fxT;
    const fxDur = fx.dur || _fxDefaultDur(kind);

    // range-continuous FX
    if (kind === "speed_lines") {
      const t1 = fx.t1 || fxT + 3;
      if (t < fxT || t > t1) continue;
      drawSpeedLines(fi, fx, t, camX, camY, zoom);
      continue;
    }
    if (kind === "trail") {
      const t1 = fx.t1 || fxT + 1;
      if (t < fxT || t > t1) continue;
      drawTrail(fi, fx, t, camX, camY, zoom);
      continue;
    }
    if (kind === "ambient") continue; // already handled above
    if (kind === "shake") {
      // register shake at trigger point
      if (dt >= 0 && dt < 0.05) {
        _shakeActive = true;
        _shakeStartT = fxT;
        _shakeAmp = fx.amp || 12;
        _shakeDur = fx.dur || 0.35;
        _shakeEndT = fxT + _shakeDur;
      }
      continue;
    }
    if (kind === "flash") {
      drawFlash(fi, fx, dt, fxDur, t);
      continue;
    }

    if (dt < -0.05 || dt > fxDur + 0.1) continue;

    switch (kind) {
      case "dust":        drawDust(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "sparkle":     drawSparkle(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "confetti":    drawConfetti(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "money_rain":  drawMoneyRain(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "shockwave":   drawShockwave(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "emphasis":    drawEmphasis(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "light_rays":  drawLightRays(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
      case "glow_pulse":  drawGlowPulse(fi, fx, dt, fxDur, t, camX, camY, zoom); break;
    }
  }

  // Apply shake to shake_wrapper
  applyShake(t);
}

function _fxDefaultDur(kind) {
  const durs = { dust:0.4, sparkle:0.6, confetti:2.0, money_rain:2.0,
                 shockwave:0.5, emphasis:0.8, speed_lines:1.0,
                 trail:1.0, flash:0.12, shake:0.35,
                 light_rays:3.4, glow_pulse:1.6 };
  return durs[kind] || 0.5;
}

// ─── dust: 2-4 smoke puffs that scatter from a world-x point ────────────────
function drawDust(fi, fx, dt, dur, t, camX, camY, zoom) {
  const small = fx.small;
  const n = small ? 2 : 3;
  const pool = getFxPool(fi, n, 13);
  const baseR = small ? 14 : 22;

  const wx = fx.x !== undefined ? fx.x : actorXAt(fx.actor, t);
  const [bsx, bsy] = worldToScreen(wx, GROUND_Y, camX, camY, zoom);

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const angle = (p.r * 2 - 1) * Math.PI * 0.7 - Math.PI * 0.5;
    const speed = (0.6 + p.r2 * 0.8) * zoom;
    const sx = bsx + Math.cos(angle) * speed * dt * 280;
    const sy = bsy + Math.sin(angle) * speed * dt * 280 - 30 * zoom * dt;
    const u = dt / dur;
    const opacity = (1 - u) * 0.7;
    const r = (baseR + p.r3 * 10) * zoom * (0.5 + u * 0.5);

    const key = "dust_" + fi;
    const el = getFxSvgEl(key, i, "circle", {});
    el.setAttribute("cx", sx.toFixed(1));
    el.setAttribute("cy", sy.toFixed(1));
    el.setAttribute("r", r.toFixed(1));
    el.setAttribute("fill", "#8B7355");
    el.setAttribute("opacity", opacity.toFixed(3));
  }
}

// ─── sparkle: 3-5 four-pointed stars ─────────────────────────────────────────
function drawSparkle(fi, fx, dt, dur, t, camX, camY, zoom) {
  const n = 4;
  const pool = getFxPool(fi, n, 7);
  const [bsx, bsy] = fxScreenPos(fx, t, camX, camY, zoom);

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const angle = p.r * 2 * Math.PI;
    const dist  = (25 + p.r2 * 35) * zoom;
    const u = easeOut(dt / dur);
    const sx = bsx + Math.cos(angle) * dist * u;
    const sy = bsy + Math.sin(angle) * dist * u;
    const starSize = (8 + p.r3 * 8) * zoom * (1 - u * 0.5);
    const opacity = dt < dur * 0.5 ? 1 : 1 - (dt - dur * 0.5) / (dur * 0.5);

    // 4-pointed star as SVG path
    const key = "sparkle_" + fi;
    const el = getFxSvgEl(key, i, "path", {});
    const s = starSize;
    const s2 = s * 0.35;
    const rot = p.r4 * 45;
    el.setAttribute("d", starPath(sx, sy, s, s2, 4, rot));
    el.setAttribute("fill", "#D4231C");
    el.setAttribute("opacity", (opacity * 0.9).toFixed(3));
  }
}

function starPath(cx, cy, outer, inner, n, rotDeg) {
  const step = Math.PI / n;
  const rot = rotDeg * Math.PI / 180;
  let d = "";
  for (let i = 0; i < n * 2; i++) {
    const r = i % 2 === 0 ? outer : inner;
    const angle = i * step + rot - Math.PI / 2;
    const x = cx + r * Math.cos(angle);
    const y = cy + r * Math.sin(angle);
    d += (i === 0 ? "M" : "L") + x.toFixed(1) + "," + y.toFixed(1);
  }
  return d + "Z";
}

// ─── speed_lines: 3 horizontal drag-lines behind actor ───────────────────────
function drawSpeedLines(fi, fx, t, camX, camY, zoom) {
  const actorName = fx.actor;
  const wx = actorXAt(actorName, t);
  const actor = RESOLVED.actors[actorName];
  const facing = actorFacingAt(actor, t);
  const n = 3;
  const pool = getFxPool(fi, n, 5);

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const wy = GROUND_Y - 60 - (p.r * 160); // spread vertically across body
    const lineLen = (80 + p.r2 * 60) * zoom;
    const tailX = wx - facing * (lineLen / (K * zoom)); // world x of tail

    const [headSx, headSy] = worldToScreen(wx, wy, camX, camY, zoom);
    const [tailSx, tailSy] = worldToScreen(tailX, wy, camX, camY, zoom);

    const key = "speed_" + fi;
    const el = getFxSvgEl(key, i, "line", {});
    el.setAttribute("x1", tailSx.toFixed(1));
    el.setAttribute("y1", headSy.toFixed(1));
    el.setAttribute("x2", headSx.toFixed(1));
    el.setAttribute("y2", headSy.toFixed(1));
    el.setAttribute("stroke", "#1A1A1A");
    el.setAttribute("stroke-width", (3 * zoom).toFixed(1));
    el.setAttribute("stroke-linecap", "round");
    el.setAttribute("opacity", "0.45");
  }
}

// ─── confetti: deterministic parabola trajectories ───────────────────────────
function drawConfetti(fi, fx, dt, dur, t, camX, camY, zoom) {
  const n = fx.n || 20;
  const pool = getFxPool(fi, n, 19);
  const wx = fx.x !== undefined ? fx.x : 0;

  const COLORS = ["#D4231C", "#2970D4", "#F5A623"];

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    // deterministic parabola: vx, vy0
    const vx = (p.r - 0.5) * 800;  // world units/s
    const vy0 = -(200 + p.r2 * 350); // initial upward world velocity
    const g = 420; // gravity world units/s²

    // world position at dt
    const pwx = wx + vx * dt;
    const pwy_world = GROUND_Y - 180 + vy0 * dt + 0.5 * g * dt * dt;
    // confetti spins
    const spinAngle = dt * (p.r3 * 3 + 1) * 360 * (p.r4 > 0.5 ? 1 : -1);

    const [svgX, svgY] = worldToScreen(pwx, pwy_world, camX, camY, zoom);
    const [cssX, cssY] = svgToCSS(svgX, svgY);
    const opacity = dt < 0.2 ? dt / 0.2 : (dt > dur - 0.4 ? (1 - (dt - (dur - 0.4)) / 0.4) : 1);
    const stage = document.getElementById("stage");
    const cssScale = stage.offsetWidth / 1920;
    const w = (10 + p.r5 * 8) * zoom * cssScale;
    const h = (5  + p.r6 * 4) * zoom * cssScale;

    const key = "confetti_" + fi;
    const el = getFxDiv(key, i);
    el.style.display = "";
    el.style.left = (cssX - w/2).toFixed(1) + "px";
    el.style.top  = (cssY - h/2).toFixed(1) + "px";
    el.style.width  = w.toFixed(1) + "px";
    el.style.height = h.toFixed(1) + "px";
    el.style.background = COLORS[Math.floor(p.r7 * 3)];
    el.style.opacity = (opacity * 0.85).toFixed(3);
    el.style.transform = `rotate(${spinAngle.toFixed(1)}deg)`;
    el.style.borderRadius = "2px";
  }
}

// ─── money_rain: ¥ coins falling from top ─────────────────────────────────────
function drawMoneyRain(fi, fx, dt, dur, t, camX, camY, zoom) {
  const n = 10;
  const pool = getFxPool(fi, n, 11);
  const wx = fx.x !== undefined ? fx.x : 0;
  const stage = document.getElementById("stage");
  const cssScale = stage.offsetWidth / 1920;

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const startDelay = p.r * dur * 0.6;
    const key = "moneyrain_" + fi;
    const el = getFxDiv(key, i);
    if (dt < startDelay) {
      el.style.display = "none";
      continue;
    }
    const localDt = dt - startDelay;
    const fallDur = Math.max(0.1, dur - startDelay);

    // world position
    const startWx = wx + (p.r2 - 0.5) * 600;
    const startWy = GROUND_Y - 900 - p.r3 * 200;
    const fallSpeed = 350 + p.r4 * 150;
    const sway = 80 * Math.sin(localDt * (1.5 + p.r5) * 2 * Math.PI) * p.r6;

    const pwx = startWx + sway;
    const pwy = startWy + fallSpeed * localDt;
    const [svgX, svgY] = worldToScreen(pwx, pwy, camX, camY, zoom);
    const [cssX, cssY] = svgToCSS(svgX, svgY);

    const progress = localDt / fallDur;
    const opacity = progress < 0.1 ? progress / 0.1 : (progress > 0.8 ? (1 - (progress - 0.8) / 0.2) : 1);
    const r = (14 + p.r7 * 8) * zoom * cssScale;

    el.style.left   = (cssX - r).toFixed(1) + "px";
    el.style.top    = (cssY - r).toFixed(1) + "px";
    el.style.width  = (r*2).toFixed(1) + "px";
    el.style.height = (r*2).toFixed(1) + "px";
    el.style.background = "#FDFAF5";
    el.style.border = `${(2*cssScale).toFixed(1)}px solid #D4231C`;
    el.style.borderRadius = "50%";
    el.style.opacity = (opacity * 0.85).toFixed(3);
    el.style.display = "flex";
    el.style.alignItems = "center";
    el.style.justifyContent = "center";
    el.style.fontSize = (r * 0.9).toFixed(1) + "px";
    el.style.color = "#D4231C";
    el.style.fontFamily = '"LXGW WenKai","LXGWWenKai","霞鹜文楷",sans-serif';
    el.style.fontWeight = "900";
    el.textContent = "¥";
    el.style.boxSizing = "border-box";
  }
}

// ─── shockwave: expanding circle ring ────────────────────────────────────────
function drawShockwave(fi, fx, dt, dur, t, camX, camY, zoom) {
  const [sx, sy] = fxScreenPos(fx, t, camX, camY, zoom);
  const u = dt / dur;
  const r = u * 200 * zoom;
  const opacity = (1 - u) * 0.6;

  const key = "shock_" + fi;
  const el = getFxSvgEl(key, 0, "circle", {});
  el.setAttribute("cx", sx.toFixed(1));
  el.setAttribute("cy", sy.toFixed(1));
  el.setAttribute("r", r.toFixed(1));
  el.setAttribute("fill", "none");
  el.setAttribute("stroke", "#1A1A1A");
  el.setAttribute("stroke-width", (4 * zoom * (1 - u * 0.6)).toFixed(1));
  el.setAttribute("opacity", opacity.toFixed(3));
}

// ─── trail: 3-5 red ghost copies behind flying item ──────────────────────────
function drawTrail(fi, fx, t, camX, camY, zoom) {
  const n = 4; // ghost copies
  const item = fx.item || "coin";
  const t1 = fx.t1 || fx.t + 1;
  // sample item positions in the recent past
  const step = 0.06; // seconds between ghosts
  for (let i = 0; i < n; i++) {
    const pastT = Math.max(fx.t, t - (i + 1) * step);
    let wx, wy;
    if (item === "coin") {
      const cs = coinStateAt(pastT);
      wx = cs.x; wy = cs.y;
      if (cs.opacity <= 0 || cs.x < -8000) { continue; }
    } else {
      wx = actorXAt(item, pastT);
      wy = GROUND_Y - 45;
    }
    const [sx, sy] = worldToScreen(wx, wy, camX, camY, zoom);
    const opacity = (1 - (i + 1) / (n + 1)) * 0.55;
    const r = 38 * zoom * (1 - i * 0.08);
    const key = "trail_" + fi;
    const el = getFxSvgEl(key, i, "circle", {});
    el.setAttribute("cx", sx.toFixed(1));
    el.setAttribute("cy", sy.toFixed(1));
    el.setAttribute("r", r.toFixed(1));
    el.setAttribute("fill", "none");
    el.setAttribute("stroke", "#D4231C");
    el.setAttribute("stroke-width", (3 * zoom).toFixed(1));
    el.setAttribute("opacity", opacity.toFixed(3));
  }
}

// ─── flash: full-screen white 0→0.55→0 over 0.12s ───────────────────────────
function drawFlash(fi, fx, dt, dur, t) {
  const flashEl = document.getElementById("flash_overlay");
  if (!flashEl) return;
  if (dt < 0 || dt > dur) {
    // don't set here; handled by applyShake fallback
    return;
  }
  const u = dt / dur;
  // triangle: 0→peak→0
  const opacity = u < 0.5 ? u * 2 * 0.55 : (1 - u) * 2 * 0.55;
  flashEl.style.opacity = opacity.toFixed(3);
}

// ─── shake: damped sinusoid on #shake_wrapper ─────────────────────────────────
function applyShake(t) {
  const wrapper = document.getElementById("shake_wrapper");
  if (!wrapper) return;
  // reset flash if no flash is active this frame
  const flashEl = document.getElementById("flash_overlay");

  let flashActive = false;
  for (const fx of (RESOLVED.fx || [])) {
    if (fx.kind === "flash") {
      const dt = t - fx.t;
      const dur = fx.dur || 0.12;
      if (dt >= 0 && dt <= dur) { flashActive = true; break; }
    }
  }
  if (!flashActive && flashEl) flashEl.style.opacity = "0";

  if (!_shakeActive || t > _shakeEndT) {
    wrapper.style.transform = "translate(0,0)";
    return;
  }
  const dt2 = t - _shakeStartT;
  const decay = Math.exp(-dt2 * 9);
  const freq = 28;
  const dx = _shakeAmp * decay * Math.sin(freq * dt2);
  const dy = _shakeAmp * 0.4 * decay * Math.sin(freq * dt2 * 1.3 + 1.1);
  wrapper.style.transform = `translate(${dx.toFixed(2)}px,${dy.toFixed(2)}px)`;
}

// ─── ambient: 8-12 slow-drifting light particles, whole-film ─────────────────
function drawAmbient(fi, fx, t, camX, camY, zoom) {
  const n = 10;
  for (let i = 0; i < n; i++) {
    const p = _ambientPool[i];
    // each particle has its own slow period (14-22s) and screen-space path
    const period = 14 + p.r * 8;
    const phase = p.r2 * 2 * Math.PI;
    const baseX = (p.r3 * 1800 + 60); // screen x 60-1860
    const baseY = (p.r4 * 900 + 80);  // screen y 80-980
    const driftX = 40 * Math.sin(2 * Math.PI * t / period + phase);
    const driftY = 25 * Math.cos(2 * Math.PI * t / (period * 0.7) + phase * 1.3);
    const sx = baseX + driftX;
    const sy = baseY + driftY;
    const r = (3 + p.r5 * 4) * zoom;
    const opacity = (0.025 + p.r6 * 0.035); // ≤ 0.06 — 再高在浅底上会读成"黑点"

    const key = "ambient_" + fi;
    const el = getFxSvgEl(key, i, "circle", {});
    el.setAttribute("cx", sx.toFixed(1));
    el.setAttribute("cy", sy.toFixed(1));
    el.setAttribute("r", r.toFixed(1));
    el.setAttribute("fill", "#E8A33D");
    el.setAttribute("opacity", opacity.toFixed(4));
  }
}

// ─── light_rays: 3-4 diagonal accent-color bands from above ──────────────────
function drawLightRays(fi, fx, dt, dur, t, camX, camY, zoom) {
  const n = 3;
  const wx = fx.x !== undefined ? fx.x : 0;
  const [bsx] = worldToScreen(wx, GROUND_Y, camX, camY, zoom);
  // dur = 1.2s expand + 2s hold + 0.8s fade → total ~4s
  const expandDur = 1.2;
  const holdDur   = 2.0;
  const fadeDur   = 0.8;
  const totalDur  = expandDur + holdDur + fadeDur;
  if (dt > totalDur) return;

  let lenFrac, opacity;
  if (dt < expandDur) {
    lenFrac = easeOut(dt / expandDur);
    opacity = dt / expandDur * 0.16;
  } else if (dt < expandDur + holdDur) {
    lenFrac = 1.0;
    opacity = 0.16;
  } else {
    lenFrac = 1.0;
    opacity = (1 - (dt - expandDur - holdDur) / fadeDur) * 0.16;
  }

  const pool = getFxPool(fi, n, 43);
  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const angle = -Math.PI / 2 + (-0.25 + p.r * 0.5); // ≈ straight down with tilt
    const offsetX = (p.r2 - 0.5) * 300 * zoom;
    const rayLen = (500 + p.r3 * 200) * zoom * lenFrac;
    const width  = (40 + p.r4 * 30) * zoom;

    const x1 = bsx + offsetX;
    const y1 = 0; // from top of screen
    const x2 = x1 + Math.sin(angle + Math.PI/2) * rayLen;
    const y2 = y1 + rayLen;

    const key = "lrays_" + fi;
    const el = getFxSvgEl(key, i, "line", {});
    el.setAttribute("x1", x1.toFixed(1));
    el.setAttribute("y1", y1.toFixed(1));
    el.setAttribute("x2", x2.toFixed(1));
    el.setAttribute("y2", y2.toFixed(1));
    el.setAttribute("stroke", "#E8A33D");
    el.setAttribute("stroke-width", width.toFixed(1));
    el.setAttribute("stroke-linecap", "butt");
    el.setAttribute("opacity", opacity.toFixed(4));
  }
}

// ─── glow_pulse: 2 pulsing glow rings at prop position ───────────────────────
function drawGlowPulse(fi, fx, dt, dur, t, camX, camY, zoom) {
  const wx = fx.x !== undefined ? fx.x : 0;
  const [sx, sy] = worldToScreen(wx, GROUND_Y, camX, camY, zoom);
  // 2 pulses in dur (~1.6s)
  for (let pulse = 0; pulse < 2; pulse++) {
    const pulseT = pulse * (dur / 2);
    const pdt = dt - pulseT;
    if (pdt < 0 || pdt > dur / 2) {
      const key = "glow_" + fi;
      const el = getFxSvgEl(key, pulse, "circle", {});
      el.setAttribute("opacity", "0");
      continue;
    }
    const u = pdt / (dur / 2);
    const r = 60 * zoom + u * 120 * zoom;
    const opacity = (1 - u) * 0.35;
    const key = "glow_" + fi;
    const el = getFxSvgEl(key, pulse, "circle", {});
    el.setAttribute("cx", sx.toFixed(1));
    el.setAttribute("cy", (sy - 150 * zoom).toFixed(1));
    el.setAttribute("r", r.toFixed(1));
    el.setAttribute("fill", "#E8A33D");
    el.setAttribute("stroke", "none");
    el.setAttribute("opacity", opacity.toFixed(3));
  }
}

// ─── emphasis: hand-drawn ! ? above head ─────────────────────────────────────
function drawEmphasis(fi, fx, dt, dur, t, camX, camY, zoom) {
  const glyph = fx.glyph || "!";
  // position above actor head in SVG space
  const actorName = fx.actor;
  let svgX, svgY;
  if (actorName) {
    const wx = actorXAt(actorName, t);
    const wy = GROUND_Y - 310; // above head top
    [svgX, svgY] = worldToScreen(wx, wy, camX, camY, zoom);
  } else {
    const [bx, by] = fxScreenPos(fx, t, camX, camY, zoom);
    svgX = bx; svgY = by - 80 * zoom;
  }
  const [cssX, cssY] = svgToCSS(svgX, svgY);

  // pop in fast (0.15s), hold, fade out last 30%
  const popDur = 0.15;
  const u = clamp(dt / popDur, 0, 1);
  const popScale = easeBackOut(u, 2.5);
  const fadeStart = dur * 0.7;
  const opacity = dt < fadeStart ? 1.0 : 1.0 - clamp((dt - fadeStart) / (dur - fadeStart), 0, 1);

  const stage = document.getElementById("stage");
  const cssScale = stage.offsetWidth / 1920;
  // Base size: 72px in SVG coords * zoom → convert to CSS
  const baseSvgPx = 72 * zoom;
  const fontSize = baseSvgPx * cssScale * Math.max(0.02, popScale);

  const key = "emph_" + fi;
  const el = getFxDiv(key, 0);
  el.style.display = "";
  el.style.left  = (cssX - fontSize * 0.4).toFixed(1) + "px";
  el.style.top   = (cssY - fontSize).toFixed(1) + "px";
  el.style.fontSize = fontSize.toFixed(1) + "px";
  el.style.color = "#D4231C";
  el.style.fontFamily = '"LXGW WenKai","LXGWWenKai","霞鹜文楷","Microsoft YaHei",sans-serif';
  el.style.fontWeight = "900";
  el.style.opacity = (opacity * 0.95).toFixed(3);
  el.style.WebkitTextStroke = `${Math.max(0.5, 0.05 * fontSize).toFixed(1)}px rgba(253,250,245,0.92)`;
  el.style.paintOrder = "stroke fill";
  el.style.lineHeight = "1";
  el.textContent = glyph;
}

// ─── burst: radial lines + tiny stars behind keyword ─────────────────────────
// Called from updateSubtitleAndKeywords; uses SVG overlay on fxSvgLayer
const _burstPools = {};
const _burstSvgEls = {};

function drawBurst(key, sx, sy, dt, zoom) {
  const dur = 0.5;
  if (dt < 0 || dt > dur) {
    if (_burstSvgEls[key]) {
      for (const el of _burstSvgEls[key]) el.setAttribute("opacity", "0");
    }
    return;
  }

  const n = 10;
  if (!_burstPools[key]) {
    const seed = key.split("").reduce((a,c) => a + c.charCodeAt(0), 0);
    const rng = seededRand(seed);
    _burstPools[key] = Array.from({length: n}, () => ({
      r: rng(), r2: rng(), r3: rng()
    }));
  }
  if (!_burstSvgEls[key]) {
    _burstSvgEls[key] = [];
    for (let i = 0; i < n; i++) {
      const el = svgEl("line", {});
      fxSvgLayer.appendChild(el);
      _burstSvgEls[key].push(el);
    }
  }

  const u = easeOut(dt / dur);
  const fadeU = dt < dur * 0.4 ? 1 : 1 - (dt - dur * 0.4) / (dur * 0.6);
  const pool = _burstPools[key];
  const els  = _burstSvgEls[key];

  for (let i = 0; i < n; i++) {
    const p = pool[i];
    const angle = (i / n) * 2 * Math.PI + p.r * 0.3;
    const len1  = (15 + p.r2 * 15) * zoom;
    const len2  = (35 + p.r3 * 35) * zoom;
    const x1 = sx + Math.cos(angle) * len1 * u;
    const y1 = sy + Math.sin(angle) * len1 * u;
    const x2 = sx + Math.cos(angle) * len2 * u;
    const y2 = sy + Math.sin(angle) * len2 * u;
    const el = els[i];
    el.setAttribute("x1", x1.toFixed(1));
    el.setAttribute("y1", y1.toFixed(1));
    el.setAttribute("x2", x2.toFixed(1));
    el.setAttribute("y2", y2.toFixed(1));
    el.setAttribute("stroke", "#D4231C");
    el.setAttribute("stroke-width", (3 * zoom).toFixed(1));
    el.setAttribute("stroke-linecap", "round");
    el.setAttribute("opacity", (fadeU * 0.7).toFixed(3));
  }
}

// ── v4 parallax cam_x for a prop ─────────────────────────────────────────────
function propCamX(prop, t, baseCamX) {
  const px = prop.parallax !== undefined ? prop.parallax : 1.0;
  if (px >= 1.0) return baseCamX;
  // spec: screen_x = (world_x - cam_x) * p * K * zoom + 960
  // 折算成等效 cam，使下游 (world - effCam) 仍成立：
  // effCam = prop.x*(1-p) + cam*p  →  (world - effCam) = (prop.x - cam)*p + drift
  return prop.x * (1.0 - px) + baseCamX * px;
}

// ── v4 cloud drift (deterministic phase from id hash) ────────────────────────
function cloudDriftX(propId, t) {
  let hash = 0;
  for (let i = 0; i < propId.length; i++) hash = (hash * 31 + propId.charCodeAt(i)) & 0xffffff;
  const phase = (hash / 0xffffff) * 2 * Math.PI;
  return 60 * Math.sin((2 * Math.PI * t / 12.0) + phase);
}

// ── evaluate(t): set all DOM positions from world state ──────────────────────
function evaluate(t) {
  const [camX, camY, zoom] = camAt(t);

  // Update ground line + shade to screen y
  const groundSY = worldToScreen(0, GROUND_Y, camX, camY, zoom)[1];
  groundLine.setAttribute("y1", groundSY.toFixed(1));
  groundLine.setAttribute("y2", groundSY.toFixed(1));
  const groundShadeEl = document.getElementById("ground_shade");
  if (groundShadeEl) {
    groundShadeEl.setAttribute("y", groundSY.toFixed(1));
    groundShadeEl.setAttribute("height", (1080 - groundSY + 10).toFixed(1));
  }

  // Props (v4: parallax, cloud drift)
  for (const prop of RESOLVED.props) {
    const g = propElems[prop.id];
    if (!g) continue;
    const st = propStateAt(prop, t);

    // compute effective cam_x with parallax
    const effectiveCamX = propCamX(prop, t, camX);
    let worldX = prop.x;
    // cloud: add self-drift
    if (prop.type === "cloud") worldX += cloudDriftX(prop.id, t);

    const [sx, sy] = worldToScreen(worldX, GROUND_Y, effectiveCamX, camY, zoom);
    const propScale = zoom * st.scale;

    let finalOpacity = st.opacity;
    if (prop.type === "skyline") finalOpacity = st.opacity * 0.30;
    g.setAttribute("opacity", finalOpacity.toFixed(3));

    let bodyTilt = 0;
    if (st.closed && prop.type === "shop") bodyTilt = 5;

    g.setAttribute("transform",
      `translate(${sx.toFixed(1)},${sy.toFixed(1)}) scale(${propScale.toFixed(4)}) rotate(${bodyTilt})`
    );

    const signGroup = document.getElementById("prop_" + prop.id + "_sign_group");
    if (signGroup) {
      if (st.closed && prop.type === "shop") {
        signGroup.setAttribute("transform", "rotate(-25, 0, -480)");
      } else {
        signGroup.setAttribute("transform", "");
      }
    }

    // shadow
    if (finalOpacity > 0.01 && prop.type !== "skyline" && prop.type !== "cloud") {
      const shadowOpacityFactor = st.scale > 1 ? 1 : st.opacity;
      updateShadow("prop_" + prop.id, sx, sy, zoom, shadowOpacityFactor, 0);
    }
  }

  // Coin
  const cs = coinStateAt(t);
  if (cs.opacity <= 0 || cs.x < -8000) {
    coinElem.setAttribute("opacity", "0");
    updateShadow("coin", 0, 0, zoom, 0, 0);
  } else {
    const [csx, csy] = worldToScreen(cs.x, cs.y, camX, camY, zoom);
    const [cgsx, cgsy] = worldToScreen(cs.x, GROUND_Y, camX, camY, zoom);
    const coinScale = zoom * cs.scale;
    coinElem.setAttribute("opacity", cs.opacity.toFixed(3));
    coinElem.setAttribute("transform",
      `translate(${csx.toFixed(1)},${csy.toFixed(1)}) scale(${coinScale.toFixed(4)})`
    );
    // shadow shrinks when coin is above ground
    const heightAbove = Math.max(0, GROUND_Y - cs.y);
    const shadowFactor = Math.max(0.2, 1 - heightAbove / 400);
    updateShadow("coin", cgsx, cgsy, zoom, cs.opacity * shadowFactor, 0);
  }

  // Actors
  for (const [name, actor] of Object.entries(RESOLVED.actors)) {
    const g = actorElems[name];
    if (!g) continue;
    const st = actorStateAt(name, actor, t);

    if (!st.visible) {
      g.setAttribute("opacity", "0");
      updateShadow("actor_" + name, 0, 0, zoom, 0, 0);
      continue;
    }
    g.setAttribute("opacity", "1");

    const worldY = GROUND_Y + st.bounceY;
    const [sx, sy] = worldToScreen(st.worldX, worldY, camX, camY, zoom);
    const [sxg, syg] = worldToScreen(st.worldX, GROUND_Y, camX, camY, zoom);

    // spin: rotateZ applied on root group; facing flip still separate
    const spinRot = st.rootRotate || 0;
    const scaleX = st.facing * zoom;
    const scaleY = zoom;
    if (spinRot !== 0) {
      g.setAttribute("transform",
        `translate(${sx.toFixed(1)},${sy.toFixed(1)}) rotate(${spinRot.toFixed(1)}) scale(${scaleX.toFixed(4)},${scaleY.toFixed(4)})`
      );
    } else {
      g.setAttribute("transform",
        `translate(${sx.toFixed(1)},${sy.toFixed(1)}) scale(${scaleX.toFixed(4)},${scaleY.toFixed(4)})`
      );
    }

    // breathing / jump scale on body group
    const bodyEl = document.getElementById("actor_" + name + "_body");
    if (bodyEl) {
      gsap.set(bodyEl, { scaleY: st.breathScale, svgOrigin: "0 30" });
    }

    // joint rotations
    const jMap = {
      body:          { suffix:"_body",     origin:"0 -145"  },
      head:          { suffix:"_head",     origin:"0 -145"  },
      armL_shoulder: { suffix:"_armL",     origin:"0 -100"  },
      armL_elbow:    { suffix:"_foreL",    origin:"-40 -60" },
      armR_shoulder: { suffix:"_armR",     origin:"0 -100"  },
      armR_elbow:    { suffix:"_foreR",    origin:"40 -60"  },
      legL_hip:      { suffix:"_legL",     origin:"0 30"    },
      legL_knee:     { suffix:"_shinL",    origin:"-22 115" },
      legR_hip:      { suffix:"_legR",     origin:"0 30"    },
      legR_knee:     { suffix:"_shinR",    origin:"22 115"  },
    };
    for (const [jk, jd] of Object.entries(jMap)) {
      const el = document.getElementById("actor_" + name + jd.suffix);
      if (el) gsap.set(el, { svgOrigin: jd.origin, rotation: st.joints[jk] || 0 });
    }

    // head sway (breathing only, independent of joints.head lean which is on _head group)
    const headSwayEl = document.getElementById("actor_" + name + "_headSway");
    if (headSwayEl) {
      gsap.set(headSwayEl, { svgOrigin: "0 -145", rotation: st.headSway });
    }

    // face
    for (const fn of ["calm","sad","angry","surprised"]) {
      const fel = document.getElementById("actor_" + name + "_face_" + fn);
      if (fel) fel.style.display = (fn === st.face) ? "" : "none";
    }

    // shadow (smaller when airborne)
    const heightAbove = Math.max(0, -st.bounceY);
    const shadowFactor = Math.max(0.15, 1 - heightAbove / 200);
    updateShadow("actor_" + name, sxg, syg, zoom, shadowFactor, 0);
  }

  // FX
  drawFX(t, camX, camY, zoom);

  // Subtitle + keywords
  updateSubtitleAndKeywords(t, camX, camY, zoom);
}

// ── Subtitle & keyword ────────────────────────────────────────────────────────
const SCENES = PLAN.scenes;
let _lastSubtitle = null;
const _kwContainer = document.getElementById("kwcontainer");
const _kwElems = {};

for (const sc of SCENES) {
  for (const kw of (sc.keywords || [])) {
    const key = kw.text + "_" + kw.t.toFixed(3);
    if (!_kwElems[key]) {
      const el = document.createElement("div");
      el.className = "kw-box";
      el.textContent = kw.text;
      // position in screen-space center-ish
      el.style.left = "55%";
      el.style.top  = "20%";
      el.style.visibility = "hidden";
      el.style.transform = "scale(0)";
      _kwContainer.appendChild(el);
      _kwElems[key] = el;
    }
  }
}

function updateSubtitleAndKeywords(t, camX, camY, zoom) {
  const subEl = document.getElementById("subtitle");
  let subText = "";
  for (const sc of SCENES) {
    if (t >= sc.start && t <= sc.end) {
      subText = sc.subtitle;
      break;
    }
  }
  if (subText !== _lastSubtitle) {
    subEl.textContent = subText;
    _lastSubtitle = subText;
  }

  for (const sc of SCENES) {
    for (const kw of (sc.keywords || [])) {
      const key = kw.text + "_" + kw.t.toFixed(3);
      const el  = _kwElems[key];
      if (!el) continue;
      const dt = t - kw.t;
      if (dt >= -0.05 && dt <= 1.5) {
        el.style.visibility = "visible";
        const u = clamp(dt / 0.35, 0, 1);
        const sc2 = easeBackOut(u, 2.5);
        el.style.transform = `scale(${sc2.toFixed(4)})`;
        el.style.opacity = dt > 1.2 ? (1 - clamp((dt - 1.2) / 0.3, 0, 1)) : 1;
        // Burst behind keyword (screen-space center of kw element)
        const stageW = 1920, stageH = 1080;
        const kwX = parseFloat(el.style.left) / 100 * stageW;
        const kwY = parseFloat(el.style.top)  / 100 * stageH;
        drawBurst(key, kwX, kwY, dt, zoom);
      } else {
        el.style.visibility = "hidden";
        el.style.transform = "scale(0)";
        el.style.opacity = 1;
        drawBurst(key, 0, 0, -1, zoom);
      }
    }
  }
}

// ── GSAP timeline ─────────────────────────────────────────────────────────────
let TL = null;

function buildTimeline() {
  if (TL) return TL;
  initJointPivots();
  evaluate(0);

  TL = gsap.timeline({ paused: true });
  TL.to({}, {
    duration: DURATION,
    ease: "none",
    onUpdate() { evaluate(TL.time()); }
  }, 0);
  TL.set({}, {}, DURATION);
  return TL;
}

// ── Runtime playback ──────────────────────────────────────────────────────────
const audio = document.getElementById("narration");
const playbtn = document.getElementById("playbtn");

function clampV(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }

playbtn.addEventListener("click", () => {
  playbtn.style.display = "none";
  buildTimeline();
  audio.play();
});
audio.addEventListener("playing", () => { if (TL) TL.play(); });
audio.addEventListener("ended",   () => { if (TL) TL.timeScale(1); });

function tick() {
  if (TL && !audio.paused && !audio.ended) {
    const drift = audio.currentTime - TL.time();
    if (Math.abs(drift) > 1.2) {
      TL.time(audio.currentTime);
      TL.timeScale(1);
    } else {
      TL.timeScale(clampV(1 + drift * 0.6, 0.6, 1.6));
    }
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

// ── Headless render hooks ─────────────────────────────────────────────────────
window.__buildOnly = () => {
  playbtn.style.display = "none";
  buildTimeline();
  TL.pause();
  return DURATION;
};
window.__seek = (t) => {
  TL.time(t, false);
  evaluate(t);
};
</script>
</body>
</html>
"""


def build_html2(
    resolved_path: Path,
    plan_path: Path,
    rig_path: Path,
    props_path: Path,
    voice_path: Path,
    out_path: Path,
) -> None:
    """Build the v4 director-mode animation HTML with all upgrades."""
    resolved = json.loads(resolved_path.read_text(encoding="utf-8"))
    plan     = json.loads(plan_path.read_text(encoding="utf-8"))
    rig      = json.loads(rig_path.read_text(encoding="utf-8"))
    props_def = json.loads(props_path.read_text(encoding="utf-8"))

    script_dir = Path(__file__).resolve().parent
    assets_dir = script_dir.parent / "assets"

    # Load GSAP core
    gsap_path = assets_dir / "gsap.min.js"
    gsap_txt  = gsap_path.read_text(encoding="utf-8")
    if "</script>" in gsap_txt:
        gsap_txt = gsap_txt.replace("</script>", "<\\/script>")

    # Load and concat GSAP plugins (CustomWiggle/Bounce/CustomEase etc.)
    plugin_files = [
        "gsap-customease.min.js",
        "gsap-customwiggle.min.js",
        "gsap-custombounce.min.js",
        # Physics2D skipped: not deterministic in seek mode
    ]
    plugin_txts = []
    for pf in plugin_files:
        pp = assets_dir / pf
        if pp.exists():
            txt = pp.read_text(encoding="utf-8")
            if "</script>" in txt:
                txt = txt.replace("</script>", "<\\/script>")
            plugin_txts.append(txt)
    plugins_combined = "\n".join(plugin_txts)

    audio_b64 = base64.b64encode(voice_path.read_bytes()).decode("ascii")

    duration = resolved["meta"]["audio_duration"]

    # Pre-sample camera table at 20 Hz (now includes cam_y)
    cam_table = build_camera_table_full(resolved, duration + 1.0, fps=20.0)

    # Font subsetting
    chars = collect_characters(resolved, plan)
    font_ttf = assets_dir / "LXGWWenKai-Regular.ttf"
    font_woff2 = b""
    if font_ttf.exists():
        font_woff2 = subset_font(font_ttf, chars)

    font_face_css = ""
    if font_woff2:
        b64_font = base64.b64encode(font_woff2).decode("ascii")
        font_face_css = (
            f"@font-face {{\n"
            f"  font-family: 'LXGW WenKai';\n"
            f"  src: url('data:font/woff2;base64,{b64_font}') format('woff2');\n"
            f"  font-weight: normal;\n"
            f"  font-style: normal;\n"
            f"}}\n"
        )
        print(f"  Font embedded: {len(font_woff2)/1024:.1f} KB for {len(chars)} chars")
    else:
        print("  Font subset skipped, falling back to system fonts")

    def js(obj: object) -> str:
        return json.dumps(obj, ensure_ascii=False).replace("</", "<\\/")

    used_types = {p["type"] for p in resolved["props"]}
    props_filtered = {k: v for k, v in props_def.items()
                      if k in used_types and not k.startswith("_")}

    html = (
        TEMPLATE
        .replace("__FONT_FACE__",  font_face_css)
        .replace("__RESOLVED__",   js(resolved))
        .replace("__PLAN__",       js(plan))
        .replace("__RIG__",        js(rig))
        .replace("__PROPS_DEF__",  js(props_filtered))
        .replace("__CAM_TABLE__",  js(cam_table))
        .replace("__AUDIO_B64__",  audio_b64)
        .replace("__GSAP__",       gsap_txt)
        .replace("__GSAP_PLUGINS__", plugins_combined)
    )

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(html, encoding="utf-8")
    size_mb = out_path.stat().st_size / (1024 * 1024)
    print(
        f"OK wrote {out_path} ({size_mb:.2f} MB), "
        f"DURATION={duration + 1.0:.3f}s, "
        f"cam_table_len={len(cam_table)}, "
        f"actors={list(resolved['actors'].keys())}, "
        f"props={[p['id'] for p in resolved['props']]}, "
        f"font_subset={len(font_woff2)/1024:.1f}KB, "
        f"chars={len(chars)}"
    )


def main() -> None:
    if len(sys.argv) != 7:
        print(__doc__)
        sys.exit(1)
    (resolved_p, plan_p, rig_p, props_p, voice_p, out_p) = (
        Path(a) for a in sys.argv[1:7]
    )
    build_html2(resolved_p, plan_p, rig_p, props_p, voice_p, out_p)


if __name__ == "__main__":
    main()
