<!doctype html>
<!--
  TEMPLATE: cinematic-cream
  DNA-ONLY template. No slot structure. No preset typography classes.
  The agent composes each caption group's typography per-scene via the
  plan.json `css` field.

  What's LOCKED (DNA — do not override):
    - font-family: Inter (+ fallback)
    - Motion vocabulary: per-word soft/present reveal curves from the
       validated memory-wall timeline (tone: "soft" | "present")
    - Color palette defaults (cream on dark scenes, deep warm on light)
    - mix-blend-mode defaults, text-shadow / filter auto-picked from
       cap_color luminance (via make-composition.py)
    - Animation: 0.7s container fade-in (at g.in-0.01 via tl.set), per-word
       reveal at w.start, group fade-out 0.5s before g.out

  What's OPEN per group (agent decides per scene via `css` field):
    - font-size, font-weight, font-style (italic or not), text-transform
       (uppercase or not), letter-spacing, line-height
    - Position: top / left / right / bottom / transform / text-align
    - Layer: bg (embed via matte) | fg (announcement on top)
    - Tone: soft | present (motion curve)
    - scale: optional shortcut for size multiplication

  LAYOUT — FIRST run the safe-zone precompute, THEN compose into PLANES:
    - Run `node scripts/safe-zones.cjs <project>` and read `safe-zones.json`. From the
       matte it gives subject coverage%, the clearer side, the largest CLEAN rectangles
       (left/right/top), and a verdict:
         · "fg"   → subject fills the frame; NO room to embed behind it. Use
            `caption_layer:"fg"` (screen-blend on top). Do NOT fight it with bg embeds —
            that's the 9:16 case that costs many failed occlusion rounds.
         · "embed"→ NARRATION planes go INSIDE the reported clean zones (prefer the clearer
            side / top). The HERO is the EXCEPTION — see the next bullet. Don't guess
            narration positions — use the zones safe-zones gives you.
    - NARRATION: define 2-4 named planes in plan.json `planes {}`, each inside a clean zone
       from safe-zones.json. Give each narration group a `plane`; groups STACK within it (a
       plane is a flex column → lines accumulate, aligned + coherent). Narration must be CLEAR.
    - THE HERO (the one promoted peak word) is the OPPOSITE of narration — BIG, and ON the
       subject. It is the scarce, earned PEAK; it must feel like it BURSTS, not a polite label:
         · BIG + dominant — **~0.22–0.34 × frame height**. Give it a WIDE plane (≈ centered,
            spanning the subject), NEVER a narrow margin column. (fit-fonts won't enlarge — if
            the hero's box is narrow it stays small; widen the box.)
         · CENTERED on the subject (use safe-zones `heroAnchor` = the subject's center),
            CROSSING it so the head/torso occludes the MIDDLE — **target ~30–55% occlusion**.
            That occlusion IS the effect. A hero shoved into a clean margin (~10% graze) is
            WRONG — it reads as a small floating word, not an embed behind the subject.
         · Exactly ONE hero per clip (scarcity = impact). Mark it `"hero": true` in the plan.
    - Do NOT hand-position every group with its own scattered absolute `top%` — that
       produces the unbalanced, occluded layouts the gate rejects (many re-tries). Planes
       first; per-group `css` only fine-tunes WITHIN a plane. (Position fields above apply
       per-group only when a group is NOT in a plane — the exception, not the default.)

  READING ORDER — on-screen position follows SPOKEN order; a promoted word appears once:
    - For groups sharing a column/region, vertical (top→bottom) order MUST match spoken
       order. A promoted hero must NOT sit below words that come after it.
       (e.g. "judge us by the [ACTIONS] that we take" → ACTIONS reads BETWEEN "by the" and
       "that we take", NEVER below "that we take".)
    - The promoted word is lifted OUT of the narration (appears once, as the hero — never
       also in a narration line). For the words AFTER it, two options, in order:
         1. PREFERRED — flow them directly BELOW the hero (in reading order) or merge them
            into the hero's group, IF that area is clean (check safe-zones). One continuous
            read, no clearing.
         2. FALLBACK — if below/merge would land on the subject (occluded), do the Standard
            hand-off: the pre-hero narration CLEARS, then the post-hero words appear fresh
            (page-flip); the hero may hold across the flip.
       Prefer promoting a CLAUSE-FINAL word when you can — then nothing follows to place.

  Philosophy:
    - Number of groups = content decides (3? 7? 12? whatever the speech
       warrants)
    - Each group's typography = scene decides (italic for soft moments,
       uppercase for punch, mixed case for narration — per group)
    - No more "cap-1 must be italic 600" rules. Those were slots.
    - The agent reads the scene + transcript and composes. The template
       only guarantees the DNA feels coherent (Inter family, same motion
       library, warm-cream or deep-warm palette, same shadow logic).

  See `references/typographic-moves.md` for RECOMMENDED typography
  combinations (NOT rules) — italic 600 for openers, uppercase 900 for
  climaxes, etc. These are suggestions the agent can pick from or deviate.
-->
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width={{WIDTH}}, height={{HEIGHT}}" />
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      :root { --font-scale: {{FONT_SCALE}}; --h: {{HEIGHT}}px; --w: {{WIDTH}}px; }

      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body {
        margin: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        overflow: hidden;
        background: #000;
        font-family: "Inter", "Helvetica Neue", system-ui, sans-serif;
      }
      #a-roll { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
      #stage {
        position: absolute;
        top: 0; left: 0;
        width: {{WIDTH}}px; height: {{HEIGHT}}px;
        pointer-events: none;
        z-index: 2;
      }

      /* BASE — everything shared across groups */
      .cap {
        display: block;
        position: absolute;
        opacity: 0;
        mix-blend-mode: {{BLEND_MODE}};
        color: {{CAP_COLOR}};
        text-shadow: {{TEXT_SHADOW}};
        filter: {{TEXT_FILTER}};
        line-height: 1.0;
        will-change: transform, opacity;
        /* Free-mode defaults (when cap is a direct child of #stage):
           per-group `top:` positions the element's TOP edge from the
           frame top. Agent writes top/left/right per group.  */
        top: 50%; left: 0; right: 0; text-align: center;
      }
      .cap .w { display: inline-block; opacity: 0; will-change: opacity, transform; }

      /* Plane mode — when .cap is nested inside .plane, switch to flow
         layout so the plane's flex/grid can own the stacking. Agent writes
         typography only per group; positions are delegated to the plane.
         This is the memory-wall / champion architecture: a shared parent
         container handles spatial coherence, caps inside don't fight for
         top% values. */
      .plane { position: absolute; pointer-events: none; }
      .plane > .cap {
        position: relative;
        top: auto; left: auto; right: auto; bottom: auto;
      }

      {{PLANES_CSS}}

      /* Per-group typography (agent-authored). */
      {{CUSTOM_CSS}}
    </style>
  </head>
  <body>
    <div
      id="root"
      data-composition-id="main"
      data-start="0"
      data-duration="{{DURATION}}"
      data-width="{{WIDTH}}"
      data-height="{{HEIGHT}}"
    >
      <video
        id="a-roll"
        src="source.mp4"
        muted
        playsinline
        data-duration="{{DURATION}}"
        data-track-index="0"
        style="z-index: 1"
      ></video>

      <div id="stage">{{GROUPS_HTML}}</div>

      <audio
        id="a-roll-audio"
        src="source.mp4"
        data-start="0"
        data-duration="{{DURATION}}"
        data-track-index="3"
        data-volume="1"
      ></audio>
    </div>

    <script>
      window.__timelines = window.__timelines || {};
      const tl = gsap.timeline({ paused: true });
      const DUR = {{DURATION}};
      const GROUPS = {{GROUPS_JSON}};

      /* Motion vocabulary — LOCKED.
         soft:    gentle fade with 8px y-drift, 0.45s power2.out
         present: snappier pop with 6px y + 1.04 scale, 0.22s power3.out
         Both target per-WORD spans. Container fade-in via tl.set at g.in-0.01. */
      GROUPS.forEach(function (g) {
        var sel = "#" + g.id;
        var isSoft = g.tone === "soft";

        // Enter: reveal opacity at g.in-0.01. The cap has been reserving flex
        // space all along (opacity: 0 but display: block from CSS), so no
        // layout reflow when it fades in — matches memory-wall canonical.
        tl.set(sel, { opacity: 1 }, Math.max(0, g.in - 0.01));

        g.words.forEach(function (w, i) {
          var wsel = sel + " .w[data-i='" + i + "']";
          if (isSoft) {
            tl.fromTo(wsel,
              { opacity: 0, y: 8 },
              { opacity: 1, y: 0, duration: 0.45, ease: "power2.out", overwrite: "auto" },
              w.start);
          } else {
            tl.fromTo(wsel,
              { opacity: 0, y: 6, scale: 1.04 },
              { opacity: 1, y: 0, scale: 1.0, duration: 0.22,
                ease: "power3.out", overwrite: "auto", transformOrigin: "50% 50%" },
              w.start);
          }
        });

        if (g.out < DUR) {
          tl.to(sel, { opacity: 0, duration: 0.5, ease: "power2.in", overwrite: "auto" },
                g.out - 0.5);
        }
        // Exit: display:none releases flex space so the next sentence-block's
        // caps can take the top slot. All caps in ONE block share the same
        // `out` so this fires synchronously for the block — one clean reflow
        // at the boundary, no per-cap jitter. Plan must share `out` within a
        // block; staggered per-cap exits are forbidden in plane mode.
        tl.set(sel, { opacity: 0, display: "none" }, g.out);
      });

      tl.seek(0);
      window.__timelines["main"] = tl;
    </script>
  </body>
</html>
