"""build_ae4.py — Director-mode v4 AE renderer.

Generates a pure ES3 ExtendScript (.jsx) for After Effects 2026 that renders
the multi-actor economics short film (Doodle4 comp, 37s).

v4 changes vs v3:
  - Comp name: Doodle4
  - v4 camera: look-ahead follow, closeup 1/3 frame, smoothstep transitions
  - Gradient Ramp sky + ground shade strip (精致扁平)
  - NO Roughen Edges, NO Fractal Noise paper texture (去手绘)
  - Props: v4 palette (white/shade/accent/cloud/none fill resolution)
  - Props: parallax<1 layers use dense position keyframes,
    child_x = (world_x*p + cam_x*(1-p))*K  (spec: screen_x=(world_x-cam_x)*p*K*zoom+960)
  - Cloud: ±60px 12s drift
  - Subtitles: no frame box, ink text + Glow effect halo
  - Keywords: pop-in, hold 1.2s, fade 0.3s, hard inPoint/outPoint window
  - FX: 6 new kinds (trail/flash/shake/ambient/light_rays/glow_pulse)
       + original 8 kinds from ae_v3_ext
  - Shake: pre-pass mutates cam_xs before keyframes are emitted
  - motionBlur preserved on moving layers
  - Vignette kept (no paper texture)

Usage:
    py -3.14 scripts/build_ae4.py <resolved.json> <plan.json> <rig_spec.json>
        <props.json> <voice.mp3_absolute_path> <out.jsx>
"""

from __future__ import annotations

import json
import math
import sys
from pathlib import Path
from typing import Any

import ae_v3_ext   # legacy FX (dust/sparkle/speed_lines/confetti/money_rain/shockwave/emphasis/burst)
import ae_v4_ext   # v4: new FX + cam + sky + subtitle + prop renderer

# ---------------------------------------------------------------------------
# Coordinate constants (identical to v3)
# ---------------------------------------------------------------------------
K: float = 0.9
COMP_W: float = 1920.0
COMP_H: float = 1080.0
CX: float = 960.0
CY: float = 540.0
GROUND_SVG_Y: float = 200.0
GROUND_CHILD_Y: float = GROUND_SVG_Y * K   # 180.0
SAMPLE_STEP: float = 0.1


def lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


def ease_in_out(t: float) -> float:
    t = max(0.0, min(1.0, t))
    return t * t * (3.0 - 2.0 * t)


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").replace("\r", "\\r")
    return '"' + escaped + '"'


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


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


# ---------------------------------------------------------------------------
# Camera null position (same formula as v3)
# ---------------------------------------------------------------------------

def cam_null_position(cam_x: float, zoom: float, cam_y: float = 0.0) -> tuple[float, float]:
    px = 960.0 - cam_x * K * zoom
    py = 800.0 - 180.0 * zoom - cam_y * K * zoom
    return (px, py)


def cam_null_scale(zoom: float) -> float:
    return zoom * 100.0


# ---------------------------------------------------------------------------
# Actor position track (identical to v3)
# ---------------------------------------------------------------------------

def build_actor_x_track(actor_data: dict) -> Any:
    x0 = float(actor_data["x0"])
    events: list[list[Any]] = []
    for m in actor_data.get("moves", []):
        events.append(["move", float(m["t0"]), float(m["t1"]),
                       (float(m["from"]) if "from" in m else None), float(m["to"])])
    for p in actor_data.get("places", []):
        events.append(["place", float(p["t"]), float(p["t"]), None, float(p["x"])])
    events.sort(key=lambda e: e[1])

    cur = x0
    for ev in events:
        if ev[0] == "move":
            if ev[3] is None:
                ev[3] = cur
            cur = ev[4]
        else:
            cur = ev[4]

    def actor_x(t: float) -> float:
        x = x0
        for kind, t0e, t1e, frm, to in events:
            if kind == "place":
                if t0e <= t:
                    x = to
            else:
                if t >= t1e:
                    x = to
                elif t0e <= t < t1e:
                    dur = t1e - t0e
                    x = lerp(frm, to, (t - t0e) / dur) if dur > 0.001 else to
        return x

    return actor_x


# ---------------------------------------------------------------------------
# Coin position track (identical to v3)
# ---------------------------------------------------------------------------

def build_coin_track(segments: list[dict], actor_x_fns: dict) -> Any:
    def hand_pos(carrier: str, t: float) -> tuple[float, float]:
        fn = actor_x_fns.get(carrier, lambda t: 0.0)
        cx = fn(t)
        hand_x = cx + 70.0
        hand_y = -40.0
        return hand_x, hand_y

    def coin_pos(t: float) -> tuple[float, float]:
        for seg in segments:
            t0 = float(seg["t0"])
            t1 = float(seg["t1"])
            if t < t0:
                continue
            if t > t1 and seg != segments[-1]:
                continue
            mode = seg["mode"]
            if mode == "carried":
                return hand_pos(seg["carrier"], t)
            elif mode == "arc":
                if "from_carrier" in seg:
                    x_start, y_start = hand_pos(seg["from_carrier"], t0)
                else:
                    x_start = float(seg.get("from_x", 0.0))
                    y_start = -40.0
                if "to_carrier" in seg:
                    x_end, y_end = hand_pos(seg["to_carrier"], t1)
                else:
                    x_end = float(seg.get("to_x", 0.0))
                    y_end = -40.0
                dur = t1 - t0
                alpha = (t - t0) / dur if dur > 0.001 else 1.0
                alpha = max(0.0, min(1.0, alpha))
                x_mid = lerp(x_start, x_end, alpha)
                arc_h = 250.0
                y_arc = lerp(y_start, y_end, alpha) - arc_h * 4.0 * alpha * (1.0 - alpha)
                return x_mid, y_arc
            elif mode == "orbit":
                center_x = float(seg["center_x"])
                center_y = -350.0
                radius = float(seg["radius"])
                loops = float(seg.get("loops", 2.0))
                dur = t1 - t0
                alpha = (t - t0) / dur if dur > 0.001 else 1.0
                alpha = max(0.0, min(1.0, alpha))
                angle = -math.pi / 2.0 + alpha * loops * 2.0 * math.pi
                cx = center_x + radius * math.cos(angle)
                cy = center_y + radius * math.sin(angle)
                return cx, cy
            elif mode == "hops":
                from_x = float(seg["from"])
                to_x = float(seg["to"])
                dur = t1 - t0
                alpha = (t - t0) / dur if dur > 0.001 else 1.0
                alpha = max(0.0, min(1.0, alpha))
                x_pos = lerp(from_x, to_x, alpha)
                hop_period = 0.45
                elapsed = (t - t0)
                phase = (elapsed % hop_period) / hop_period
                hop_y = -120.0 * math.sin(math.pi * phase)
                return x_pos, (GROUND_SVG_Y - 50.0) + hop_y
            elif mode == "shrink":
                return hand_pos(seg.get("carrier", "xiaoming"), t)
            elif mode == "hidden":
                return -9999.0, -9999.0
            elif mode == "static":
                return float(seg["x"]), -350.0
        last = segments[-1]
        if last["mode"] == "static":
            return float(last["x"]), -350.0
        if last["mode"] == "hops":
            return float(last["to"]), GROUND_SVG_Y - 50.0
        return 0.0, 0.0

    return coin_pos


# ---------------------------------------------------------------------------
# JSX keyframe helpers (same as v3)
# ---------------------------------------------------------------------------

def add_kf(lines: list[str], prop_expr: str, time: float, value: str) -> None:
    lines.append("    " + prop_expr + ".setValueAtTime(" + r(time) + ", " + value + ");")


# ---------------------------------------------------------------------------
# Shape drawing helpers (same as v3 — used for character rigs only)
# ---------------------------------------------------------------------------

def emit_polyline(
    lines: list[str], grp_var: str, pts: list[list[float]],
    closed: bool, fill: str | None, stroke_color: str, stroke_width: float, sub_index: int,
) -> None:
    sp = "    "
    iso_var = grp_var + "_iso" + str(sub_index)
    lines.append(sp + "var " + iso_var + " = " + grp_var + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    path_var = grp_var + "_path" + str(sub_index)
    lines.append(sp + "var " + path_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
    verts = "[" + ", ".join("[" + r(float(p[0])) + "," + r(float(p[1])) + "]" for p in pts) + "]"
    tans = "[" + ", ".join("[0,0]" for _ in pts) + "]"
    shape_var = path_var + "_sh"
    lines.append(sp + "var " + shape_var + " = new Shape();")
    lines.append(sp + shape_var + ".vertices = " + verts + ";")
    lines.append(sp + shape_var + ".inTangents = " + tans + ";")
    lines.append(sp + shape_var + ".outTangents = " + tans + ";")
    lines.append(sp + shape_var + ".closed = " + ("true" if closed else "false") + ";")
    lines.append(sp + path_var + ".property(\"Path\").setValue(" + shape_var + ");")
    if fill is not None:
        fill_var = path_var + "_fill"
        lines.append(sp + "var " + fill_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(sp + fill_var + ".property(\"Color\").setValue(" + js_color(fill) + ");")
    stroke_var = path_var + "_stroke"
    lines.append(sp + "var " + stroke_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
    lines.append(sp + stroke_var + ".property(\"Color\").setValue(" + js_color(stroke_color) + ");")
    lines.append(sp + stroke_var + ".property(\"Stroke Width\").setValue(" + r(float(stroke_width)) + ");")
    lines.append(sp + stroke_var + ".property(\"Line Cap\").setValue(2);")


def emit_circle_prim(
    lines: list[str], grp_var: str, cx: float, cy: float, radius: float,
    fill: str | None, stroke_color: str, stroke_width: float, sub_index: int,
) -> None:
    sp = "    "
    iso_var = grp_var + "_cIso" + str(sub_index)
    lines.append(sp + "var " + iso_var + " = " + grp_var + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    ellipse_var = grp_var + "_ell" + str(sub_index)
    lines.append(sp + "var " + ellipse_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
    lines.append(sp + ellipse_var + ".property(\"Size\").setValue([" + r(radius * 2) + "," + r(radius * 2) + "]);")
    lines.append(sp + ellipse_var + ".property(\"Position\").setValue([" + r(cx) + "," + r(cy) + "]);")
    if fill is not None:
        fill_var = ellipse_var + "_fill"
        lines.append(sp + "var " + fill_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(sp + fill_var + ".property(\"Color\").setValue(" + js_color(fill) + ");")
    stroke_var = ellipse_var + "_stroke"
    lines.append(sp + "var " + stroke_var + " = " + iso_var + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
    lines.append(sp + stroke_var + ".property(\"Color\").setValue(" + js_color(stroke_color) + ");")
    lines.append(sp + stroke_var + ".property(\"Stroke Width\").setValue(" + r(float(stroke_width)) + ");")


# ---------------------------------------------------------------------------
# SVG rig helpers (identical to v3)
# ---------------------------------------------------------------------------

def svg_to_rig(svg_x: float, svg_y: float) -> tuple[float, float]:
    return (svg_x * K, svg_y * K)


def emit_rig(lines: list[str], prefix: str, rig: dict, ink: str, paper: str) -> None:
    sp = "    "
    joints = rig["joints"]
    stroke_width = int(rig.get("stroke_width", 18))

    body_j = joints["body"]
    body_pivot_svg = tuple(body_j["from"])

    def emit_limb(var_name: str, from_svg: tuple, to_svg: tuple, pivot_svg: tuple,
                  color: str = ink, sw: int = stroke_width) -> None:
        from_rel = (from_svg[0] * K - pivot_svg[0] * K, from_svg[1] * K - pivot_svg[1] * K)
        to_rel   = (to_svg[0] * K - pivot_svg[0] * K,   to_svg[1] * K - pivot_svg[1] * K)
        lines.append("")
        lines.append(sp + "// --- " + var_name + " ---")
        lines.append(sp + "var " + var_name + " = comp.layers.addShape();")
        lines.append(sp + var_name + ".name = " + js_str(var_name) + ";")
        grp = var_name + "_grp"
        lines.append(sp + "var " + grp + " = " + var_name + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        path_v = var_name + "_path"
        lines.append(sp + "var " + path_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
        lines.append(sp + "var " + var_name + "_sh = new Shape();")
        lines.append(sp + var_name + "_sh.vertices = [[" + r(from_rel[0]) + "," + r(from_rel[1]) + "],[" + r(to_rel[0]) + "," + r(to_rel[1]) + "]];")
        lines.append(sp + var_name + "_sh.inTangents = [[0,0],[0,0]];")
        lines.append(sp + var_name + "_sh.outTangents = [[0,0],[0,0]];")
        lines.append(sp + var_name + "_sh.closed = false;")
        lines.append(sp + path_v + ".property(\"Path\").setValue(" + var_name + "_sh);")
        lines.append(sp + "var " + var_name + "_stroke = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(sp + var_name + "_stroke.property(\"Color\").setValue(" + js_color(color) + ");")
        lines.append(sp + var_name + "_stroke.property(\"Stroke Width\").setValue(" + str(sw) + ");")
        lines.append(sp + var_name + "_stroke.property(\"Line Cap\").setValue(2);")
        pivot_rig = (pivot_svg[0] * K - body_pivot_svg[0] * K,
                     pivot_svg[1] * K - body_pivot_svg[1] * K)
        lines.append(sp + var_name + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(sp + var_name + ".property(\"Transform\").property(\"Position\").setValue([" + r(pivot_rig[0]) + "," + r(pivot_rig[1]) + "]);")

    def emit_head_circle(var_name: str, cx_svg: float, cy_svg: float, radius: float,
                         fill_c: str, stroke_c: str, sw: int = stroke_width) -> None:
        lines.append("")
        lines.append(sp + "// --- " + var_name + " ---")
        lines.append(sp + "var " + var_name + " = comp.layers.addShape();")
        lines.append(sp + var_name + ".name = " + js_str(var_name) + ";")
        grp = var_name + "_grp"
        lines.append(sp + "var " + grp + " = " + var_name + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        ell_v = var_name + "_ell"
        lines.append(sp + "var " + ell_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
        lines.append(sp + ell_v + ".property(\"Size\").setValue([" + r(radius * 2 * K) + "," + r(radius * 2 * K) + "]);")
        lines.append(sp + ell_v + ".property(\"Position\").setValue([0,0]);")
        lines.append(sp + "var " + var_name + "_fill = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(sp + var_name + "_fill.property(\"Color\").setValue(" + js_color(fill_c) + ");")
        lines.append(sp + "var " + var_name + "_stroke = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
        lines.append(sp + var_name + "_stroke.property(\"Color\").setValue(" + js_color(stroke_c) + ");")
        lines.append(sp + var_name + "_stroke.property(\"Stroke Width\").setValue(" + str(sw) + ");")
        head_rig = ((cx_svg - body_pivot_svg[0]) * K, (cy_svg - body_pivot_svg[1]) * K)
        lines.append(sp + var_name + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(sp + var_name + ".property(\"Transform\").property(\"Position\").setValue([" + r(head_rig[0]) + "," + r(head_rig[1]) + "]);")

    body_var = prefix + "_body"
    emit_limb(body_var, tuple(body_j["from"]), tuple(body_j["to"]), tuple(body_j["from"]))

    armL = joints["armL"]
    armL_upper_var = prefix + "_armL_upper"
    armL_fore_var = prefix + "_armL_fore"
    emit_limb(armL_upper_var, tuple(armL["upper"]["from"]), tuple(armL["upper"]["to"]), tuple(armL["pivot_shoulder"]))
    emit_limb(armL_fore_var, tuple(armL["fore"]["from"]), tuple(armL["fore"]["to"]), tuple(armL["pivot_elbow"]))

    armR = joints["armR"]
    armR_upper_var = prefix + "_armR_upper"
    armR_fore_var = prefix + "_armR_fore"
    emit_limb(armR_upper_var, tuple(armR["upper"]["from"]), tuple(armR["upper"]["to"]), tuple(armR["pivot_shoulder"]))
    emit_limb(armR_fore_var, tuple(armR["fore"]["from"]), tuple(armR["fore"]["to"]), tuple(armR["pivot_elbow"]))

    legL = joints["legL"]
    legL_thigh_var = prefix + "_legL_thigh"
    legL_shin_var  = prefix + "_legL_shin"
    emit_limb(legL_thigh_var, tuple(legL["thigh"]["from"]), tuple(legL["thigh"]["to"]), tuple(legL["pivot_hip"]))
    emit_limb(legL_shin_var, tuple(legL["shin"]["from"]), tuple(legL["shin"]["to"]), tuple(legL["pivot_knee"]))

    legR = joints["legR"]
    legR_thigh_var = prefix + "_legR_thigh"
    legR_shin_var  = prefix + "_legR_shin"
    emit_limb(legR_thigh_var, tuple(legR["thigh"]["from"]), tuple(legR["thigh"]["to"]), tuple(legR["pivot_hip"]))
    emit_limb(legR_shin_var, tuple(legR["shin"]["from"]), tuple(legR["shin"]["to"]), tuple(legR["pivot_knee"]))

    head_j = joints["head"]
    head_var = prefix + "_head"
    emit_head_circle(head_var, float(head_j["cx"]), float(head_j["cy"]),
                     float(head_j["r"]), paper, ink)

    lines.append("")
    lines.append(sp + "// FK parent chain for " + prefix)

    def emit_fk_parent(child_var: str, parent_var: str, child_pivot_svg: tuple, parent_pivot_svg: tuple) -> None:
        dx = (child_pivot_svg[0] - parent_pivot_svg[0]) * K
        dy = (child_pivot_svg[1] - parent_pivot_svg[1]) * K
        lines.append(sp + child_var + ".parent = " + parent_var + ";")
        lines.append(sp + child_var + ".property(\"Transform\").property(\"Position\").setValue([" + r(dx) + "," + r(dy) + "]);")

    emit_fk_parent(armL_fore_var, armL_upper_var, tuple(armL["pivot_elbow"]), tuple(armL["pivot_shoulder"]))
    emit_fk_parent(armL_upper_var, body_var, tuple(armL["pivot_shoulder"]), tuple(body_j["from"]))
    emit_fk_parent(armR_fore_var, armR_upper_var, tuple(armR["pivot_elbow"]), tuple(armR["pivot_shoulder"]))
    emit_fk_parent(armR_upper_var, body_var, tuple(armR["pivot_shoulder"]), tuple(body_j["from"]))
    emit_fk_parent(legL_shin_var, legL_thigh_var, tuple(legL["pivot_knee"]), tuple(legL["pivot_hip"]))
    emit_fk_parent(legL_thigh_var, body_var, tuple(legL["pivot_hip"]), tuple(body_j["from"]))
    emit_fk_parent(legR_shin_var, legR_thigh_var, tuple(legR["pivot_knee"]), tuple(legR["pivot_hip"]))
    emit_fk_parent(legR_thigh_var, body_var, tuple(legR["pivot_hip"]), tuple(body_j["from"]))
    emit_fk_parent(head_var, body_var, (float(head_j["cx"]), float(head_j["cy"])), tuple(body_j["from"]))
    lines.append(sp + head_var + ".moveAfter(" + armL_upper_var + ");")

    root_var = prefix + "_root"
    lines.append("")
    lines.append(sp + "// Root null for " + prefix)
    lines.append(sp + "var " + root_var + " = comp.layers.addNull(comp.duration);")
    lines.append(sp + root_var + ".name = " + js_str(root_var) + ";")
    lines.append(sp + root_var + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    root_y_offset = float(body_j["from"][1]) * K
    lines.append(sp + root_var + ".property(\"Transform\").property(\"Position\").setValue([0," + r(root_y_offset) + "]);")
    lines.append(sp + body_var + ".parent = " + root_var + ";")
    lines.append(sp + body_var + ".property(\"Transform\").property(\"Position\").setValue([0,0]);")


# ---------------------------------------------------------------------------
# Main JSX builder (v4)
# ---------------------------------------------------------------------------

def build(
    resolved: dict[str, Any],
    plan: dict[str, Any],
    rig: dict[str, Any],
    props_spec: dict[str, Any],
    voice_path: str,
    out_path: str,
) -> list[str]:
    scenes = plan["scenes"]
    audio_duration = float(resolved["meta"]["audio_duration"])
    comp_duration = audio_duration + 1.0
    ground_y = float(resolved["meta"].get("ground_y", 200.0))
    actors_data = resolved["actors"]
    items_data = resolved["items"]
    props_list = resolved["props"]
    camera_segs = resolved["camera"]
    fx_list = resolved.get("fx", [])

    palette = rig["palette"]
    ink = palette["ink"]
    paper = palette["paper"]
    red = palette["red"]
    pose_presets = rig["pose_presets"]
    joints = rig["joints"]
    body_j = joints["body"]
    body_pivot_svg = tuple(body_j["from"])

    lines: list[str] = []
    L = lines.append

    # Build actor position functions
    actor_x_fns: dict[str, Any] = {}
    for aname, adata in actors_data.items():
        actor_x_fns[aname] = build_actor_x_track(adata)

    coin_segs = items_data["coin"]["segments"]
    coin_pos_fn = build_coin_track(coin_segs, actor_x_fns)
    item_positions = {"coin": coin_pos_fn}

    # v4 camera: look-ahead + smoothstep
    cam_times, cam_xs, cam_zooms, cam_ys = ae_v4_ext.build_cam_track_v4(
        camera_segs, actor_x_fns, item_positions, comp_duration, SAMPLE_STEP
    )

    # Pre-pass: apply shake FX to cam_xs (mutates the list in-place)
    for fx in fx_list:
        if fx.get("kind") == "shake":
            t_sh = float(fx.get("t", 0.0))
            amp_sh = float(fx.get("amp", 12.0))
            dur_sh = float(fx.get("dur", 0.35))
            ae_v4_ext.apply_shake_to_cam(cam_times, cam_xs, cam_ys, t_sh, amp_sh, dur_sh)

    # Hard-cut times
    hard_cut_times: set[float] = set()
    for seg in camera_segs:
        if seg.get("hard_cut"):
            hard_cut_times.add(round(float(seg["t0"]), 6))

    # -----------------------------------------------------------------------
    # JSX Header
    # -----------------------------------------------------------------------
    L("// Doodle4 Build — generated by build_ae4.py (director mode v4)")
    L("// ES3 compatible: var/function/string concat only. No const/let/arrow/template.")
    L("")
    L("app.beginUndoGroup(\"Doodle4 Build\");")
    L("")
    L("var proj = app.project;")
    L("")

    # Audio
    voice_js = js_str(voice_path.replace("\\", "/"))
    L("// --- Import voice audio ---")
    L("var audioFile = new File(" + voice_js + ");")
    L("var audioIO = new ImportOptions(audioFile);")
    L("audioIO.importAs = ImportAsType.FOOTAGE;")
    L("var audioItem = proj.importFile(audioIO);")
    L("")

    # Composition (v4: Doodle4)
    L("// --- Create composition Doodle4 (v4) ---")
    L("var comp = proj.items.addComp(\"Doodle4\", 1920, 1080, 1, " + r(comp_duration) + ", 30);")
    L("comp.pixelAspect = 1;")
    L("comp.motionBlur = true;")
    L("")

    L("// --- Audio layer ---")
    L("var audioLayer = comp.layers.add(audioItem);")
    L("audioLayer.startTime = 0;")
    L("")

    # Paper background (v4: sky_top color)
    sky_top = ae_v4_ext.PALETTE_V4["sky_top"]
    L("// --- Paper background ---")
    L("comp.bgColor = " + js_color3(sky_top) + ";")
    L("var bgSolid = comp.layers.addSolid(" + js_color3(sky_top) + ", \"PaperBG\", 1920, 1080, 1, " + r(comp_duration) + ");")
    L("bgSolid.moveToEnd();")
    L("// Background prop stack anchor: parallax<1 props are re-stacked just above")
    L("// PaperBG (AE adds layers on TOP, opposite of SVG paint order — without this")
    L("// the skyline/clouds would cover foreground props like the cake).")
    L("var bgStackTop = bgSolid;")
    L("")

    # CamCtrl
    L("// --- CamCtrl null ---")
    L("var camCtrl = comp.layers.addNull(comp.duration);")
    L("camCtrl.name = \"CamCtrl\";")
    L("camCtrl.property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    pos0 = cam_null_position(cam_xs[0], cam_zooms[0], cam_ys[0])
    sc0 = cam_null_scale(cam_zooms[0])
    L("camCtrl.property(\"Transform\").property(\"Position\").setValue([" + r(pos0[0]) + "," + r(pos0[1]) + "]);")
    L("camCtrl.property(\"Transform\").property(\"Scale\").setValue([" + r(sc0) + "," + r(sc0) + "]);")
    L("")

    L("// --- CamCtrl dense keyframes (v4: look-ahead + shake pre-baked) ---")
    L("var camPos = camCtrl.property(\"Transform\").property(\"Position\");")
    L("var camScale = camCtrl.property(\"Transform\").property(\"Scale\");")

    for i, t in enumerate(cam_times):
        pos = cam_null_position(cam_xs[i], cam_zooms[i], cam_ys[i])
        sc = cam_null_scale(cam_zooms[i])
        t_rounded = round(t, 6)
        if t_rounded in hard_cut_times and i > 0:
            pos_prev = cam_null_position(cam_xs[i - 1], cam_zooms[i - 1], cam_ys[i - 1])
            sc_prev = cam_null_scale(cam_zooms[i - 1])
            t_hold = round(t - 0.001, 6)
            L("    camPos.setValueAtTime(" + r(t_hold) + ", [" + r(pos_prev[0]) + "," + r(pos_prev[1]) + "]);")
            L("    camScale.setValueAtTime(" + r(t_hold) + ", [" + r(sc_prev) + "," + r(sc_prev) + "]);")
        L("    camPos.setValueAtTime(" + r(t) + ", [" + r(pos[0]) + "," + r(pos[1]) + "]);")
        L("    camScale.setValueAtTime(" + r(t) + ", [" + r(sc) + "," + r(sc) + "]);")
    L("")

    # Ground line
    L("// --- Ground line ---")
    L("var groundLayer = comp.layers.addShape();")
    L("groundLayer.name = \"Ground\";")
    L("var groundGrp = groundLayer.property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    L("groundGrp.name = \"GroundLine\";")
    L("var groundPath = groundGrp.property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
    L("var groundShape = new Shape();")
    L("groundShape.vertices = [[-5000, 0], [5000, 0]];")
    L("groundShape.inTangents = [[0,0],[0,0]];")
    L("groundShape.outTangents = [[0,0],[0,0]];")
    L("groundShape.closed = false;")
    L("groundPath.property(\"Path\").setValue(groundShape);")
    L("var groundStroke = groundGrp.property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
    L("groundStroke.property(\"Color\").setValue(" + js_color(ink) + ");")
    L("groundStroke.property(\"Stroke Width\").setValue(4);")
    ground_child_y = ground_y * K
    L("groundLayer.parent = camCtrl;")
    L("groundLayer.property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    L("groundLayer.property(\"Transform\").property(\"Position\").setValue([0," + r(ground_child_y) + "]);")
    L("groundLayer.property(\"Transform\").property(\"Scale\").setValue([100,100]);")
    L("")

    # Props (v4: palette-aware, parallax, cloud drift)
    L("// ======================================================")
    L("// PROPS (v4)")
    L("// ======================================================")
    L("")
    for prop in props_list:
        ae_v4_ext.emit_prop_v4(
            lines, prop, props_spec, ink, paper, red,
            ground_child_y, cam_xs, cam_zooms, cam_ys, cam_times,
            comp_duration, SAMPLE_STEP
        )

    # Characters (identical to v3)
    L("// ======================================================")
    L("// CHARACTER RIGS")
    L("// ======================================================")
    L("")

    actor_names = list(actors_data.keys())

    for aname in actor_names:
        adata = actors_data[aname]
        L("")
        L("// ======================== " + aname + " ========================")
        emit_rig(lines, aname, rig, ink, paper)
        root_var = aname + "_root"

        L("")
        L("    // Parent " + aname + " root to CamCtrl")
        L("    " + root_var + ".parent = camCtrl;")

        x0 = float(adata["x0"])
        root_y_offset = float(body_j["from"][1]) * K
        L("    " + root_var + ".property(\"Transform\").property(\"Position\").setValue([" + r(x0 * K) + "," + r(root_y_offset) + "]);")

        hidden0 = bool(adata.get("hidden0", False))

        vis_points: list[tuple[float, float]] = [(0.0, 0.0 if hidden0 else 100.0)]
        for vis_ev in adata.get("vis", []):
            vt = float(vis_ev["t"])
            new_val = 100.0 if bool(vis_ev["on"]) else 0.0
            vis_points.append((round(max(0.0, vt - 0.01), 6), vis_points[-1][1]))
            vis_points.append((round(vt + 0.3, 6), new_val))

        def vis_at(t: float) -> float:
            val = vis_points[0][1]
            for vi in range(1, len(vis_points)):
                ta, va = vis_points[vi - 1]
                tb, vb = vis_points[vi]
                if t <= ta:
                    break
                if t >= tb:
                    val = vb
                else:
                    val = va + (vb - va) * (t - ta) / (tb - ta) if tb - ta > 1e-9 else vb
            return val

        part_suffixes = ["_body", "_head",
                         "_armL_upper", "_armL_fore", "_armR_upper", "_armR_fore",
                         "_legL_thigh", "_legL_shin", "_legR_thigh", "_legR_shin"]
        if hidden0 or adata.get("vis"):
            L("")
            L("    // Visibility opacity on every rig part of " + aname)
            for sfx in part_suffixes:
                p_op = aname + sfx + ".property(\"Transform\").property(\"Opacity\")"
                for vp_t, vp_v in vis_points:
                    L("    " + p_op + ".setValueAtTime(" + r(vp_t) + ", " + r(vp_v) + ");")

        # Dense position keyframes
        bounce_windows: list[tuple[float, float, float]] = []
        for bounce in adata.get("bounces", []):
            b_t = float(bounce["t"])
            b_h = float(bounce.get("height", 50.0))
            b_times = int(bounce.get("times", 1))
            hop_dur = 0.3
            for bi in range(b_times):
                bt0 = b_t + bi * hop_dur
                bounce_windows.append((bt0, bt0 + hop_dur, b_h))

        sample_ts: set[float] = set()
        t_cur = 0.0
        while t_cur <= comp_duration + 0.05:
            sample_ts.add(round(t_cur, 6))
            t_cur = round(t_cur + SAMPLE_STEP, 6)
        for bt0, bt1, _bh in bounce_windows:
            for et in (bt0, (bt0 + bt1) / 2.0, bt1):
                if 0.0 <= et <= comp_duration:
                    sample_ts.add(round(et, 6))

        x_fn = actor_x_fns[aname]
        L("")
        L("    // Dense position keyframes for " + aname)
        L("    var " + aname + "_posP = " + root_var + ".property(\"Transform\").property(\"Position\");")
        L("    var " + aname + "_scaleP = " + root_var + ".property(\"Transform\").property(\"Scale\");")

        prev_x = x0
        prev_facing = 1
        for t_cur in sorted(sample_ts):
            cur_x = x_fn(t_cur)
            dx = cur_x - prev_x
            facing = 1 if dx >= 0 else -1
            if abs(dx) < 0.001:
                facing = prev_facing
            y_off = 0.0
            for bt0, bt1, bh in bounce_windows:
                if bt0 <= t_cur <= bt1:
                    y_off = -bh * K * math.sin(math.pi * (t_cur - bt0) / (bt1 - bt0))
                    break
            L("    " + aname + "_posP.setValueAtTime(" + r(t_cur) + ", [" + r(cur_x * K) + "," + r(root_y_offset + y_off) + "]);")
            sc_x = facing * 100
            L("    " + aname + "_scaleP.setValueAtTime(" + r(t_cur) + ", [" + str(sc_x) + ",100]);")
            prev_x = cur_x
            prev_facing = facing

        # Gait keyframes
        rotation_map = {
            "armL_shoulder": aname + "_armL_upper",
            "armL_elbow":    aname + "_armL_fore",
            "armR_shoulder": aname + "_armR_upper",
            "armR_elbow":    aname + "_armR_fore",
            "legL_hip":      aname + "_legL_thigh",
            "legL_knee":     aname + "_legL_shin",
            "legR_hip":      aname + "_legR_thigh",
            "legR_knee":     aname + "_legR_shin",
            "body":          aname + "_body",
            "head":          aname + "_head",
        }

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

        for move in adata.get("moves", []):
            mt0 = float(move["t0"])
            mt1 = float(move["t1"])
            gait = move.get("gait", "walk")
            if gait == "walk":
                half = 0.4; body_tilt = 2; arm_amp = 25; leg_amp = 22
            else:
                half = 0.25; body_tilt = 8; arm_amp = int(25 * 1.2); leg_amp = int(22 * 1.2)
            t_lead = round(max(0.0, mt0 - 0.15), 6)
            for field in ["legL_hip", "legR_hip", "armL_shoulder", "armR_shoulder", "legL_knee", "legR_knee"]:
                L("    " + rot_prop_for(field) + ".setValueAtTime(" + r(t_lead) + ", 0);")
            t_g = mt0
            step = 0
            while t_g < mt1:
                sign = 1 if (step % 2 == 0) else -1
                L("    " + rot_prop_for("legL_hip") + ".setValueAtTime(" + r(t_g) + ", " + str(leg_amp * sign) + ");")
                L("    " + rot_prop_for("legR_hip") + ".setValueAtTime(" + r(t_g) + ", " + str(-leg_amp * sign) + ");")
                L("    " + rot_prop_for("armL_shoulder") + ".setValueAtTime(" + r(t_g) + ", " + str(-arm_amp * sign) + ");")
                L("    " + rot_prop_for("armR_shoulder") + ".setValueAtTime(" + r(t_g) + ", " + str(arm_amp * sign) + ");")
                L("    " + rot_prop_for("legL_knee") + ".setValueAtTime(" + r(t_g) + ", " + str(int(15 * sign)) + ");")
                L("    " + rot_prop_for("legR_knee") + ".setValueAtTime(" + r(t_g) + ", " + str(int(-10 * sign)) + ");")
                t_g = round(t_g + half, 6)
                step += 1
            for field in ["legL_hip", "legR_hip", "armL_shoulder", "armR_shoulder", "legL_knee", "legR_knee", "body", "head"]:
                L("    " + rot_prop_for(field) + ".setValueAtTime(" + r(mt1) + ", 0);")

        for pose_ev in adata.get("poses", []):
            p_t = float(pose_ev["t"])
            preset_name = pose_ev.get("preset", "idle")
            if preset_name not in pose_presets:
                preset_name = "idle"
            pose = pose_presets[preset_name]
            for field, target_val in pose.items():
                if field.startswith("_"):
                    continue
                target = float(target_val)
                prop = rot_prop_for(field)
                t_ant = round(p_t - 0.2, 6)
                t_ov = round(p_t + 0.35, 6)
                t_set = round(p_t + 0.6, 6)
                ant_val = round(-8.0 if target >= 0 else 8.0, 4)
                ov_val = round(target * 1.1, 4)
                if t_ant < 0:
                    t_ant = 0.0
                L("    " + prop + ".setValueAtTime(" + r(t_ant) + ", " + r(ant_val) + ");")
                L("    " + prop + ".setValueAtTime(" + r(p_t) + ", " + r(target) + ");")
                L("    " + prop + ".setValueAtTime(" + r(t_ov) + ", " + r(ov_val) + ");")
                L("    " + prop + ".setValueAtTime(" + r(t_set) + ", " + r(target) + ");")

        # Face layers
        face_names = ["calm", "sad", "angry", "surprised"]
        face0 = adata.get("face0", "calm")
        faces_events = adata.get("faces", [])

        face_timeline: list[tuple[float, str]] = [(0.0, face0)]
        for fev in faces_events:
            face_timeline.append((float(fev["t"]), fev["face"]))
        face_timeline.sort(key=lambda x: x[0])

        L("")
        L("    // Face layers for " + aname)
        head_var = aname + "_head"
        for fn_i, fname in enumerate(face_names):
            fv = aname + "_face_" + fname
            L("    var " + fv + " = comp.layers.addShape();")
            L("    " + fv + ".name = " + js_str(aname + "_face_" + fname) + ";")

            face_grp = fv + "_grp"
            L("    var " + face_grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")

            if fname == "calm":
                L("    var " + fv + "_eyeL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeL.property(\"Size\").setValue([10,10]);")
                L("    " + fv + "_eyeL.property(\"Position\").setValue([-18,5]);")
                L("    var " + fv + "_eyeR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeR.property(\"Size\").setValue([10,10]);")
                L("    " + fv + "_eyeR.property(\"Position\").setValue([18,5]);")
                L("    var " + fv + "_stroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_stroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_stroke.property(\"Stroke Width\").setValue(8);")
                L("    var " + fv + "_fill = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
                L("    " + fv + "_fill.property(\"Color\").setValue(" + js_color(ink) + ");")
                # brow lines (v4: add subtle brows)
                L("    var " + fv + "_bL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_bLsh = new Shape();")
                L("    " + fv + "_bLsh.vertices = [[-22,-10],[-10,-8]];")
                L("    " + fv + "_bLsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bLsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bLsh.closed = false;")
                L("    " + fv + "_bL.property(\"Path\").setValue(" + fv + "_bLsh);")
                L("    var " + fv + "_bR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_bRsh = new Shape();")
                L("    " + fv + "_bRsh.vertices = [[10,-8],[22,-10]];")
                L("    " + fv + "_bRsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bRsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bRsh.closed = false;")
                L("    " + fv + "_bR.property(\"Path\").setValue(" + fv + "_bRsh);")
                # Mouth smile
                L("    var " + fv + "_mpath = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_msh = new Shape();")
                L("    " + fv + "_msh.vertices = [[-14,22],[0,30],[14,22]];")
                L("    " + fv + "_msh.inTangents = [[0,0],[0,0],[0,0]];")
                L("    " + fv + "_msh.outTangents = [[0,0],[0,0],[0,0]];")
                L("    " + fv + "_msh.closed = false;")
                L("    " + fv + "_mpath.property(\"Path\").setValue(" + fv + "_msh);")
                L("    var " + fv + "_mstroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_mstroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_mstroke.property(\"Stroke Width\").setValue(6);")

            elif fname == "sad":
                L("    var " + fv + "_eyeL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeL.property(\"Size\").setValue([10,8]);")
                L("    " + fv + "_eyeL.property(\"Position\").setValue([-18,8]);")
                L("    var " + fv + "_eyeR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeR.property(\"Size\").setValue([10,8]);")
                L("    " + fv + "_eyeR.property(\"Position\").setValue([18,8]);")
                L("    var " + fv + "_stroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_stroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_stroke.property(\"Stroke Width\").setValue(8);")
                L("    var " + fv + "_fill = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
                L("    " + fv + "_fill.property(\"Color\").setValue(" + js_color(ink) + ");")
                # sad brows: inner corners raised
                L("    var " + fv + "_bL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_bLsh = new Shape();")
                L("    " + fv + "_bLsh.vertices = [[-22,-6],[-10,-12]];")
                L("    " + fv + "_bLsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bLsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bLsh.closed = false;")
                L("    " + fv + "_bL.property(\"Path\").setValue(" + fv + "_bLsh);")
                L("    var " + fv + "_bR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_bRsh = new Shape();")
                L("    " + fv + "_bRsh.vertices = [[10,-12],[22,-6]];")
                L("    " + fv + "_bRsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bRsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_bRsh.closed = false;")
                L("    " + fv + "_bR.property(\"Path\").setValue(" + fv + "_bRsh);")
                # tear drop
                L("    var " + fv + "_tear = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_tear.property(\"Size\").setValue([7,10]);")
                L("    " + fv + "_tear.property(\"Position\").setValue([-22,18]);")
                # Sad mouth: downward arc
                L("    var " + fv + "_mpath = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_msh = new Shape();")
                L("    " + fv + "_msh.vertices = [[-14,30],[0,22],[14,30]];")
                L("    " + fv + "_msh.inTangents = [[0,0],[0,0],[0,0]];")
                L("    " + fv + "_msh.outTangents = [[0,0],[0,0],[0,0]];")
                L("    " + fv + "_msh.closed = false;")
                L("    " + fv + "_mpath.property(\"Path\").setValue(" + fv + "_msh);")
                L("    var " + fv + "_mstroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_mstroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_mstroke.property(\"Stroke Width\").setValue(6);")

            elif fname == "angry":
                # Angry brows: slanted inward (v-shape)
                L("    var " + fv + "_ebL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_ebLsh = new Shape();")
                L("    " + fv + "_ebLsh.vertices = [[-24,-8],[-10,-4]];")
                L("    " + fv + "_ebLsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_ebLsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_ebLsh.closed = false;")
                L("    " + fv + "_ebL.property(\"Path\").setValue(" + fv + "_ebLsh);")
                L("    var " + fv + "_ebR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
                L("    var " + fv + "_ebRsh = new Shape();")
                L("    " + fv + "_ebRsh.vertices = [[10,-4],[24,-8]];")
                L("    " + fv + "_ebRsh.inTangents = [[0,0],[0,0]];")
                L("    " + fv + "_ebRsh.outTangents = [[0,0],[0,0]];")
                L("    " + fv + "_ebRsh.closed = false;")
                L("    " + fv + "_ebR.property(\"Path\").setValue(" + fv + "_ebRsh);")
                L("    var " + fv + "_eyeL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeL.property(\"Size\").setValue([12,6]);")
                L("    " + fv + "_eyeL.property(\"Position\").setValue([-18,7]);")
                L("    var " + fv + "_eyeR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeR.property(\"Size\").setValue([12,6]);")
                L("    " + fv + "_eyeR.property(\"Position\").setValue([18,7]);")
                L("    var " + fv + "_stroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_stroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_stroke.property(\"Stroke Width\").setValue(8);")
                L("    var " + fv + "_fill = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
                L("    " + fv + "_fill.property(\"Color\").setValue(" + js_color(ink) + ");")

            elif fname == "surprised":
                L("    var " + fv + "_eyeL = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeL.property(\"Size\").setValue([16,16]);")
                L("    " + fv + "_eyeL.property(\"Position\").setValue([-18,4]);")
                L("    var " + fv + "_eyeR = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_eyeR.property(\"Size\").setValue([16,16]);")
                L("    " + fv + "_eyeR.property(\"Position\").setValue([18,4]);")
                L("    var " + fv + "_mell = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
                L("    " + fv + "_mell.property(\"Size\").setValue([16,12]);")
                L("    " + fv + "_mell.property(\"Position\").setValue([0,26]);")
                L("    var " + fv + "_stroke = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
                L("    " + fv + "_stroke.property(\"Color\").setValue(" + js_color(ink) + ");")
                L("    " + fv + "_stroke.property(\"Stroke Width\").setValue(8);")
                L("    var " + fv + "_fill = " + face_grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
                L("    " + fv + "_fill.property(\"Color\").setValue(" + js_color(ink) + ");")

            L("    " + fv + ".parent = " + head_var + ";")
            L("    " + fv + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
            L("    " + fv + ".property(\"Transform\").property(\"Position\").setValue([0,0]);")
            L("    " + fv + ".property(\"Transform\").property(\"Scale\").setValue([" + r(K * 100) + "," + r(K * 100) + "]);")

            face_op = fv + ".property(\"Transform\").property(\"Opacity\")"

            def face_active(t: float) -> bool:
                active_name = face_timeline[0][1]
                for ft_e, fn_e in face_timeline:
                    if ft_e <= t + 1e-9:
                        active_name = fn_e
                return active_name == fname

            bps: set[float] = {0.0}
            for vp_t, _vp_v in vis_points:
                bps.add(round(vp_t, 6))
            for ft_e, _fn_e in face_timeline[1:]:
                bps.add(round(max(0.0, ft_e - 0.01), 6))
                bps.add(round(ft_e, 6))
            for bp in sorted(bps):
                op_val = vis_at(bp) if face_active(bp) else 0.0
                L("    " + face_op + ".setValueAtTime(" + r(bp) + ", " + r(op_val) + ");")

        L("")

    # Coin
    L("// ======================================================")
    L("// COIN ITEM")
    L("// ======================================================")
    L("")
    L("var coinLayer = comp.layers.addShape();")
    L("coinLayer.name = \"coin\";")
    L("var coinGrp = coinLayer.property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    L("coinGrp.name = \"coin_grp\";")
    L("var coinEll = coinGrp.property(\"Contents\").addProperty(\"ADBE Vector Shape - Ellipse\");")
    L("coinEll.property(\"Size\").setValue([90,90]);")
    L("coinEll.property(\"Position\").setValue([0,0]);")
    L("var coinFill = coinGrp.property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
    L("coinFill.property(\"Color\").setValue(" + js_color(ae_v4_ext.PALETTE_V4["white"]) + ");")
    L("var coinStroke = coinGrp.property(\"Contents\").addProperty(\"ADBE Vector Graphic - Stroke\");")
    L("coinStroke.property(\"Color\").setValue(" + js_color(red) + ");")
    L("coinStroke.property(\"Stroke Width\").setValue(12);")
    coin_init_x, coin_init_y = coin_pos_fn(0.0)
    L("coinLayer.parent = camCtrl;")
    L("coinLayer.property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    L("coinLayer.property(\"Transform\").property(\"Position\").setValue([" + r(coin_init_x * K) + "," + r(coin_init_y * K) + "]);")
    L("coinLayer.property(\"Transform\").property(\"Scale\").setValue([100,100]);")
    L("")
    L("var coinText = comp.layers.addText(\"\\u00A5\");")
    L("coinText.name = \"coin_label\";")
    L("var coinTextDoc = coinText.property(\"Source Text\").value;")
    L("coinTextDoc.fontSize = 55;")
    L("coinTextDoc.fillColor = " + js_color3(red) + ";")
    L("coinTextDoc.font = \"KaiTi\";")
    L("coinTextDoc.justification = ParagraphJustification.CENTER_JUSTIFY;")
    L("coinText.property(\"Source Text\").setValue(coinTextDoc);")
    L("coinText.parent = camCtrl;")
    L("coinText.property(\"Transform\").property(\"Anchor Point\").setValue([0,-19]);")
    L("coinText.property(\"Transform\").property(\"Position\").setValue([" + r(coin_init_x * K) + "," + r(coin_init_y * K) + "]);")
    L("coinText.property(\"Transform\").property(\"Scale\").setValue([100,100]);")
    L("")

    L("// Dense coin position keyframes")
    L("var coinPosP = coinLayer.property(\"Transform\").property(\"Position\");")
    L("var coinOpP = coinLayer.property(\"Transform\").property(\"Opacity\");")
    L("var coinScaleP = coinLayer.property(\"Transform\").property(\"Scale\");")
    L("var coinTextPosP = coinText.property(\"Transform\").property(\"Position\");")
    L("var coinTextOpP = coinText.property(\"Transform\").property(\"Opacity\");")

    coin_ts: set[float] = set()
    t_cur = 0.0
    while t_cur <= comp_duration + 0.05:
        coin_ts.add(round(t_cur, 6))
        t_cur = round(t_cur + SAMPLE_STEP, 6)
    for seg in coin_segs:
        for et in (float(seg["t0"]), float(seg["t1"])):
            if 0.0 <= et <= comp_duration:
                coin_ts.add(round(et, 6))
        if seg["mode"] == "hops":
            h_t0 = float(seg["t0"])
            h_t1 = float(seg["t1"])
            kk = 0
            while True:
                apex = h_t0 + (kk + 0.5) * 0.45
                land = h_t0 + (kk + 1) * 0.45
                if apex >= h_t1 or apex > comp_duration:
                    break
                coin_ts.add(round(apex, 6))
                if land < h_t1 and land <= comp_duration:
                    coin_ts.add(round(land, 6))
                kk += 1

    for t_cur in sorted(coin_ts):
        cx_w, cy_w = coin_pos_fn(t_cur)
        visible = cx_w > -9000.0
        coin_op = 100 if visible else 0
        L("    coinPosP.setValueAtTime(" + r(t_cur) + ", [" + r(cx_w * K) + "," + r(cy_w * K) + "]);")
        L("    coinTextPosP.setValueAtTime(" + r(t_cur) + ", [" + r(cx_w * K) + "," + r(cy_w * K) + "]);")
        L("    coinOpP.setValueAtTime(" + r(t_cur) + ", " + str(coin_op) + ");")
        L("    coinTextOpP.setValueAtTime(" + r(t_cur) + ", " + str(coin_op) + ");")

    L("    var coinTextScaleP = coinText.property(\"Transform\").property(\"Scale\");")
    L("    coinScaleP.setValueAtTime(0, [100,100]);")
    L("    coinTextScaleP.setValueAtTime(0, [100,100]);")
    for seg in coin_segs:
        if seg["mode"] == "shrink":
            sh_t0 = float(seg["t0"])
            sh_t1 = float(seg["t1"])
            for sc_p in ("coinScaleP", "coinTextScaleP"):
                L("    " + sc_p + ".setValueAtTime(" + r(sh_t0) + ", [100,100]);")
                L("    " + sc_p + ".setValueAtTime(" + r(sh_t1) + ", [0,0]);")
        elif seg["mode"] == "hidden":
            for sc_p in ("coinScaleP", "coinTextScaleP"):
                L("    " + sc_p + ".setValueAtTime(" + r(float(seg["t1"])) + ", [100,100]);")
    L("")

    # Subtitles (v4: no frame, glow)
    ae_v4_ext.emit_subtitle_v4(lines, scenes, ink)

    # Keywords (v4: no fade-out)
    kw_index = ae_v4_ext.emit_keywords_v4(lines, scenes, red)

    # Breathing (identical to v3)
    L("// ======================================================")
    L("// BREATHING (1s step, 2s cycle)")
    L("// ======================================================")
    L("")
    for aname in actor_names:
        body_var = aname + "_body"
        L("// Breathing for " + aname)
        L("var " + aname + "_breathP = " + body_var + ".property(\"Transform\").property(\"Scale\");")
        t_br = 0.0
        cycle_dur = 2.0
        while t_br <= comp_duration + 0.1:
            phase = (t_br % cycle_dur) / cycle_dur
            sc_y = 100.0 + 1.2 * math.sin(phase * 2.0 * math.pi)
            L("    " + aname + "_breathP.setValueAtTime(" + r(t_br) + ", [100," + r(sc_y) + "]);")
            t_br = round(t_br + 1.0, 6)
        L("")

    # Gestures + walk_v2 + give arms (delegate to ae_v3_ext)
    L("// ======================================================")
    L("// V4 GESTURES + WALK V2 + GIVE ARMS (from v3)")
    L("// ======================================================")
    L("")
    for aname in actor_names:
        adata = actors_data[aname]
        root_y_offset_a = float(body_j["from"][1]) * K
        rotation_map_a = {
            "armL_shoulder": aname + "_armL_upper",
            "armL_elbow":    aname + "_armL_fore",
            "armR_shoulder": aname + "_armR_upper",
            "armR_elbow":    aname + "_armR_fore",
            "legL_hip":      aname + "_legL_thigh",
            "legL_knee":     aname + "_legL_shin",
            "legR_hip":      aname + "_legR_thigh",
            "legR_knee":     aname + "_legR_shin",
            "body":          aname + "_body",
            "head":          aname + "_head",
        }
        bounce_windows_a: list[tuple[float, float, float]] = []
        for bounce in adata.get("bounces", []):
            b_t = float(bounce["t"])
            b_h = float(bounce.get("height", 50.0))
            b_times = int(bounce.get("times", 1))
            hop_dur = 0.3
            for bi in range(b_times):
                bt0_a = b_t + bi * hop_dur
                bounce_windows_a.append((bt0_a, bt0_a + hop_dur, b_h))

        gestures = adata.get("gestures", [])
        if gestures:
            ae_v3_ext.emit_gestures(
                lines, aname, gestures, rotation_map_a,
                actor_x_fns[aname], root_y_offset_a,
                bounce_windows_a, comp_duration
            )

        moves_a = adata.get("moves", [])
        if moves_a:
            ae_v3_ext.emit_walk_v2(lines, aname, moves_a, rotation_map_a, comp_duration)

    rotation_maps_all: dict[str, dict[str, str]] = {}
    for aname in actor_names:
        rotation_maps_all[aname] = {
            "armL_shoulder": aname + "_armL_upper",
            "armR_shoulder": aname + "_armR_upper",
        }
    arc_segs = [s for s in coin_segs if s.get("mode") == "arc"]
    ae_v3_ext.emit_give_arms(lines, arc_segs, actor_x_fns, rotation_maps_all, comp_duration)

    # FX system (v4: 6 new + 8 legacy)
    ae_v4_ext.emit_all_fx_v4(
        lines, fx_list, actor_x_fns, coin_pos_fn,
        cam_xs, cam_zooms, cam_ys, cam_times,
        ink, red, paper, comp_duration, SAMPLE_STEP
    )

    # Actor shadows (from v3)
    L("// ======================================================")
    L("// V4 ACTOR SHADOWS")
    L("// ======================================================")
    L("")
    for aname in actor_names:
        ae_v3_ext.emit_actor_shadow(
            lines, aname, actor_x_fns[aname], ink, comp_duration, SAMPLE_STEP
        )

    # motionBlur on moving layers
    L("// ======================================================")
    L("// MOTION BLUR ON MOVING LAYERS")
    L("// ======================================================")
    L("")
    for aname in actor_names:
        for sfx in ["_root", "_body", "_head",
                    "_armL_upper", "_armL_fore", "_armR_upper", "_armR_fore",
                    "_legL_thigh", "_legL_shin", "_legR_thigh", "_legR_shin"]:
            L(aname + sfx + ".motionBlur = true;")
    L("")

    # v4 polish: vignette only (no paper texture)
    ae_v4_ext.emit_v4_polish(lines, comp_duration)

    # v4 gradient sky + ground shade (placed below everything)
    ae_v4_ext.emit_gradient_sky(lines, comp_duration)

    # Text layers to top
    L("// ======================================================")
    L("// TEXT LAYERS TO TOP")
    L("// ======================================================")
    for kw_i in range(kw_index):
        L("kw_" + str(kw_i) + ".moveToBeginning();")
    L("subtitleLayer.moveToBeginning();")
    L("")

    # Footer
    L("app.endUndoGroup();")
    L("$.writeln(\"Doodle4 Build complete v4. Comp duration: \" + comp.duration);")
    L("")

    return lines


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

def main() -> None:
    if len(sys.argv) != 7:
        print(
            "Usage: py -3.14 scripts/build_ae4.py "
            "<resolved.json> <plan.json> <rig_spec.json> <props.json> "
            "<voice.mp3> <out.jsx>",
            file=sys.stderr,
        )
        sys.exit(1)

    resolved_path, plan_path, rig_path, props_path, voice_path, out_path = sys.argv[1:]

    with open(resolved_path, encoding="utf-8") as f:
        resolved: dict[str, Any] = json.load(f)
    with open(plan_path, encoding="utf-8") as f:
        plan: dict[str, Any] = json.load(f)
    with open(rig_path, encoding="utf-8") as f:
        rig: dict[str, Any] = json.load(f)
    with open(props_path, encoding="utf-8") as f:
        props_spec: dict[str, Any] = json.load(f)

    jsx_lines = build(resolved, plan, rig, props_spec, voice_path, out_path)
    jsx_content = "\n".join(jsx_lines) + "\n"

    Path(out_path).write_text(jsx_content, encoding="utf-8")
    print("[build_ae4] Written " + str(len(jsx_lines)) + " lines -> " + out_path)


if __name__ == "__main__":
    main()
