"""ae_v4_ext.py — v4 extension: flat design, new FX 6 kinds, subtitle glow,
look-ahead camera, parallax clouds, gradient sky, clean lines.

All ES3 ExtendScript. Appended to lines: list[str].
Coordinate rules: child_pos = world * K (K=0.9), parent camCtrl.
"""
from __future__ import annotations

import math
from typing import Any

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

# Palette v4
PALETTE_V4 = {
    "white":      "#FFFFFF",
    "shade":      "#E9E2D5",
    "accent":     "#E8A33D",
    "sky_top":    "#FBF8F2",
    "sky_bottom": "#EFE8DC",
    "cloud":      "#F6F2EA",
    "ink":        "#1A1A2E",
    "red":        "#D4231C",
    "paper":      "#FDFAF4",
}


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

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) + "]"


_fx_counter = [0]


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


# ---------------------------------------------------------------------------
# resolve palette fill key -> hex
# ---------------------------------------------------------------------------

def resolve_fill(fill_key: str | None, ink: str, paper: str, red: str) -> str | None:
    if fill_key is None:
        return None
    if fill_key == "none":
        return None
    if fill_key == "white":
        return PALETTE_V4["white"]
    if fill_key == "shade":
        return PALETTE_V4["shade"]
    if fill_key == "accent":
        return PALETTE_V4["accent"]
    if fill_key == "cloud":
        return PALETTE_V4["cloud"]
    if fill_key == "red":
        return red
    if fill_key == "ink":
        return ink
    if fill_key == "paper":
        return paper
    return ink


def resolve_stroke(stroke_key: str | None, ink: str, red: str) -> str | None:
    if stroke_key == "none":
        return None
    if stroke_key == "red":
        return red
    if stroke_key == "accent":
        return PALETTE_V4["accent"]
    return ink  # default ink


# ---------------------------------------------------------------------------
# v4 gradient sky background (Gradient Ramp effect on a solid)
# ---------------------------------------------------------------------------

def emit_gradient_sky(lines: list[str], comp_duration: float) -> None:
    """Add Gradient Ramp adjustment over the paper BG: sky_top->sky_bottom top-down."""
    sky_top = PALETTE_V4["sky_top"]
    sky_bottom = PALETTE_V4["sky_bottom"]
    lines.append("// --- v4 Gradient Sky ---")
    lines.append("var skySolid = comp.layers.addSolid(" + js_color3(sky_top) + ", \"SkyGrad\", 1920, 1080, 1, " + r(comp_duration) + ");")
    lines.append("skySolid.moveToEnd();")
    lines.append("try {")
    lines.append("  var skyRamp = skySolid.property(\"Effects\").addProperty(\"ADBE Ramp\");")
    lines.append("  skyRamp.property(\"ADBE Ramp-0001\").setValue([960, 0]);")
    lines.append("  skyRamp.property(\"ADBE Ramp-0002\").setValue(" + js_color3(sky_top) + ");")
    lines.append("  skyRamp.property(\"ADBE Ramp-0003\").setValue([960, 810]);")
    lines.append("  skyRamp.property(\"ADBE Ramp-0004\").setValue(" + js_color3(sky_bottom) + ");")
    lines.append("  skyRamp.property(\"ADBE Ramp-0005\").setValue(1);")  # linear
    lines.append("} catch(eSky){}")
    lines.append("")
    # Ground shade strip (thin 6%-opacity shade rect under the ground line)
    lines.append("// --- v4 Ground shade strip ---")
    lines.append("var groundShade = comp.layers.addShape();")
    lines.append("groundShade.name = \"GroundShade\";")
    lines.append("var gsGrp = groundShade.property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    lines.append("var gsRect = gsGrp.property(\"Contents\").addProperty(\"ADBE Vector Shape - Rect\");")
    lines.append("gsRect.property(\"Size\").setValue([10000, 120]);")
    lines.append("gsRect.property(\"Position\").setValue([0, 60]);")
    lines.append("var gsFill = gsGrp.property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
    lines.append("gsFill.property(\"Color\").setValue(" + js_color3(PALETTE_V4["shade"]) + ");")
    lines.append("groundShade.parent = camCtrl;")
    lines.append("groundShade.property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
    lines.append("groundShade.property(\"Transform\").property(\"Position\").setValue([0, 180]);")
    lines.append("groundShade.property(\"Transform\").property(\"Opacity\").setValue(6);")
    lines.append("groundShade.moveToEnd();")
    lines.append("")


# ---------------------------------------------------------------------------
# v4 vignette only (no Roughen Edges, no Fractal Noise paper texture)
# ---------------------------------------------------------------------------

def emit_v4_polish(lines: list[str], comp_duration: float) -> None:
    """Vignette only. NO Roughen Edges. NO Fractal Noise paper texture."""
    lines.append("// --- v4 Vignette (clean lines: no paper texture, no roughen) ---")
    lines.append("var vigSolid4 = comp.layers.addSolid([0,0,0], \"Vignette4\", 1920, 1080, 1, " + r(comp_duration) + ");")
    lines.append("vigSolid4.property(\"Transform\").property(\"Opacity\").setValue(7);")
    lines.append("try {")
    lines.append("  var vigMask4 = vigSolid4.property(\"Masks\").addProperty(\"Mask\");")
    lines.append("  var vigMaskSh4 = new Shape();")
    lines.append("  vigMaskSh4.vertices = [[0,540],[960,0],[1920,540],[960,1080]];")
    lines.append("  vigMaskSh4.inTangents = [[0,0],[-700,0],[0,0],[700,0]];")
    lines.append("  vigMaskSh4.outTangents = [[0,0],[700,0],[0,0],[-700,0]];")
    lines.append("  vigMaskSh4.closed = true;")
    lines.append("  vigMask4.property(\"ADBE Mask Shape\").setValue(vigMaskSh4);")
    lines.append("  vigMask4.maskMode = MaskMode.ADD;")
    lines.append("  vigMask4.inverted = true;")
    lines.append("  vigMask4.property(\"ADBE Mask Feather\").setValue([320,320]);")
    lines.append("  vigMask4.property(\"ADBE Mask Offset\").setValue(60);")
    lines.append("} catch(eVig4){ try { vigSolid4.remove(); } catch(e2){} }")
    lines.append("")


# ---------------------------------------------------------------------------
# v4 subtitle: ink text + white glow outline, no background box
# ---------------------------------------------------------------------------

def emit_subtitle_v4(lines: list[str], scenes: list[dict], ink: str) -> None:
    """Subtitle in ink color, Glow effect for soft halo. No box shape."""
    lines.append("// ======================================================")
    lines.append("// V4 SUBTITLES (no frame, glow halo)")
    lines.append("// ======================================================")
    lines.append("")
    lines.append("var subtitleLayer = comp.layers.addText(\"\");")
    lines.append("subtitleLayer.name = \"Subtitle\";")
    lines.append("var subSrcProp = subtitleLayer.property(\"Source Text\");")
    lines.append("subtitleLayer.property(\"Transform\").property(\"Position\").setValue([960, 980]);")
    lines.append("")
    lines.append("subSrcProp.setValue(subSrcProp.value);")
    lines.append("var subDoc = subSrcProp.value;")
    lines.append("subDoc.fontSize = 44;")
    lines.append("subDoc.fillColor = " + js_color3(ink) + ";")
    lines.append("subDoc.font = \"KaiTi\";")
    lines.append("subDoc.justification = ParagraphJustification.CENTER_JUSTIFY;")
    lines.append("")
    for scene in scenes:
        subtitle_text = scene.get("subtitle", "")
        scene_start = float(scene["start"])
        lines.append("subDoc.text = " + js_str(subtitle_text) + ";")
        lines.append("subSrcProp.setValueAtTime(" + r(scene_start) + ", subDoc);")
        lines.append("")
    # Add soft glow via Gaussian Blur on a duplicate (cheap glow trick in ES3)
    lines.append("try {")
    lines.append("  var subGlow = subtitleLayer.property(\"Effects\").addProperty(\"ADBE Glo2\");")
    lines.append("  subGlow.property(\"ADBE Glo2-0002\").setValue(20);")
    lines.append("  subGlow.property(\"ADBE Glo2-0001\").setValue(60);")
    lines.append("} catch(eSubGlow){}")
    lines.append("")


# ---------------------------------------------------------------------------
# v4 keyword: pop in, hold ~1.2s, fade out 0.3s (same as v1~v3; "不回退" means
# no feature regression, NOT "no fade"). Hard inPoint/outPoint window is
# mandatory: opacity holds its first-keyframe value when scrubbed before the
# first key, so without the window the layer stays visible forever (known pit).
# ---------------------------------------------------------------------------

def emit_keywords_v4(lines: list[str], scenes: list[dict], red: str) -> int:
    """Emit keyword text layers. Returns count."""
    lines.append("// ======================================================")
    lines.append("// V4 KEYWORDS (pop in, hold 1.2s, fade 0.3s, hard window)")
    lines.append("// ======================================================")
    lines.append("")
    kw_index = 0
    for scene in scenes:
        for kw in scene.get("keywords", []):
            kw_text = kw["text"]
            kw_t = float(kw["t"])
            kv = "kw_" + str(kw_index)
            kw_index += 1

            lines.append("// Keyword: " + kw_text + " @ t=" + r(kw_t))
            lines.append("var " + kv + " = comp.layers.addText(" + js_str(kw_text) + ");")
            lines.append(kv + ".name = " + js_str("kw_" + kw_text) + ";")
            lines.append("var " + kv + "_doc = " + kv + ".property(\"Source Text\").value;")
            lines.append(kv + "_doc.fontSize = 80;")
            lines.append(kv + "_doc.fillColor = " + js_color3(red) + ";")
            lines.append(kv + "_doc.font = \"KaiTi\";")
            lines.append(kv + "_doc.justification = ParagraphJustification.CENTER_JUSTIFY;")
            lines.append(kv + ".property(\"Source Text\").setValue(" + kv + "_doc);")
            lines.append(kv + ".property(\"Transform\").property(\"Position\").setValue([1320, 360]);")

            t_s0 = round(kw_t - 0.05, 6)
            t_s1 = round(kw_t + 0.12, 6)
            t_s2 = round(kw_t + 0.25, 6)
            scale_prop = kv + ".property(\"Transform\").property(\"Scale\")"
            lines.append("    " + scale_prop + ".setValueAtTime(" + r(t_s0) + ", [0,0]);")
            lines.append("    " + scale_prop + ".setValueAtTime(" + r(t_s1) + ", [120,120]);")
            lines.append("    " + scale_prop + ".setValueAtTime(" + r(t_s2) + ", [100,100]);")

            # Fade out: hold 1.2s after pop, then 0.3s opacity -> 0 (v1~v3 behavior)
            t_op0 = round(kw_t + 1.2, 6)
            t_op1 = round(kw_t + 1.5, 6)
            op_prop = kv + ".property(\"Transform\").property(\"Opacity\")"
            lines.append("    " + op_prop + ".setValueAtTime(" + r(t_op0) + ", 100);")
            lines.append("    " + op_prop + ".setValueAtTime(" + r(t_op1) + ", 0);")
            # Hard visibility window: layer does not exist outside [t_s0, t_op1]
            lines.append(kv + ".inPoint = " + r(t_s0) + ";")
            lines.append(kv + ".outPoint = " + r(t_op1) + ";")
            lines.append("")
    return kw_index


# ---------------------------------------------------------------------------
# v4 camera: look-ahead follow + closeup third-of-frame offset
# ---------------------------------------------------------------------------

def build_cam_track_v4(
    camera_segs: list[dict],
    actor_positions: dict[str, Any],
    item_positions: dict[str, Any],
    total_dur: float,
    sample_step: float = 0.1,
    look_ahead: float = 0.3,
    look_ahead_scale: float = 0.8,
    look_ahead_clamp: float = 150.0,
) -> tuple[list[float], list[float], list[float], list[float]]:
    """Dense cam track with look-ahead (follow mode), 0.8s smoothstep transitions,
    hard_cut instant cut, closeup third-frame offset."""

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

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

    def get_follow_x(seg: dict, t: float) -> float:
        target = seg["target"]
        if target in actor_positions:
            return float(actor_positions[target](t))
        elif target == "coin" and "coin" in item_positions:
            return float(item_positions["coin"](t)[0])
        return 0.0

    def get_raw_cam_x(seg: dict, t: float) -> float:
        if seg["mode"] == "fixed":
            return float(seg["x"])
        raw_x = get_follow_x(seg, t)
        # look-ahead: bias toward where target is moving
        t_ahead = min(t + look_ahead, total_dur)
        raw_x_ahead = get_follow_x(seg, t_ahead)
        la_offset = (raw_x_ahead - raw_x) * look_ahead_scale
        la_offset = max(-look_ahead_clamp, min(look_ahead_clamp, la_offset))
        raw_x += la_offset
        # closeup: third-of-frame offset (target at 10% from center toward facing)
        if float(seg.get("zoom", 0.9)) >= 1.5:
            # push subject slightly off-center (face-forward side has more room)
            dx_dir = 1.0 if la_offset >= 0 else -1.0
            raw_x += dx_dir * COMP_W * 0.1 / (float(seg.get("zoom", 1.5)) * K)
        return max(-2000.0, min(5500.0, raw_x))

    times: list[float] = []
    cam_xs: list[float] = []
    zooms: list[float] = []
    cam_ys: list[float] = []

    max_t = total_dur + 0.5
    t_cur = 0.0
    while t_cur <= max_t:
        # find active segment
        active_seg = None
        for seg in camera_segs:
            if float(seg["t0"]) <= t_cur <= float(seg["t1"]) + 0.001:
                active_seg = seg
                break

        if active_seg is not None:
            cx_val = get_raw_cam_x(active_seg, t_cur)
            zoom_val = float(active_seg.get("zoom", 0.9))
            cy_val = float(active_seg.get("cam_y", 0.0))
        else:
            # between segments
            prev_seg = None
            next_seg = None
            for seg in camera_segs:
                if float(seg["t1"]) < t_cur:
                    prev_seg = seg
                if next_seg is None and float(seg["t0"]) > t_cur:
                    next_seg = seg
            if prev_seg is not None and next_seg is not None:
                is_hard = bool(next_seg.get("hard_cut", False))
                if is_hard:
                    cx_val = get_raw_cam_x(prev_seg, float(prev_seg["t1"]))
                    zoom_val = float(prev_seg.get("zoom", 0.9))
                    cy_val = float(prev_seg.get("cam_y", 0.0))
                else:
                    t0_tr = float(prev_seg["t1"])
                    t1_tr = float(next_seg["t0"])
                    dur_tr = t1_tr - t0_tr
                    if dur_tr > 0.001:
                        alpha = smoothstep((t_cur - t0_tr) / dur_tr)
                        x0 = get_raw_cam_x(prev_seg, float(prev_seg["t1"]))
                        x1 = get_raw_cam_x(next_seg, float(next_seg["t0"]))
                        z0 = float(prev_seg.get("zoom", 0.9))
                        z1 = float(next_seg.get("zoom", 0.9))
                        y0 = float(prev_seg.get("cam_y", 0.0))
                        y1 = float(next_seg.get("cam_y", 0.0))
                        cx_val = lerp(x0, x1, alpha)
                        zoom_val = lerp(z0, z1, alpha)
                        cy_val = lerp(y0, y1, alpha)
                    else:
                        cx_val = get_raw_cam_x(next_seg, t_cur)
                        zoom_val = float(next_seg.get("zoom", 0.9))
                        cy_val = float(next_seg.get("cam_y", 0.0))
            elif prev_seg is not None:
                cx_val = get_raw_cam_x(prev_seg, float(prev_seg["t1"]))
                zoom_val = float(prev_seg.get("zoom", 0.9))
                cy_val = float(prev_seg.get("cam_y", 0.0))
            elif next_seg is not None:
                cx_val = get_raw_cam_x(next_seg, float(next_seg["t0"]))
                zoom_val = float(next_seg.get("zoom", 0.9))
                cy_val = float(next_seg.get("cam_y", 0.0))
            else:
                cx_val = 0.0
                zoom_val = 0.9
                cy_val = 0.0

        times.append(t_cur)
        cam_xs.append(cx_val)
        zooms.append(zoom_val)
        cam_ys.append(cy_val)
        t_cur = round(t_cur + sample_step, 6)

    return times, cam_xs, zooms, cam_ys


# ---------------------------------------------------------------------------
# v4 prop renderer: resolves palette_v4 fill/stroke per primitive
# ---------------------------------------------------------------------------

def emit_prop_v4(
    lines: list[str],
    prop: dict,
    props_spec: dict,
    ink: str,
    paper: str,
    red: str,
    ground_child_y: float,
    cam_xs: list[float],
    cam_zooms: list[float],
    cam_ys: list[float],
    cam_times: list[float],
    comp_duration: float,
    sample_step: float = 0.1,
) -> None:
    """Emit a prop layer with v4 palette, parallax, cloud drift."""
    prop_id = prop["id"]
    prop_type = prop["type"]
    prop_x = float(prop["x"])
    prop_label = prop.get("label", "")
    prop_hidden = bool(prop.get("hidden", False))
    prop_events = prop.get("events", [])
    parallax = float(prop.get("parallax", 1.0))

    if prop_type not in props_spec:
        return

    spec = props_spec[prop_type]
    primitives = spec.get("primitives", [])

    prop_var = prop_id.replace("-", "_")
    f = K

    lines.append("// --- Prop v4: " + prop_id + " (" + prop_type + ") ---")
    lines.append("var " + prop_var + " = comp.layers.addShape();")
    lines.append(prop_var + ".name = " + js_str(prop_id) + ";")

    main_grp = prop_var + "_mainGrp"
    lines.append("var " + main_grp + " = " + prop_var + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
    lines.append(main_grp + ".name = \"main\";")

    sub_i = 0
    sign_layer_var = None
    sign_layer_grp_var = None
    sign_hinge: tuple[float, float] | None = None
    text_layer_vars: list[str] = []

    for prim in primitives:
        kind = prim.get("kind", "polyline")
        prim_id = prim.get("_id", "")
        # Per-primitive stroke
        stroke_key = prim.get("stroke", "ink")
        sw = float(prim.get("stroke_width", 14)) * f
        stroke_hex = resolve_stroke(stroke_key, ink, red)
        fill_key = prim.get("fill")
        fill_hex = resolve_fill(fill_key, ink, paper, red)

        if kind == "polyline":
            pts = [[float(p[0]) * f, float(p[1]) * f] for p in prim["pts"]]
            closed = bool(prim.get("closed", False))

            if prim_id == "sign":
                if sign_layer_var is None:
                    sign_layer_var = prop_var + "_signLayer"
                    sign_layer_grp_var = prop_var + "_signLayerGrp"
                    lines.append("var " + sign_layer_var + " = comp.layers.addShape();")
                    lines.append(sign_layer_var + ".name = " + js_str(prop_id + "_sign") + ";")
                    lines.append("var " + sign_layer_grp_var + " = " + sign_layer_var + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
                    lines.append(sign_layer_grp_var + ".name = \"sign_grp\";")
                    sign_hinge = (min(p[0] for p in pts), min(p[1] for p in pts))
                _emit_polyline_v4(lines, sign_layer_grp_var, pts, closed, fill_hex, stroke_hex, sw, sub_i)
            else:
                _emit_polyline_v4(lines, main_grp, pts, closed, fill_hex, stroke_hex, sw, sub_i)

        elif kind == "circle":
            cx_p = float(prim["c"][0]) * f
            cy_p = float(prim["c"][1]) * f
            radius_p = float(prim["r"]) * f
            _emit_circle_v4(lines, main_grp, cx_p, cy_p, radius_p, fill_hex, stroke_hex, sw, sub_i)

        elif kind == "text":
            text_str = prim.get("str", "")
            if "{label}" in text_str:
                text_str = prop_label
            if text_str == "":
                sub_i += 1
                continue
            pos = prim["pos"]
            size = float(prim.get("size", 60)) * f
            col_key = prim.get("color", "ink")
            text_color = red if col_key == "red" else (PALETTE_V4["accent"] if col_key == "accent" else ink)

            text_var = prop_var + "_text" + str(sub_i)
            parent_var = sign_layer_var if (prim_id == "sign_text" and sign_layer_var is not None) else prop_var
            lines.append("var " + text_var + " = comp.layers.addText(" + js_str(text_str) + ");")
            lines.append(text_var + ".name = " + js_str(prop_id + "_txt" + str(sub_i)) + ";")
            lines.append("var " + text_var + "_doc = " + text_var + ".property(\"Source Text\").value;")
            lines.append(text_var + "_doc.fontSize = " + r(size) + ";")
            lines.append(text_var + "_doc.fillColor = " + js_color3(text_color) + ";")
            lines.append(text_var + "_doc.font = \"KaiTi\";")
            lines.append(text_var + "_doc.justification = ParagraphJustification.CENTER_JUSTIFY;")
            lines.append(text_var + ".property(\"Source Text\").setValue(" + text_var + "_doc);")
            lines.append(text_var + ".parent = " + parent_var + ";")
            lines.append(text_var + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
            lines.append(text_var + ".property(\"Transform\").property(\"Position\").setValue([" + r(float(pos[0]) * f) + "," + r(float(pos[1]) * f) + "]);")
            lines.append(text_var + ".property(\"Transform\").property(\"Scale\").setValue([100,100]);")
            text_layer_vars.append(text_var)

        sub_i += 1

    # Wire into CamCtrl — parallax spec (storyboard-spec.md):
    #   screen_x = (world_x - cam_x) * p * K * zoom + 960   (world offset scaled by p)
    # CamCtrl child mapping: screen_x = 960 - cam_x*K*zoom + zoom*child_x, so
    #   child_x = (world_x*p + cam_x*(1-p)) * K
    # NOT (world_x - cam_x*p) — that throws far props off-screen before the cam arrives.
    # y stays ground_child_y (screen_y = (world_y-cam_y)*K*zoom + 800-180*zoom, no p).
    if parallax < 0.999:
        # Parallax prop: dense child-x keyframes sampled against the cam track
        prop_y_child = ground_child_y
        lines.append(prop_var + ".parent = camCtrl;")
        lines.append(prop_var + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        pos_p = prop_var + ".property(\"Transform\").property(\"Position\")"

        # cloud: add ±60 world units 12s drift based on hash of prop_id
        is_cloud = (prop_type == "cloud")
        drift_phase = (hash(prop_id) % 100) / 100.0 * 2.0 * math.pi if is_cloud else 0.0
        drift_amp = 60.0 if is_cloud else 0.0

        t_cur = 0.0
        step = 0.2  # coarser for background props
        while t_cur <= comp_duration + 0.05:
            idx = min(int(round(t_cur / 0.1)), len(cam_times) - 1)
            cam_x_t = cam_xs[idx]
            # child_x = (world_x*p + cam_x*(1-p))*K  =>  screen follows cam at p speed
            drift = drift_amp * math.sin(t_cur * 2.0 * math.pi / 12.0 + drift_phase)
            px = ((prop_x + drift) * parallax + cam_x_t * (1.0 - parallax)) * K
            lines.append("    " + pos_p + ".setValueAtTime(" + r(round(t_cur, 6)) + ", [" + r(px) + "," + r(prop_y_child) + "]);")
            t_cur = round(t_cur + step, 6)
        lines.append(prop_var + ".property(\"Transform\").property(\"Scale\").setValue([100,100]);")
        # Re-stack to background: just above PaperBG/previous bg prop, below all
        # foreground props (AE stacks new layers on top — SVG order is reversed).
        lines.append(prop_var + ".moveBefore(bgStackTop);")
        lines.append("bgStackTop = " + prop_var + ";")
    else:
        prop_x_child = prop_x * K
        prop_y_child = ground_child_y
        lines.append(prop_var + ".parent = camCtrl;")
        lines.append(prop_var + ".property(\"Transform\").property(\"Anchor Point\").setValue([0,0]);")
        lines.append(prop_var + ".property(\"Transform\").property(\"Position\").setValue([" + r(prop_x_child) + "," + r(prop_y_child) + "]);")
        lines.append(prop_var + ".property(\"Transform\").property(\"Scale\").setValue([100,100]);")

    if sign_layer_var is not None and sign_hinge is not None:
        hx, hy = sign_hinge
        lines.append(sign_layer_var + ".parent = camCtrl;")
        lines.append(sign_layer_var + ".property(\"Transform\").property(\"Anchor Point\").setValue([" + r(hx) + "," + r(hy) + "]);")
        if parallax < 0.999:
            # sign follows same parallax as prop — simplified: fixed at first sample
            sign_px = prop_x * K + hx
        else:
            sign_px = prop_x * K + hx
        lines.append(sign_layer_var + ".property(\"Transform\").property(\"Position\").setValue([" + r(sign_px) + "," + r(ground_child_y + hy) + "]);")
        lines.append(sign_layer_var + ".property(\"Transform\").property(\"Scale\").setValue([100,100]);")

    # Skyline dimmed; cloud slightly transparent
    if prop_type == "skyline":
        show_op = 35.0
    elif prop_type == "cloud":
        show_op = 90.0
    else:
        show_op = 100.0

    vis_targets = [prop_var]
    if sign_layer_var is not None:
        vis_targets.append(sign_layer_var)
    vis_targets.extend(text_layer_vars)

    for tv in vis_targets:
        tv_op = tv + ".property(\"Transform\").property(\"Opacity\")"
        if prop_hidden:
            lines.append(tv_op + ".setValueAtTime(0, 0);")
        else:
            lines.append(tv_op + ".setValueAtTime(0, " + r(show_op) + ");")

    for ev in prop_events:
        ev_t = float(ev["t"])
        ev_kind = ev["kind"]
        if ev_kind == "show":
            for tv in vis_targets:
                tv_op = tv + ".property(\"Transform\").property(\"Opacity\")"
                lines.append(tv_op + ".setValueAtTime(" + r(ev_t - 0.01) + ", 0);")
                lines.append(tv_op + ".setValueAtTime(" + r(ev_t + 0.3) + ", " + r(show_op) + ");")
        elif ev_kind == "hide":
            for tv in vis_targets:
                tv_op = tv + ".property(\"Transform\").property(\"Opacity\")"
                lines.append(tv_op + ".setValueAtTime(" + r(ev_t) + ", " + r(show_op) + ");")
                lines.append(tv_op + ".setValueAtTime(" + r(ev_t + 0.3) + ", 0);")
        elif ev_kind == "close" and prop_type == "shop":
            rot_prop_str = prop_var + ".property(\"Transform\").property(\"Rotation\")"
            lines.append(rot_prop_str + ".setValueAtTime(" + r(ev_t) + ", 0);")
            lines.append(rot_prop_str + ".setValueAtTime(" + r(ev_t + 0.4) + ", 5);")
            if sign_layer_var is not None:
                sign_rot_str = sign_layer_var + ".property(\"Transform\").property(\"Rotation\")"
                lines.append(sign_rot_str + ".setValueAtTime(" + r(ev_t) + ", 0);")
                lines.append(sign_rot_str + ".setValueAtTime(" + r(ev_t + 0.4) + ", 25);")
        elif ev_kind == "open" and prop_type == "shop":
            rot_prop_str = prop_var + ".property(\"Transform\").property(\"Rotation\")"
            lines.append(rot_prop_str + ".setValueAtTime(" + r(ev_t) + ", 5);")
            lines.append(rot_prop_str + ".setValueAtTime(" + r(ev_t + 0.3) + ", 0);")
            if sign_layer_var is not None:
                sign_rot_str = sign_layer_var + ".property(\"Transform\").property(\"Rotation\")"
                lines.append(sign_rot_str + ".setValueAtTime(" + r(ev_t) + ", 25);")
                lines.append(sign_rot_str + ".setValueAtTime(" + r(ev_t + 0.3) + ", 0);")
        elif ev_kind == "grow":
            grow_t0 = ev_t
            grow_t1 = float(ev.get("t1", ev_t + 1.0))
            from_sc = float(ev.get("from", 0.5)) * 100.0
            to_sc = float(ev.get("to", 1.0)) * 100.0
            sc_prop = prop_var + ".property(\"Transform\").property(\"Scale\")"
            lines.append(sc_prop + ".setValueAtTime(" + r(grow_t0) + ", [" + r(from_sc) + "," + r(from_sc) + "]);")
            lines.append(sc_prop + ".setValueAtTime(" + r(grow_t1) + ", [" + r(to_sc) + "," + r(to_sc) + "]);")

    lines.append("")


def _emit_polyline_v4(
    lines: list[str],
    grp_var: str,
    pts: list[list[float]],
    closed: bool,
    fill_hex: str | None,
    stroke_hex: str | None,
    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_hex 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_hex) + ");")
    if stroke_hex is not None and stroke_width > 0:
        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_hex) + ");")
        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_v4(
    lines: list[str],
    grp_var: str,
    cx: float,
    cy: float,
    radius: float,
    fill_hex: str | None,
    stroke_hex: str | None,
    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_hex 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_hex) + ");")
    if stroke_hex is not None and stroke_width > 0:
        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_hex) + ");")
        lines.append(sp + stroke_var + ".property(\"Stroke Width\").setValue(" + r(float(stroke_width)) + ");")


# ---------------------------------------------------------------------------
# NEW FX 1: trail — 3-5 red fading dot ghosts following coin arc/hops
# ---------------------------------------------------------------------------

def emit_fx_trail(
    lines: list[str],
    coin_pos_fn: Any,
    t0: float,
    t1: float,
    red: str,
    comp_duration: float,
) -> None:
    """3 trailing ghost circles following coin, red with opacity 60→0."""
    n_ghosts = 3
    trail_delay = 0.08  # seconds behind
    t_end = min(t1, comp_duration)

    for gi in range(n_ghosts):
        fv = _fx_var()
        delay = trail_delay * (gi + 1)
        radius = (18.0 - gi * 4.0) * K
        base_op = 60 - gi * 18

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx4_trail_" + 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(radius * 2) + "," + r(radius * 2) + "]);")
        lines.append(ell + ".property(\"Position\").setValue([0,0]);")
        fill_v = fv + "_fill"
        lines.append("var " + fill_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(fill_v + ".property(\"Color\").setValue(" + js_color(red) + ");")

        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\")"
        # Fade in/out
        lines.append(op_p + ".setValueAtTime(" + r(t0) + ", 0);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t0 + 0.05, 6)) + ", " + str(base_op) + ");")
        lines.append(op_p + ".setValueAtTime(" + r(round(t_end - 0.05, 6)) + ", " + str(base_op) + ");")
        lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")

        step = 0.05
        cur_t = t0
        while cur_t <= t_end + 0.001:
            t_sample = max(0.0, cur_t - delay)
            cx_w, cy_w = coin_pos_fn(t_sample)
            lines.append(pos_p + ".setValueAtTime(" + r(round(cur_t, 6)) + ", [" + r(cx_w * K) + "," + r(cy_w * K) + "]);")
            cur_t = round(cur_t + step, 6)

        lines.append(fv + ".inPoint = " + r(t0) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


# ---------------------------------------------------------------------------
# NEW FX 2: flash — full-screen white solid, screen-space, 0.12s
# ---------------------------------------------------------------------------

def emit_fx_flash(
    lines: list[str],
    t: float,
    dur: float,
    comp_duration: float,
) -> None:
    """White flash: screen-space solid, Opacity 0->55->0 over dur seconds."""
    fv = _fx_var()
    t_end = min(t + dur, comp_duration)
    t_mid = round(t + dur * 0.4, 6)

    lines.append("var " + fv + " = comp.layers.addSolid([1,1,1], " + js_str("fx4_flash_" + str(_fx_counter[0])) + ", 1920, 1080, 1, " + r(comp_duration) + ");")
    op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
    lines.append(op_p + ".setValueAtTime(" + r(max(0.0, t - 0.001)) + ", 0);")
    lines.append(op_p + ".setValueAtTime(" + r(t_mid) + ", 55);")
    lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
    lines.append(fv + ".inPoint = " + r(max(0.0, t - 0.001)) + ";")
    lines.append(fv + ".outPoint = " + r(t_end) + ";")
    lines.append("")


# ---------------------------------------------------------------------------
# NEW FX 3: shake — decaying sinusoid added to CamCtrl position keyframes
# (call BEFORE building cam track, or inject into existing cam_xs list)
# We apply it as a post-process on the cam_xs/cam_ys arrays in-place.
# ---------------------------------------------------------------------------

def apply_shake_to_cam(
    cam_times: list[float],
    cam_xs: list[float],
    cam_ys: list[float],
    t_start: float,
    amp: float,
    dur: float,
) -> None:
    """Mutate cam_xs with a decaying-sine shake starting at t_start."""
    freq = 18.0  # Hz
    for i, t in enumerate(cam_times):
        if t_start <= t <= t_start + dur:
            elapsed = t - t_start
            decay = 1.0 - elapsed / dur
            offset = amp * decay * math.sin(elapsed * freq * 2.0 * math.pi)
            cam_xs[i] += offset


# ---------------------------------------------------------------------------
# NEW FX 4: ambient — 10 pale circles drifting very slowly, world-space
# ---------------------------------------------------------------------------

def emit_fx_ambient(
    lines: list[str],
    t0: float,
    t1: float,
    ink: str,
    comp_duration: float,
) -> None:
    """8-12 pale small circles floating gently, opacity<=10, world space."""
    n_dots = 10
    t_end = min(t1, comp_duration)
    for i in range(n_dots):
        fv = _fx_var()
        # Spread across a wide world range
        start_x = (-800.0 + i * 600.0) * K
        start_y = (50.0 + (i % 4) * 120.0) * K
        # Slow drift: 50px over full duration
        drift_x = math.cos(i * 1.1) * 50.0 * K
        drift_y = math.sin(i * 0.9) * 30.0 * K
        radius = (6.0 + (i % 3) * 3.0) * K
        opacity = 6 + (i % 4)  # 6–9%

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx4_amb_" + 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(radius * 2) + "," + r(radius * 2) + "]);")
        lines.append(ell + ".property(\"Position\").setValue([0,0]);")
        fill_v = fv + "_fill"
        lines.append("var " + fill_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(fill_v + ".property(\"Color\").setValue(" + js_color(PALETTE_V4["shade"]) + ");")

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

        pos_p = fv + ".property(\"Transform\").property(\"Position\")"
        lines.append(pos_p + ".setValueAtTime(" + r(t0) + ", [" + r(start_x) + "," + r(start_y) + "]);")
        lines.append(pos_p + ".setValueAtTime(" + r(t_end) + ", [" + r(start_x + drift_x) + "," + r(start_y + drift_y) + "]);")
        lines.append(fv + ".property(\"Transform\").property(\"Opacity\").setValue(" + str(opacity) + ");")
        lines.append(fv + ".inPoint = " + r(t0) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


# ---------------------------------------------------------------------------
# NEW FX 5: light_rays — 3-4 accent-color thin triangles from top, world-space
# ---------------------------------------------------------------------------

def emit_fx_light_rays(
    lines: list[str],
    world_x: float,
    t: float,
    ink: str,
    comp_duration: float,
) -> None:
    """3 diagonal accent-color thin triangles radiating from above, 8% opacity."""
    accent = PALETTE_V4["accent"]
    n_rays = 3
    t_expand = round(t + 1.2, 6)
    t_end = min(round(t + 3.2, 6), comp_duration)
    cx = world_x * K

    for ri in range(n_rays):
        fv = _fx_var()
        angle_deg = -65.0 + ri * 20.0   # slanted from top-left/top/top-right
        angle_rad = math.radians(angle_deg)
        ray_len = 650.0 * K
        half_w = 28.0 * K
        # Triangle: apex at top of screen world coords (-400 above ground)
        apex_y = -430.0 * K
        tip_x = cx + ri * 160.0 * K - 160.0 * K
        tip_y = apex_y - 200.0 * K
        # base points along ray direction
        bx1 = tip_x + math.cos(angle_rad + math.pi / 2.0) * half_w + math.cos(angle_rad) * ray_len
        by1 = tip_y + math.sin(angle_rad + math.pi / 2.0) * half_w + math.sin(angle_rad) * ray_len
        bx2 = tip_x - math.cos(angle_rad + math.pi / 2.0) * half_w + math.cos(angle_rad) * ray_len
        by2 = tip_y - math.sin(angle_rad + math.pi / 2.0) * half_w + math.sin(angle_rad) * ray_len

        pts = [[r(tip_x), r(tip_y)], [r(bx1), r(by1)], [r(bx2), r(by2)]]
        verts = "[" + ",".join("[" + p[0] + "," + p[1] + "]" for p in pts) + "]"
        tans = "[[0,0],[0,0],[0,0]]"

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx4_ray_" + str(_fx_counter[0])) + ";")
        grp = fv + "_g"
        lines.append("var " + grp + " = " + fv + ".property(\"Contents\").addProperty(\"ADBE Vector Group\");")
        path_v = fv + "_path"
        lines.append("var " + path_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Shape - Group\");")
        lines.append("var " + fv + "_sh = new Shape();")
        lines.append(fv + "_sh.vertices = " + verts + ";")
        lines.append(fv + "_sh.inTangents = " + tans + ";")
        lines.append(fv + "_sh.outTangents = " + tans + ";")
        lines.append(fv + "_sh.closed = true;")
        lines.append(path_v + ".property(\"Path\").setValue(" + fv + "_sh);")
        fill_v = grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\")"
        lines.append("var " + fv + "_fill = " + fill_v + ";")
        lines.append(fv + "_fill.property(\"Color\").setValue(" + js_color(accent) + ");")

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

        sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
        lines.append(sc_p + ".setValueAtTime(" + r(t) + ", [0,100]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_expand) + ", [100,100]);")
        lines.append(op_p + ".setValueAtTime(" + r(t) + ", 0);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t + 0.1, 6)) + ", 8);")
        lines.append(op_p + ".setValueAtTime(" + r(round(t_end - 0.3, 6)) + ", 8);")
        lines.append(op_p + ".setValueAtTime(" + r(t_end) + ", 0);")
        lines.append(fv + ".inPoint = " + r(t) + ";")
        lines.append(fv + ".outPoint = " + r(t_end) + ";")
        lines.append("")


# ---------------------------------------------------------------------------
# NEW FX 6: glow_pulse — large blurry circle 2 pulses at prop, world-space
# ---------------------------------------------------------------------------

def emit_fx_glow_pulse(
    lines: list[str],
    world_x: float,
    t: float,
    accent: str,
    comp_duration: float,
) -> None:
    """2 pulsing large circles (blurred via Scale 100->160->100), 20% opacity."""
    cx = world_x * K
    cy = -150.0 * K  # above ground level, roughly mid-prop

    for pulse_i in range(2):
        fv = _fx_var()
        t_p0 = round(t + pulse_i * 0.6, 6)
        t_p_mid = round(t_p0 + 0.3, 6)
        t_p_end = min(round(t_p0 + 0.6, 6), comp_duration)
        radius = 90.0 * K

        lines.append("var " + fv + " = comp.layers.addShape();")
        lines.append(fv + ".name = " + js_str("fx4_glow_" + 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(radius * 2) + "," + r(radius * 2) + "]);")
        lines.append(ell + ".property(\"Position\").setValue([0,0]);")
        fill_v = fv + "_fill"
        lines.append("var " + fill_v + " = " + grp + ".property(\"Contents\").addProperty(\"ADBE Vector Graphic - Fill\");")
        lines.append(fill_v + ".property(\"Color\").setValue(" + js_color(accent) + ");")

        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(cy) + "]);")

        sc_p = fv + ".property(\"Transform\").property(\"Scale\")"
        op_p = fv + ".property(\"Transform\").property(\"Opacity\")"
        lines.append(sc_p + ".setValueAtTime(" + r(t_p0) + ", [100,100]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_p_mid) + ", [160,160]);")
        lines.append(sc_p + ".setValueAtTime(" + r(t_p_end) + ", [100,100]);")
        lines.append(op_p + ".setValueAtTime(" + r(t_p0) + ", 20);")
        lines.append(op_p + ".setValueAtTime(" + r(t_p_mid) + ", 10);")
        lines.append(op_p + ".setValueAtTime(" + r(t_p_end) + ", 0);")
        # Add Gaussian Blur for soft glow look
        lines.append("try {")
        lines.append("  var " + fv + "_gb = " + fv + ".property(\"Effects\").addProperty(\"ADBE Gaussian Blur 2\");")
        lines.append("  " + fv + "_gb.property(\"ADBE Gaussian Blur 2-0001\").setValue(55);")
        lines.append("} catch(eGB" + str(_fx_counter[0]) + "){}")
        lines.append(fv + ".inPoint = " + r(t_p0) + ";")
        lines.append(fv + ".outPoint = " + r(t_p_end) + ";")
        lines.append("")


# ---------------------------------------------------------------------------
# v4 emit_all_fx: dispatch including 6 new kinds + existing 8 kinds
# delegates existing 8 kinds to ae_v3_ext
# ---------------------------------------------------------------------------

def emit_all_fx_v4(
    lines: list[str],
    fx_list: list[dict],
    actor_x_fns: dict[str, Any],
    coin_pos_fn: 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. New v4 kinds handled here; legacy 8 kinds via ae_v3_ext."""
    import ae_v3_ext

    lines.append("// ======================================================")
    lines.append("// FX SYSTEM (v4: 6 new + 8 legacy)")
    lines.append("// ======================================================")
    lines.append("")

    accent = PALETTE_V4["accent"]

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

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

        # --- new v4 kinds ---
        if kind == "trail":
            t1 = float(fx.get("t1", t + 0.6))
            emit_fx_trail(lines, coin_pos_fn, t, t1, red, comp_duration)

        elif kind == "flash":
            dur = float(fx.get("dur", 0.12))
            emit_fx_flash(lines, t, dur, comp_duration)

        elif kind == "shake":
            # shake is applied to cam_xs in-place before this call (see build_ae4.py)
            # here we skip — handled by apply_shake_to_cam pre-pass
            pass

        elif kind == "ambient":
            t1 = float(fx.get("t1", comp_duration))
            emit_fx_ambient(lines, t, t1, ink, comp_duration)

        elif kind == "light_rays":
            emit_fx_light_rays(lines, world_x, t, ink, comp_duration)

        elif kind == "glow_pulse":
            emit_fx_glow_pulse(lines, world_x, t, accent, comp_duration)

        # --- legacy 8 kinds delegated to ae_v3_ext ---
        elif kind == "dust":
            small = bool(fx.get("small", False))
            ae_v3_ext.emit_fx_dust(lines, world_x, t, small, ink, comp_duration)

        elif kind == "sparkle":
            world_y = -40.0
            world_x_h = world_x + 70.0 if fx.get("anchor") == "hand" else world_x
            ae_v3_ext.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))
                ae_v3_ext.emit_fx_speed_lines(lines, fn, t, t1, ink, comp_duration)

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

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

        elif kind == "shockwave":
            ae_v3_ext.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 = ae_v3_ext.actor_screen_pos(
                fn or (lambda t: world_x), t,
                cam_xs, cam_zooms, cam_ys, cam_times, sample_step)
            ae_v3_ext.emit_fx_emphasis(lines, glyph, sx, sy - 60.0, t, ink, red, comp_duration)
            ae_v3_ext.emit_fx_burst(lines, sx, sy - 60.0, t, ink, comp_duration)
