"""ae_v3_ext.py — v3 extension: FX pre-sampling + gesture keyframes + polish.

Called by build_ae2.py (import ae_v3_ext).
All output is pure ES3 ExtendScript appended to lines: list[str].

Coordinate rules (same as build_ae2.py):
  child_pos = world * K   (K=0.9)
  Parent all world-FX layers to camCtrl; burst is screen-space (no parent).
"""
from __future__ import annotations

import math
from typing import Any

K: float = 0.9
COMP_W: float = 1920.0
COMP_H: float = 1080.0

# ---------------------------------------------------------------------------
# helpers re-exported so build_ae2 can use them without reimport
# ---------------------------------------------------------------------------

def r(v: float) -> str:
    rounded = round(v, 4)
    if rounded == int(rounded):
        return str(int(rounded))
    return str(rounded)


def js_str(s: str) -> str:
    escaped = s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
    return '"' + escaped + '"'


def js_color(h: str) -> str:
    h = h.lstrip("#")
    return "[" + r(int(h[0:2], 16) / 255) + "," + r(int(h[2:4], 16) / 255) + "," + r(int(h[4:6], 16) / 255) + ",1]"


def js_color3(h: str) -> str:
    h = h.lstrip("#")
    return "[" + r(int(h[0:2], 16) / 255) + "," + r(int(h[2:4], 16) / 255) + "," + r(int(h[4:6], 16) / 255) + "]"


# ---------------------------------------------------------------------------
# Gesture keyframe emitter
# Appends rotation keyframes for wave/point/nod/shake_head/jump/spin.
# actor_x_fn: callable t->world_x (for jump y-track).
# root_y_offset: AE child-space y of root (body pivot * K).
# ---------------------------------------------------------------------------

def emit_gestures(
    lines: list[str],
    aname: str,
    gestures: list[dict],
    rotation_map: dict[str, str],
    actor_x_fn: Any,
    root_y_offset: float,
    bounce_windows: list[tuple[float, float, float]],
    comp_duration: float,
) -> None:
    """Append gesture rotation + position keyframes for one actor."""

    def rp(field: str) -> str:
        vn = rotation_map[field]
        return vn + ".property(\"Transform\").property(\"Rotation\")"

    root_var = aname + "_root"
    pos_var = aname + "_posP"   # already declared in build_ae2 actor loop
    scale_var = aname + "_scaleP"

    for g in gestures:
        name = g.get("name", "")
        gt = float(g.get("t", 0.0))
        dur = float(g.get("dur", 0.9))
        dir_val = int(g.get("dir", 1))   # 1=right, -1=left
        height = float(g.get("height", 160.0))

        if name == "wave":
            # Lift right arm to ~-140°, swing ±15° three times, return
            lines.append("    // gesture wave @ " + r(gt) + " " + aname)
            arm_prop = rotation_map["armR_shoulder"] + ".property(\"Transform\").property(\"Rotation\")"
            fore_prop = rotation_map["armR_elbow"] + ".property(\"Transform\").property(\"Rotation\")"
            swing_step = dur / 4.0
            # pre-idle
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(gt - 0.1, 6)) + ", 0);")
            lines.append("    " + fore_prop + ".setValueAtTime(" + r(round(gt - 0.1, 6)) + ", 0);")
            # lift
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(gt, 6)) + ", -140);")
            lines.append("    " + fore_prop + ".setValueAtTime(" + r(round(gt, 6)) + ", -25);")
            # 3 swings
            for i in range(3):
                swing_sign = 1 if i % 2 == 0 else -1
                st = round(gt + swing_step * (i + 1), 6)
                lines.append("    " + arm_prop + ".setValueAtTime(" + r(st) + ", " + str(-140 + swing_sign * 15) + ");")
            # return
            t_ret = round(gt + dur + 0.1, 6)
            if t_ret > comp_duration:
                t_ret = comp_duration
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(t_ret) + ", 0);")
            lines.append("    " + fore_prop + ".setValueAtTime(" + r(t_ret) + ", 0);")

        elif name == "point":
            # Raise right/left arm toward dir at -90°, hold 0.8s, fall back
            lines.append("    // gesture point dir=" + str(dir_val) + " @ " + r(gt) + " " + aname)
            arm_prop = rotation_map["armR_shoulder"] + ".property(\"Transform\").property(\"Rotation\")" if dir_val >= 0 else rotation_map["armL_shoulder"] + ".property(\"Transform\").property(\"Rotation\")"
            # pre
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(gt - 0.05, 6)) + ", 0);")
            # raise to -90 (straight up/forward)
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(gt + 0.15, 6)) + ", -90);")
            # hold
            hold_end = round(gt + dur - 0.1, 6)
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(hold_end) + ", -90);")
            # fall
            t_fall = round(gt + dur + 0.2, 6)
            if t_fall > comp_duration:
                t_fall = comp_duration
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(t_fall) + ", 0);")

        elif name == "nod":
            # Head +12° × 2 cycles
            lines.append("    // gesture nod @ " + r(gt) + " " + aname)
            head_prop = rotation_map["head"] + ".property(\"Transform\").property(\"Rotation\")"
            step = dur / 4.0
            lines.append("    " + head_prop + ".setValueAtTime(" + r(round(gt - 0.05, 6)) + ", 0);")
            for i in range(4):
                nd_sign = 1 if i % 2 == 0 else -1
                nd_t = round(gt + step * (i + 1), 6)
                lines.append("    " + head_prop + ".setValueAtTime(" + r(nd_t) + ", " + str(nd_sign * 12) + ");")
            lines.append("    " + head_prop + ".setValueAtTime(" + r(round(gt + dur + 0.1, 6)) + ", 0);")

        elif name == "shake_head":
            # Head ±10° × 3 rapid swings
            lines.append("    // gesture shake_head @ " + r(gt) + " " + aname)
            head_prop = rotation_map["head"] + ".property(\"Transform\").property(\"Rotation\")"
            step = dur / 6.0
            lines.append("    " + head_prop + ".setValueAtTime(" + r(round(gt - 0.05, 6)) + ", 0);")
            for i in range(6):
                sh_sign = 1 if i % 2 == 0 else -1
                sh_t = round(gt + step * (i + 1), 6)
                lines.append("    " + head_prop + ".setValueAtTime(" + r(sh_t) + ", " + str(sh_sign * 10) + ");")
            lines.append("    " + head_prop + ".setValueAtTime(" + r(round(gt + dur + 0.1, 6)) + ", 0);")

        elif name == "jump":
            # Vertical arc in root Position + scaleY squash/stretch
            lines.append("    // gesture jump h=" + r(height) + " @ " + r(gt) + " " + aname)
            # insert extra samples for jump arc into posP
            cur_wx = actor_x_fn(gt)
            cur_sc_x = 100  # assume forward; real facing is handled in dense track
            jump_steps = 8
            jump_dur = dur if dur > 0.1 else 0.5
            for ji in range(jump_steps + 1):
                alpha = ji / jump_steps
                jt = round(gt + alpha * jump_dur, 6)
                if jt > comp_duration:
                    break
                jy_off = -height * K * math.sin(math.pi * alpha)
                # squash: compressed on takeoff/land, stretched at peak
                if alpha < 0.15 or alpha > 0.85:
                    sc_y = 92.0
                elif 0.4 < alpha < 0.6:
                    sc_y = 106.0
                else:
                    sc_y = 100.0
                wx = actor_x_fn(jt)
                lines.append("    " + pos_var + ".setValueAtTime(" + r(jt) + ", [" + r(wx * K) + "," + r(root_y_offset + jy_off) + "]);")
                lines.append("    " + aname + "_body.property(\"Transform\").property(\"Scale\").setValueAtTime(" + r(jt) + ", [100," + r(sc_y) + "]);")
            # restore scale after
            t_land_end = round(gt + jump_dur + 0.1, 6)
            if t_land_end <= comp_duration:
                lines.append("    " + aname + "_body.property(\"Transform\").property(\"Scale\").setValueAtTime(" + r(t_land_end) + ", [100,100]);")

        elif name == "spin":
            # Root group 360° rotation over dur
            lines.append("    // gesture spin @ " + r(gt) + " " + aname)
            root_rot = root_var + ".property(\"Transform\").property(\"Rotation\")"
            lines.append("    " + root_rot + ".setValueAtTime(" + r(round(gt - 0.05, 6)) + ", 0);")
            lines.append("    " + root_rot + ".setValueAtTime(" + r(round(gt + dur, 6)) + ", 360);")
            t_after = round(gt + dur + 0.05, 6)
            if t_after <= comp_duration:
                lines.append("    " + root_rot + ".setValueAtTime(" + r(t_after) + ", 0);")


# ---------------------------------------------------------------------------
# give arm auto-motion emitter
# arc_segs: list of arc segments from resolved items.coin.segments
# actor_x_fns: dict name -> callable
# ---------------------------------------------------------------------------

def emit_give_arms(
    lines: list[str],
    arc_segs: list[dict],
    actor_x_fns: dict[str, Any],
    rotation_maps: dict[str, dict[str, str]],
    comp_duration: float,
) -> None:
    """For each arc segment, raise giving arm 35° and receiving arm 25°."""
    for seg in arc_segs:
        if seg.get("mode") != "arc":
            continue
        from_c = seg.get("from_carrier")
        to_c = seg.get("to_carrier")
        if not from_c or not to_c:
            continue
        t0 = float(seg["t0"])
        t1 = float(seg["t1"])
        mid = (t0 + t1) / 2.0
        recover = round(t1 + 0.3, 6)
        if recover > comp_duration:
            recover = comp_duration

        for actor, angle, label in [(from_c, -35, "give"), (to_c, -25, "recv")]:
            if actor not in rotation_maps:
                continue
            rm = rotation_maps[actor]
            arm_prop = rm["armR_shoulder"] + ".property(\"Transform\").property(\"Rotation\")"
            lines.append("    // give arm " + label + " actor=" + actor + " t=" + r(t0))
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(t0 - 0.05, 6)) + ", 0);")
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(round(mid, 6)) + ", " + str(angle) + ");")
            lines.append("    " + arm_prop + ".setValueAtTime(" + r(recover) + ", 0);")


# ---------------------------------------------------------------------------
# Walk v2: bob + tilt improvements
# Emits ADDITIONAL keyframes on top of the existing gait keyframes:
#   - body tilt for walk/run
#   - head overshoot on stop
# The dense position track already handles y-bob via move data.
# We add body lean and smooth stop head-overshoot here.
# ---------------------------------------------------------------------------

def emit_walk_v2(
    lines: list[str],
    aname: str,
    moves: list[dict],
    rotation_map: dict[str, str],
    comp_duration: float,
) -> None:
    """Add body lean + head-stop overshoot on top of existing gait keys."""
    for move in moves:
        mt0 = float(move["t0"])
        mt1 = float(move["t1"])
        gait = move.get("gait", "walk")
        tilt = 3.0 if gait == "walk" else 8.0

        body_prop = rotation_map["body"] + ".property(\"Transform\").property(\"Rotation\")"
        head_prop = rotation_map["head"] + ".property(\"Transform\").property(\"Rotation\")"

        # body lean forward during move
        lines.append("    // walk_v2 tilt " + gait + " " + aname)
        lines.append("    " + body_prop + ".setValueAtTime(" + r(round(mt0 + 0.1, 6)) + ", " + r(tilt) + ");")
        lines.append("    " + body_prop + ".setValueAtTime(" + r(round(mt1 - 0.05, 6)) + ", " + r(tilt) + ");")
        # stop: body + head snap back with slight overshoot (head 4° forward then 0)
        t_stop = round(mt1, 6)
        t_over = round(mt1 + 0.15, 6)
        t_settle = round(mt1 + 0.25, 6)
        if t_settle > comp_duration:
            t_settle = comp_duration
        lines.append("    " + body_prop + ".setValueAtTime(" + r(t_stop) + ", 0);")
        lines.append("    " + head_prop + ".setValueAtTime(" + r(t_stop) + ", 4);")
        lines.append("    " + head_prop + ".setValueAtTime(" + r(t_over) + ", 4);")
        lines.append("    " + head_prop + ".setValueAtTime(" + r(t_settle) + ", 0);")


# ---------------------------------------------------------------------------
# FX layer emitters (all world-space unless noted, parented to camCtrl)
# ---------------------------------------------------------------------------

_fx_counter = [0]

def _fx_var() -> str:
    _fx_counter[0] += 1
    return "fx_" + str(_fx_counter[0])


def emit_fx_dust(
    lines: list[str],
    world_x: float,
    t: float,
    small: bool,
    ink: str,
    comp_duration: float,
) -> None:
    """2-4 small circles scatter + fade 0.4s. small=True -> half size."""
    n_dots = 2 if small else 3
    base_r = 12.0 if small else 22.0
    dur = 0.4
    cx = world_x * K

    for i in range(n_dots):
        fv = _fx_var()
        angle = (i / n_dots) * math.pi * 2.0 + 0.3
        dx = math.cos(angle) * (35.0 + i * 12.0) * K
        dy = -math.sin(angle) * (20.0 + i * 8.0) * K
        rad = (base_r - i * 3.0) * K

        t_end = min(t + dur, comp_duration)
        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_dust_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        ell = fv + "_e"
        lines.append("var " + ell + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
        lines.append(ell + ".property(\"Size\").setValue([" + r(rad * 2) + "," + r(rad * 2) + "]);")
        lines.append(ell + ".property(\"Position\").setValue([0,0]);")
        stk = fv + "_stk"
        lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(stk + ".property(\"Color\").setValue(" + js_color(ink) + ");")
        lines.append(stk + ".property(\"Stroke Width\").setValue(" + r(5.0 * K) + ");")
        lines.append(fv + ".parent = camCtrl;")
        lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(fv + ".property(\"Transform\").property(\"Position\").setValueAtTime(" + r(t) + ", [" + r(cx) + ",180]);")
        lines.append(fv + ".property(\"Transform\").property(\"Position\").setValueAtTime(" + r(t_end) + ", [" + r(cx + dx) + "," + r(180.0 + dy) + "]);")
        lines.append(fv + ".property(\"Transform\").property(\"Opacity\").setValueAtTime(" + r(t) + ", 80);")
        lines.append(fv + ".property(\"Transform\").property(\"Opacity\").setValueAtTime(" + r(t_end) + ", 0);")
        # Hard visibility window: layer does not exist outside [t, t_end]
        lines.append(fv + ".inPoint = " + r(t) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


def emit_fx_sparkle(
    lines: list[str],
    world_x: float,
    world_y: float,
    t: float,
    ink: str,
    comp_duration: float,
) -> None:
    """3-5 four-point star shapes flash 0.6s at a position."""
    n_stars = 4
    dur = 0.6
    cx = world_x * K
    cy = world_y * K

    for i in range(n_stars):
        fv = _fx_var()
        angle = (i / n_stars) * math.pi * 2.0
        off_x = math.cos(angle) * 28.0 * K
        off_y = math.sin(angle) * 28.0 * K
        star_size = (18.0 - i * 2.0) * K
        t_peak = t + 0.15
        t_end = min(t + dur, comp_duration)

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_sparkle_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")

        # 4-point star = polyline with 8 vertices alternating outer/inner
        pts = []
        for vi in range(8):
            a = vi * math.pi / 4.0 - math.pi / 8.0
            rad = star_size if vi % 2 == 0 else star_size * 0.35
            pts.append([r(math.cos(a) * rad), r(math.sin(a) * rad)])
        pts_str = "[" + ",".join("[" + p[0] + "," + p[1] + "]" for p in pts) + "]"
        tang_str = "[[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0]]"

        lines.append("var " + fv + "_path = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
        lines.append("var " + fv + "_sh = new Shape();")
        lines.append(fv + "_sh.vertices = " + pts_str + ";")
        lines.append(fv + "_sh.inTangents = " + tang_str + ";")
        lines.append(fv + "_sh.outTangents = " + tang_str + ";")
        lines.append(fv + "_sh.closed = true;")
        lines.append(fv + "_path.property(\"Path\").setValue(" + fv + "_sh);")
        stk = fv + "_stk"
        lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(stk + ".property(\"Color\").setValue(" + js_color(ink) + ");")
        lines.append(stk + ".property(\"Stroke Width\").setValue(" + r(4.0 * K) + ");")

        lines.append(fv + ".parent = camCtrl;")
        lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(fv + ".property(\"Transform\").property(\"Position\").setValue([" + r(cx + off_x) + "," + r(cy + off_y) + "]);")
        sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
        lines.append(sc_p + ".setValueAtTime(" + r(t) + ", [0,0]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_peak) + ", [120,120]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_end) + ", [0,0]);")
        lines.append(fv + ".property(\"Transform\").property(\"Opacity\").setValueAtTime(" + r(t) + ", 100);")
        lines.append(fv + ".property(\"Transform\").property(\"Opacity\").setValueAtTime(" + r(t_end) + ", 0);")
        lines.append(fv + ".inPoint = " + r(t) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


def emit_fx_speed_lines(
    lines: list[str],
    actor_x_fn: Any,
    t0: float,
    t1: float,
    ink: str,
    comp_duration: float,
) -> None:
    """3 horizontal short lines trailing behind actor during run."""
    n_lines = 3
    t_end = min(t1, comp_duration)
    step = 0.05

    for li in range(n_lines):
        fv = _fx_var()
        y_off = (-30.0 + li * 30.0) * K   # stagger vertically around torso
        line_len = (60.0 - li * 10.0) * K

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_speedline_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        lines.append("var " + fv + "_path = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
        lines.append("var " + fv + "_sh = new Shape();")
        lines.append(fv + "_sh.vertices = [[-" + r(line_len) + ",0],[0,0]];")
        lines.append(fv + "_sh.inTangents = [[0,0],[0,0]];")
        lines.append(fv + "_sh.outTangents = [[0,0],[0,0]];")
        lines.append(fv + "_sh.closed = false;")
        lines.append(fv + "_path.property(\"Path\").setValue(" + fv + "_sh);")
        stk = fv + "_stk"
        lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(stk + ".property(\"Color\").setValue(" + js_color(ink) + ");")
        lines.append(stk + ".property(\"Stroke Width\").setValue(" + r(4.5 * K) + ");")

        lines.append(fv + ".parent = camCtrl;")
        lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")

        pos_p = fv + ".property(\"Transform\").property(\"Position\")"
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
        lines.append(op_p + ".setValueAtTime(" + r(t0) + ", 0);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t0 + 0.1, 6)) + ", 70);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t_end - 0.1, 6)) + ", 70);")
        lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")

        cur_t = t0
        while cur_t <= t_end + 0.001:
            wx = actor_x_fn(cur_t)
            # trail behind actor (subtract ~80 world units to sit behind)
            trail_x = (wx - 80.0) * K
            lines.append(pos_p + ".setValueAtTime(" + r(round(cur_t, 6)) + ", [" + r(trail_x) + "," + r(180.0 + y_off - 100.0 * K) + "]);")
            cur_t = round(cur_t + step, 6)
        lines.append(fv + ".inPoint = " + r(t0) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


def emit_fx_confetti(
    lines: list[str],
    world_x: float,
    t: float,
    n: int,
    comp_duration: float,
) -> None:
    """n three-color rect confetti pieces, parabolic + rotation, 2s fall."""
    colors = ["#E94040", "#2060D0", "#30A040"]
    dur = 2.0
    t_end = min(t + dur, comp_duration)
    cx = world_x * K

    for i in range(n):
        fv = _fx_var()
        col = colors[i % 3]
        angle_start = (i / n) * math.pi * 2.0
        vx = math.cos(angle_start) * (80.0 + (i % 4) * 30.0) * K
        vy_up = -(200.0 + (i % 5) * 40.0) * K   # initial upward velocity contribution
        vy_down = (350.0 + (i % 3) * 50.0) * K  # gravity end
        rot_end = 180.0 + (i % 3) * 120.0

        # sample 8 frames for parabola
        n_samp = 8
        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_conf_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        lines.append("var " + fv + "_rect = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Rect\");")
        w_r = (10.0 + i % 4 * 3) * K
        h_r = (6.0 + i % 3 * 2) * K
        lines.append(fv + "_rect.property(\"Size\").setValue([" + r(w_r) + "," + r(h_r) + "]);")
        lines.append(fv + "_rect.property(\"Position\").setValue([0,0]);")
        stk = fv + "_stk"
        lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(stk + ".property(\"Color\").setValue(" + js_color3(col) + ");")

        lines.append(fv + ".parent = camCtrl;")
        lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")

        pos_p = fv + ".property(\"Transform\").property(\"Position\")"
        rot_p = fv + ".property(\"Transform\").property(\"Rotation\")"
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"

        for si in range(n_samp + 1):
            alpha = si / n_samp
            samp_t = round(t + alpha * dur, 6)
            if samp_t > comp_duration:
                break
            # parabola: x linear, y = up-arc then gravity
            px = cx + vx * alpha
            # parabola y: vy_up drives up, gravity brings down
            py = 180.0 + vy_up * alpha + (vy_up * -1.0 + vy_down) * alpha * alpha
            lines.append(pos_p + ".setValueAtTime(" + r(samp_t) + ", [" + r(px) + "," + r(py) + "]);")

        lines.append(rot_p + ".setValueAtTime(" + r(t) + ", 0);")
        lines.append(rot_p + ".setValueAtTime(" + r(t_end) + ", " + r(rot_end) + ");")
        lines.append(op_p + ".setValueAtTime(" + r(t) + ", 100);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t_end - 0.3, 6)) + ", 100);")
        lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
        lines.append(fv + ".inPoint = " + r(t) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


def emit_fx_money_rain(
    lines: list[str],
    world_x: float,
    t: float,
    ink: str,
    paper: str,
    comp_duration: float,
) -> None:
    """8-12 small red ¥ circles fall from top with sway, 2s."""
    n_coins = 10
    dur = 2.0
    t_end = min(t + dur, comp_duration)
    cx = world_x * K

    for i in range(n_coins):
        fv = _fx_var()
        start_x = cx + (i - n_coins // 2) * 55.0 * K
        sway = math.sin(i * 1.3) * 25.0 * K
        start_y = -900.0 * K   # above top of frame (screen_y<0 needs world_y<-846 @zoom .85)
        end_y = 160.0 * K      # near ground
        delay = i * 0.12

        t_start = round(t + delay, 6)
        t_fall_end = round(t_start + dur - delay, 6)
        if t_fall_end > comp_duration:
            t_fall_end = comp_duration

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_money_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        ell = fv + "_e"
        lines.append("var " + ell + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
        rad_e = 22.0 * K   # world diameter 44 >= 40 (mini coin)
        lines.append(ell + ".property(\"Size\").setValue([" + r(rad_e * 2) + "," + r(rad_e * 2) + "]);")
        lines.append(ell + ".property(\"Position\").setValue([0,0]);")
        lines.append("var " + fv + "_fill = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(fv + "_fill.property(\"Color\").setValue(" + js_color(paper) + ");")
        lines.append("var " + fv + "_stk = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(fv + "_stk.property(\"Color\").setValue(" + js_color("#D4231C") + ");")
        lines.append(fv + "_stk.property(\"Stroke Width\").setValue(" + r(8.0 * K) + ");")

        # ¥ text child
        tv = fv + "_txt"
        lines.append("var " + tv + " = comp.layers.addText(\"\\u00a5\");")
        lines.append(tv + ".name = " + js_str("fx_money_txt_" + str(_fx_counter[0])) + ";")
        lines.append("var " + tv + "_doc = " + tv + ".property(\"Source Text\").value;")
        lines.append(tv + "_doc.fontSize = " + r(30.0 * K) + ";")
        lines.append(tv + "_doc.fillColor = " + js_color3("#D4231C") + ";")
        lines.append(tv + "_doc.justification = ParagraphJustification.CENTER_JUSTIFY;")
        lines.append(tv + ".property(\"Source Text\").setValue(" + tv + "_doc);")

        lines.append(fv + ".parent = camCtrl;")
        lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(tv + ".parent = " + fv + ";")
        lines.append(tv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(tv + ".property(\"Transform\").property(\"Position\").setValue([0," + r(10.0 * K) + "]);")
        lines.append(tv + ".property(\"Transform\").property(\"Scale\").setValue([100,100]);")

        pos_p = fv + ".property(\"Transform\").property(\"Position\")"
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
        rot_p = fv + ".property(\"Transform\").property(\"Rotation\")"

        lines.append(op_p + ".setValueAtTime(" + r(round(t_start - 0.01, 6)) + ", 0);")
        lines.append(op_p + ".setValueAtTime(" + r(t_start) + ", 100);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t_fall_end - 0.2, 6)) + ", 100);")
        lines.append(op_p + ".setValueAtTime(" + r(t_fall_end) + ", 0);")
        # parenting does NOT inherit opacity -> mirror keys on the text layer
        top_p = tv + ".property(\"Transform\").property(\"Opacity\")"
        lines.append(top_p + ".setValueAtTime(" + r(round(t_start - 0.01, 6)) + ", 0);")
        lines.append(top_p + ".setValueAtTime(" + r(t_start) + ", 100);")
        lines.append(top_p + ".setValueAtTime(" + r(round(t_fall_end - 0.2, 6)) + ", 100);")
        lines.append(top_p + ".setValueAtTime(" + r(t_fall_end) + ", 0);")
        # hard visibility window for circle AND text
        lines.append(fv + ".inPoint = " + r(t_start) + ";")
        lines.append(fv + ".outPoint = " + r(t_fall_end) + ";")
        lines.append(tv + ".inPoint = " + r(t_start) + ";")
        lines.append(tv + ".outPoint = " + r(t_fall_end) + ";")

        # sway: sin oscillation during fall (3 samples)
        for si in range(5):
            alpha = si / 4.0
            samp_t = round(t_start + alpha * (t_fall_end - t_start), 6)
            sw = sway * math.sin(alpha * math.pi * 2.0)
            px = start_x + sw
            py = start_y + (end_y - start_y) * alpha
            lines.append(pos_p + ".setValueAtTime(" + r(samp_t) + ", [" + r(px) + "," + r(py) + "]);")
        lines.append(rot_p + ".setValueAtTime(" + r(t_start) + ", 0);")
        lines.append(rot_p + ".setValueAtTime(" + r(t_fall_end) + ", " + r(20.0 * (1 if i % 2 == 0 else -1)) + ");")
        lines.append("")


def emit_fx_shockwave(
    lines: list[str],
    world_x: float,
    t: float,
    ink: str,
    comp_duration: float,
) -> None:
    """Expanding ring: Scale 100->300, Opacity 100->0 over 0.5s."""
    fv = _fx_var()
    dur = 0.5
    t_end = min(t + dur, comp_duration)
    cx = world_x * K

    lines.append("var " + fv + " = comp.layers.addShape();")
    lines.append(fv + ".name = " + js_str("fx_shock_" + str(_fx_counter[0])) + ";")
    grp = fv + "_g"
    lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    ell = fv + "_e"
    lines.append("var " + ell + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
    lines.append(ell + ".property(\"Size\").setValue([" + r(80.0 * K) + "," + r(80.0 * K) + "]);")
    lines.append(ell + ".property(\"Position\").setValue([0,0]);")
    stk = fv + "_stk"
    lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
    lines.append(stk + ".property(\"Color\").setValue(" + js_color(ink) + ");")
    lines.append(stk + ".property(\"Stroke Width\").setValue(" + r(8.0 * K) + ");")

    lines.append(fv + ".parent = camCtrl;")
    lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    lines.append(fv + ".property(\"Transform\").property(\"Position\").setValue([" + r(cx) + "," + r(140.0 * K) + "]);")
    sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
    lines.append(sc_p + ".setValueAtTime(" + r(t) + ", [100,100]);")
    lines.append(sc_p + ".setValueAtTime(" + r(t_end) + ", [300,300]);")
    op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
    lines.append(op_p + ".setValueAtTime(" + r(t) + ", 100);")
    lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
    lines.append(fv + ".inPoint = " + r(t) + ";")
    lines.append(fv + ".outPoint = " + r(t_end) + ";")
    lines.append("")


def emit_fx_emphasis(
    lines: list[str],
    glyph: str,
    screen_x: float,
    screen_y: float,
    t: float,
    ink: str,
    red: str,
    comp_duration: float,
) -> None:
    """! or ? shape over actor head. Screen-space (not parented to camCtrl)."""
    fv = _fx_var()
    dur = 0.8
    t_end = min(t + dur, comp_duration)

    lines.append("var " + fv + " = comp.layers.addText(" + js_str(glyph) + ");")
    lines.append(fv + ".name = " + js_str("fx_emph_" + str(_fx_counter[0])) + ";")
    lines.append("var " + fv + "_doc = " + fv + ".property(\"Source Text\").value;")
    lines.append(fv + "_doc.fontSize = 90;")
    col = red if glyph in ("!", "！") else ink
    lines.append(fv + "_doc.fillColor = " + js_color3(col) + ";")
    lines.append(fv + "_doc.justification = ParagraphJustification.CENTER_JUSTIFY;")
    lines.append(fv + ".property(\"Source Text\").setValue(" + fv + "_doc);")
    lines.append(fv + ".property(\"Transform\").property(\"Position\").setValue([" + r(screen_x) + "," + r(screen_y) + "]);")
    sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
    lines.append(sc_p + ".setValueAtTime(" + r(round(t - 0.05, 6)) + ", [0,0]);")
    lines.append(sc_p + ".setValueAtTime(" + r(round(t + 0.1, 6)) + ", [130,130]);")
    lines.append(sc_p + ".setValueAtTime(" + r(round(t + 0.2, 6)) + ", [100,100]);")
    op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
    lines.append(op_p + ".setValueAtTime(" + r(round(t_end - 0.15, 6)) + ", 100);")
    lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
    lines.append(fv + ".inPoint = " + r(max(0.0, round(t - 0.05, 6))) + ";")
    lines.append(fv + ".outPoint = " + r(t_end) + ";")
    lines.append("")


def emit_fx_burst(
    lines: list[str],
    screen_x: float,
    screen_y: float,
    t: float,
    ink: str,
    comp_duration: float,
) -> None:
    """8 radial short lines burst. Screen-space."""
    n_rays = 8
    dur = 0.5
    t_end = min(t + dur, comp_duration)

    for ri in range(n_rays):
        fv = _fx_var()
        angle = (ri / n_rays) * math.pi * 2.0
        dx0 = math.cos(angle) * 18.0
        dy0 = math.sin(angle) * 18.0
        dx1 = math.cos(angle) * 55.0
        dy1 = math.sin(angle) * 55.0

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx_burst_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        lines.append("var " + fv + "_path = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
        lines.append("var " + fv + "_sh = new Shape();")
        lines.append(fv + "_sh.vertices = [[" + r(dx0) + "," + r(dy0) + "],[" + r(dx1) + "," + r(dy1) + "]];")
        lines.append(fv + "_sh.inTangents = [[0,0],[0,0]];")
        lines.append(fv + "_sh.outTangents = [[0,0],[0,0]];")
        lines.append(fv + "_sh.closed = false;")
        lines.append(fv + "_path.property(\"Path\").setValue(" + fv + "_sh);")
        stk = fv + "_stk"
        lines.append("var " + stk + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(stk + ".property(\"Color\").setValue(" + js_color(ink) + ");")
        lines.append(stk + ".property(\"Stroke Width\").setValue(5);")

        # screen-space: no parent
        lines.append(fv + ".property(\"Transform\").property(\"Position\").setValue([" + r(screen_x) + "," + r(screen_y) + "]);")
        sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
        lines.append(sc_p + ".setValueAtTime(" + r(t) + ", [30,30]);")
        lines.append(sc_p + ".setValueAtTime(" + r(round(t + 0.2, 6)) + ", [100,100]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_end) + ", [120,120]);")
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
        lines.append(op_p + ".setValueAtTime(" + r(t) + ", 100);")
        lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
        lines.append(fv + ".inPoint = " + r(t) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


# ---------------------------------------------------------------------------
# Shadow ellipse per actor
# ---------------------------------------------------------------------------

def emit_actor_shadow(
    lines: list[str],
    aname: str,
    actor_x_fn: Any,
    ink: str,
    comp_duration: float,
    sample_step: float = 0.1,
) -> None:
    """Soft ellipse shadow at actor's feet, world-space parented to camCtrl."""
    fv = "shadow_" + aname
    lines.append("// shadow " + aname)
    lines.append("var " + fv + " = comp.layers.addShape();")
    lines.append(fv + ".name = " + js_str("shadow_" + aname) + ";")
    grp = fv + "_g"
    lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    ell = fv + "_e"
    lines.append("var " + ell + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
    lines.append(ell + ".property(\"Size\").setValue([" + r(90.0 * K) + "," + r(18.0 * K) + "]);")
    lines.append(ell + ".property(\"Position\").setValue([0,0]);")
    fill = fv + "_fill"
    lines.append("var " + fill + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
    lines.append(fill + ".property(\"Color\").setValue(" + js_color(ink) + ");")
    lines.append(fill + ".property(\"Opacity\").setValue(18);")

    lines.append(fv + ".parent = camCtrl;")
    lines.append(fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")

    pos_p = fv + ".property(\"Transform\").property(\"Position\")"
    cur_t = 0.0
    while cur_t <= comp_duration + 0.05:
        wx = actor_x_fn(cur_t)
        lines.append(pos_p + ".setValueAtTime(" + r(round(cur_t, 6)) + ", [" + r(wx * K) + ",180]);")
        cur_t = round(cur_t + sample_step, 6)
    lines.append("")


# ---------------------------------------------------------------------------
# Roughen Edges + Fractal Noise paper texture + Vignette
# ---------------------------------------------------------------------------

def emit_polish_effects(
    lines: list[str],
    ink: str,
    paper: str,
    comp_duration: float,
) -> None:
    """Add Fractal Noise adjustment layer (paper texture), vignette solid."""
    # --- Fractal Noise paper texture adjustment layer ---
    lines.append("// --- Paper texture (Fractal Noise adjustment layer) ---")
    lines.append("var paperTex = comp.layers.addSolid([1,1,1], \"PaperTex\", 1920, 1080, 1, " + r(comp_duration) + ");")
    lines.append("paperTex.adjustmentLayer = true;")
    lines.append("paperTex.moveToEnd();")
    fn_eff = "paperTex.property(\"Effects\").addProperty(\"ADBE Fractal Noise\")"
    lines.append("var fnEff = " + fn_eff + ";")
    # Fractal Noise: Fractal Type=1(Basic), Contrast=200, Scale=800, Opacity via layer
    lines.append("try { fnEff.property(\"Contrast\").setValue(200); } catch(eFN1){}")
    lines.append("try { fnEff.property(\"Scale\").setValue(800); } catch(eFN2){}")
    lines.append("paperTex.property(\"Transform\").property(\"Opacity\").setValue(4);")
    lines.append("")

    # --- Vignette: black solid, INVERTED ellipse mask -> dark corners only.
    # CRITICAL: maskMode/inverted are MaskPropertyGroup ATTRIBUTES, not
    # properties — property("maskMode") returns null and throws, which the old
    # catch swallowed, leaving an un-inverted hard-edged ADD mask = gray blob
    # covering the screen centre. Use attribute assignment + matchnames.
    # On any failure remove the solid entirely (no vignette beats a broken one).
    lines.append("// --- Vignette ---")
    lines.append("var vigSolid = comp.layers.addSolid([0,0,0], \"Vignette\", 1920, 1080, 1, " + r(comp_duration) + ");")
    lines.append("vigSolid.property(\"Transform\").property(\"Opacity\").setValue(7);")
    lines.append("try {")
    lines.append("  var vigMask = vigSolid.property(\"Masks\").addProperty(\"Mask\");")
    lines.append("  var vigMaskSh = new Shape();")
    lines.append("  vigMaskSh.vertices = [[0,540],[960,0],[1920,540],[960,1080]];")
    lines.append("  vigMaskSh.inTangents = [[0,0],[-700,0],[0,0],[700,0]];")
    lines.append("  vigMaskSh.outTangents = [[0,0],[700,0],[0,0],[-700,0]];")
    lines.append("  vigMaskSh.closed = true;")
    lines.append("  vigMask.property(\"ADBE Mask Shape\").setValue(vigMaskSh);")
    lines.append("  vigMask.maskMode = MaskMode.ADD;")
    lines.append("  vigMask.inverted = true;")
    lines.append("  vigMask.property(\"ADBE Mask Feather\").setValue([320,320]);")
    lines.append("  vigMask.property(\"ADBE Mask Offset\").setValue(60);")
    lines.append("} catch(eVig){ try { vigSolid.remove(); } catch(eVig2){} }")
    lines.append("")


# ---------------------------------------------------------------------------
# Helper: compute actor screen position at time t (for emphasis placement)
# ---------------------------------------------------------------------------

def actor_screen_pos(
    actor_x_fn: Any,
    t: float,
    cam_xs: list[float],
    cam_zooms: list[float],
    cam_ys: list[float],
    cam_times: list[float],
    sample_step: float = 0.1,
) -> tuple[float, float]:
    """Return (screen_x, screen_y) for actor at time t, using pre-sampled cam track."""
    # Find the sample index closest to t
    idx = min(int(round(t / sample_step)), len(cam_times) - 1)
    idx = max(0, idx)
    cam_x = cam_xs[idx]
    zoom = cam_zooms[idx]
    cam_y = cam_ys[idx]
    wx = actor_x_fn(t)
    sx = (wx - cam_x) * 0.9 * zoom + 960.0
    # actor head is approx world_y = 200 - 500 = -300 (head top above ground)
    sy = (-300.0 - cam_y) * 0.9 * zoom + (800.0 - 180.0 * zoom)
    return sx, sy


# ---------------------------------------------------------------------------
# Dispatch: route resolved.json fx entries to the right emitter
# ---------------------------------------------------------------------------

def emit_all_fx(
    lines: list[str],
    fx_list: list[dict],
    actor_x_fns: dict[str, Any],
    cam_xs: list[float],
    cam_zooms: list[float],
    cam_ys: list[float],
    cam_times: list[float],
    ink: str,
    red: str,
    paper: str,
    comp_duration: float,
    sample_step: float = 0.1,
) -> None:
    """Dispatch all fx entries from resolved.json to the correct emitter."""
    lines.append("// ======================================================")
    lines.append("// FX SYSTEM (v3)")
    lines.append("// ======================================================")
    lines.append("")

    for fx in fx_list:
        kind = fx.get("kind", "")
        t = float(fx.get("t", 0.0))
        n = int(fx.get("n", 24))

        # Determine world_x
        if "x" in fx:
            world_x = float(fx["x"])
        elif "actor" in fx:
            actor = fx["actor"]
            fn = actor_x_fns.get(actor)
            world_x = fn(t) if fn else 0.0
        else:
            world_x = 0.0

        # Determine world_y for sparkle (hand position)
        world_y = -40.0  # default hand height above ground

        if kind == "dust":
            small = bool(fx.get("small", False))
            emit_fx_dust(lines, world_x, t, small, ink, comp_duration)

        elif kind == "sparkle":
            # anchor=hand: hand is at ~world_x+70, world_y=-40
            if fx.get("anchor") == "hand":
                world_y = -40.0
                world_x_h = world_x + 70.0
            else:
                world_x_h = world_x
            emit_fx_sparkle(lines, world_x_h, world_y, t, ink, comp_duration)

        elif kind == "speed_lines":
            actor = fx.get("actor", "")
            fn = actor_x_fns.get(actor)
            if fn:
                t1 = float(fx.get("t1", t + 1.0))
                emit_fx_speed_lines(lines, fn, t, t1, ink, comp_duration)

        elif kind == "confetti":
            emit_fx_confetti(lines, world_x, t, n, comp_duration)

        elif kind == "money_rain":
            emit_fx_money_rain(lines, world_x, t, ink, paper, comp_duration)

        elif kind == "shockwave":
            emit_fx_shockwave(lines, world_x, t, ink, comp_duration)

        elif kind == "emphasis":
            glyph = fx.get("glyph", "!")
            actor = fx.get("actor", "")
            fn = actor_x_fns.get(actor)
            sx, sy = actor_screen_pos(fn or (lambda t: world_x), t,
                                       cam_xs, cam_zooms, cam_ys, cam_times, sample_step)
            emit_fx_emphasis(lines, glyph, sx, sy - 60.0, t, ink, red, comp_duration)
            # Also emit burst behind emphasis (keyword burst)
            emit_fx_burst(lines, sx, sy - 60.0, t, ink, comp_duration)
