# Text–Object Interleave

Text and object interleave in depth: type rises from behind the product, a device slots into a gap in a sentence, or a word passes through an object and comes out as another word.

- Position in a launch film: Product reveal
- Slug: text-object-interleave
- Preview video: https://media.motionboard.app/text-object-interleave.mp4
- Recipe page: https://motionboard.app/recipes/text-object-interleave/
- License: MIT. Free to use in your own projects, including commercial launch films. https://github.com/krisyangdev/motionboard/blob/main/LICENSE

## How to use this recipe

You are a coding agent. The user wants this shot for their own product. Follow these steps.

1. **Know the product.** If the product is clear from the user's repo or conversation, ask nothing and start. Otherwise ask once for: product name, logo file, brand colors.
2. **Pick the output.** If the user's message says a video or their website, do that. Otherwise ask once which one they want.
   - **Video (HyperFrames).** Create a folder named `text-object-interleave`. Download every file under "Source files" below with the curl commands, keeping relative paths. Adapt `index.html` (see "What to adapt"). Run `npx --yes hyperframes@0.8.209 check` on the untouched copy first and note its result. Preview with `npx --yes hyperframes@0.8.209 preview`, render an MP4 with `npx --yes hyperframes@0.8.209 render` (into `renders/`, named with a timestamp). The same commands are in `package.json` as `dev`, `check`, `render`; pass flags after `--`, as in `npm run render -- --quality draft`. Rendering needs network access for web fonts and GSAP.
   - **Website motion.** Port the GSAP timeline in `index.html` into the user's site, using their stack (React, Vue, Svelte, plain HTML). Build the same DOM structure, keep the timeline's durations, delays and easings exactly, and start it when the element scrolls into view (IntersectionObserver or ScrollTrigger). Respect `prefers-reduced-motion: reduce`: show the final frame as a static state and skip the movement.
3. **What to adapt.**
   - **Brand layer** (product name, logo, colors, fonts, copy): replace directly with the user's.
   - **UI layer** (the product screens, cards, windows, controls shown in the shot): rebuild the user's real UI as clean DOM, from their codebase (components, design tokens) or from a screenshot used as reference only. Never paste a screenshot into the shot.
   - **Motion** (timing, easing, camera moves, layering, stagger, order of events): keep exactly as in the source. This is what the recipe is.
   - The timeline targets UI elements by id and class, and camera moves land on fixed coordinates. Keep the ids the timeline uses, keep row counts and text lengths close to the source, and when the layout moves, update the target coordinates without changing the times.
4. **Fonts.** HyperFrames fetches Google Fonts such as Inter on its own. Some sources use macOS local fonts (PingFang, Songti via `local()`). Use the user's brand font when it can be bundled; for a system font such as SF Pro, use Inter. Check that text still fits its boxes.
5. **Verify.** Compare the render with the preview video above at the beat times in the Timing row. The beats, timing and camera should match; only brand and UI content differ.

## Spec

| Item | Value |
| --- | --- |
| Timing | Film 11s with hard cuts at 3.2s and 7.8s. Wordmark-rise scene 0–3.2s: product lights 0–1.15s, sinks while the wordmark rises 0.9–2.7s (1.8s, 54 frames). Pass-through scene 3.2–7.8s: source text slides in 1.0s (30 frames; 3.75–4.75s and 5.85–6.85s), translation emerges 0.4s (12 frames; 4.75–5.15s and 6.85–7.25s). Slot-in scene 7.8–11.0s: device drops in 8.55–9.05s (0.5s, 15 frames), light ring glows 9.05–9.65s (0.6s, 18 frames) |
| Easing | Wordmark rise, device drop, translation emerging and lockup `cubic-bezier(0.16,1,0.3,1)` (expo.out); source text slide `linear`; product lighting power2.inOut; word lighting and ring glow power2.out and power2.inOut |
| Camera | Fixed perspective camera, 20° fov, distance 34 units, medium close-up. When the wordmark rises, the case sinks from top 350px to 515px (165px, about 15% of frame height) while the wordmark rises 255px; slow drift: case scale +2.5% in the first scene, earbud scale +2% in the second |
| Subject | Layer order: text at the back, 3D product in the middle, text in front. In the pass-through scene the translation is clipped to start at the earbud tip (x 1060px) and the source text is hidden past x 820px; in the slot-in scene the sentence splits into two groups with a 400px gap, one earbud wide, and the left words light first (8.05s), the right words second (8.3s) |
| Sync | The light ring turns on after both word groups have lit and the device has dropped in (9.05s); the translation emerges at the earbud's sound outlet (tip at 1060px, 580px) and its language label follows 0.12s later |
| Style | Black background in the first and last scene, light gray `#f5f5f7` in the middle one; wordmark vertical gradient `#ff5a0a` → `#ff7a26` → `#ffb27a` → `#ffd2ad` (Archivo Black 340px), translation gradient `#4b2dff` → `#9b33ff` → `#ff2f8a` (`background-clip: text`), sentence words `#5e5e63` lighting to `#f5f5f7` |

## Why it works

The text sits one layer behind the product, and the product covers about 1/3 of the wordmark, which creates a front layer and a back layer. The sentence breaks in the middle and the device becomes part of it, so the eye travels from the left word through the screen to the right word. Translation is shown through occlusion: the word goes in as black source text and comes out as gradient-colored translation, with no arrows.

## Prompt

> Build a front-and-back interleave of text and product called Text–Object Interleave as an 11-second film with three scenes and hard cuts at 3.2s and 7.8s. Use three layers: text at the back, your 3D product in the middle (fixed perspective camera, 20° fov, distance 34 units), text in front. Scene 1 (black stage): the case lights up (rim 0–0.45s, key 0.25–1.15s), then from 0.9 to 2.7s it sinks 165px (about 15% of frame height) with expo.out (cubic-bezier(0.16,1,0.3,1)) while a large wordmark with a vertical orange gradient (#ff5a0a to #ffd2ad) rises 255px from behind its top edge and is clipped at the bottom. Scene 2 (light gray #f5f5f7): an earbud close-up with its sound outlet fixed on screen; source text slides at constant speed (linear) for 1.0s into the earbud and is hidden, then 0.4s later the translation emerges from the outlet with expo.out in a purple-pink gradient (#4b2dff to #ff2f8a, background-clip: text); run two passes (3.75–5.15s and 5.85–7.25s). Scene 3 (black): a sentence split into two word groups with a 400px gap one earbud wide; the left words light first (8.05s), the right words 0.25s later; the earbud drops into the gap from above over 0.5s with expo.out (8.55–9.05s), then its light ring glows over 0.6s (9.05–9.65s) and the wordmark settles in with expo.out. Use layered HTML + GSAP over a Three.js canvas.

## Often confused with

- [Word-by-Word Highlight](https://motionboard.app/r/word-by-word-highlight.md): Word-by-Word Highlight controls reading pace on plain text; Text–Object Interleave puts text and object into an occlusion relationship.
- [Dispense Line-up](https://motionboard.app/r/dispense-line-up.md): Dispense Line-up has objects slide out of the main subject in turn, synced word by word with the caption; Text–Object Interleave emphasizes the front-and-back layering of text and object.

## Source files

Runnable HyperFrames project, 3 files.

- https://motionboard.app/source/text-object-interleave/hyperframes.json (318 B)
- https://motionboard.app/source/text-object-interleave/index.html (40.9 KB)
- https://motionboard.app/source/text-object-interleave/package.json (311 B)

Download all of them:

```sh
curl -fsSL --create-dirs -o "text-object-interleave/hyperframes.json" "https://motionboard.app/source/text-object-interleave/hyperframes.json"
curl -fsSL --create-dirs -o "text-object-interleave/index.html" "https://motionboard.app/source/text-object-interleave/index.html"
curl -fsSL --create-dirs -o "text-object-interleave/package.json" "https://motionboard.app/source/text-object-interleave/package.json"
```

## index.html

```html
<!doctype html>
<html lang="en" data-resolution="landscape">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=1920, height=1080" />
    <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"}}</script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <style>
      @font-face { font-family: "JP"; font-weight: 600; src: local("Hiragino Sans W6"), local("HiraginoSans-W6"), local("Hiragino Kaku Gothic ProN W6"); }
      * { margin: 0; padding: 0; box-sizing: border-box; }
      html, body { width: 1920px; height: 1080px; overflow: hidden; background: #000; }
      #root { position: relative; width: 1920px; height: 1080px; background: #000; font-family: "Inter", sans-serif; overflow: hidden; }
      #bg { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; background: #000; z-index: 0; }
      .layer { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #back1 { z-index: 1; }
      #back2 { z-index: 2; }
      #stage { z-index: 5; }
      #front2 { z-index: 8; }
      #front3 { z-index: 9; }
      #gl { display: block; width: 1920px; height: 1080px; }

      /* ---- scene 1: wordmark rises behind the case ---- */
      #wm1clip { position: absolute; left: 0; top: 0; width: 1920px; overflow: hidden; }
      #wm1 {
        position: absolute; left: 0; top: 0; width: 1920px; text-align: center;
        font-family: "Archivo Black", sans-serif; font-size: 340px; line-height: 360px; letter-spacing: -0.012em;
        background: linear-gradient(180deg, #ff5a0a 0%, #ff7a26 34%, #ffb27a 64%, #ffd2ad 82%);
        -webkit-background-clip: text; background-clip: text; color: transparent;
        white-space: nowrap;
      }
      #intro1 {
        position: absolute; left: 0; top: 0; width: 1920px; text-align: center;
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 34px; line-height: 40px; letter-spacing: -0.01em;
        color: #f5f5f7; white-space: nowrap; opacity: 0;
      }

      /* ---- scene 2: text passes through the earbud ---- */
      .cap2 {
        position: absolute; left: 0; width: 1920px; text-align: center; white-space: nowrap;
        font-family: "Inter", sans-serif;
      }
      #title2 { top: 118px; font-weight: 600; font-size: 34px; line-height: 40px; letter-spacing: -0.01em; color: #1d1d1f; }
      #sub2 { top: 932px; font-weight: 500; font-size: 28px; line-height: 34px; color: #6e6e73; }
      .srcClip, .dstClip { position: absolute; height: 180px; overflow: hidden; }
      .src {
        position: absolute; left: 0; top: 0; height: 180px; line-height: 180px; white-space: nowrap;
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 104px; letter-spacing: -0.025em; color: #1d1d1f;
      }
      .dst {
        position: absolute; left: 0; top: 0; height: 180px; line-height: 180px; white-space: nowrap; padding-right: 8px;
        font-family: "Inter", "JP", sans-serif; font-weight: 600; font-size: 104px; letter-spacing: -0.025em;
        background: linear-gradient(90deg, #4b2dff 0%, #9b33ff 42%, #ff2f8a 100%);
        -webkit-background-clip: text; background-clip: text; color: transparent;
      }
      .dst.jp { font-family: "JP", sans-serif; letter-spacing: 0; font-size: 96px; }
      .lang {
        position: absolute; white-space: nowrap; font-family: "Inter", sans-serif; font-weight: 500;
        font-size: 28px; line-height: 34px; color: #86868b;
      }

      /* ---- scene 3: the earbud slots into the sentence ---- */
      .w3 {
        position: absolute; top: 0; height: 80px; line-height: 80px; white-space: nowrap;
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 66px; letter-spacing: -0.025em; color: #5e5e63;
      }
      #w3l { text-align: right; }
      #wm3 {
        position: absolute; left: 0; width: 1920px; text-align: center; white-space: nowrap;
        font-family: "Archivo Black", sans-serif; font-size: 54px; line-height: 80px; letter-spacing: -0.01em;
        background: linear-gradient(180deg, #ff5a0a 10%, #ff8a3d 55%, #ffc596 95%);
        -webkit-background-clip: text; background-clip: text; color: transparent;
      }
      #tag3 {
        position: absolute; left: 0; width: 1920px; text-align: center; white-space: nowrap;
        font-family: "Inter", sans-serif; font-weight: 500; font-size: 28px; line-height: 34px; color: #86868b;
      }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="11" data-width="1920" data-height="1080">
      <div id="bg" class="clip" data-start="0" data-duration="11" data-track-index="0"></div>

      <div id="back1" class="layer clip" data-start="0" data-duration="3.2" data-track-index="1" data-layout-allow-overlap>
        <div id="intro1" data-layout-allow-occlusion>Introducing</div>
        <div id="wm1clip"><div id="wm1" data-layout-allow-occlusion data-layout-allow-overflow>AUREL</div></div>
      </div>

      <div id="back2" class="layer clip" data-start="3.2" data-duration="4.6" data-track-index="2" data-layout-allow-overlap>
        <div class="srcClip" id="srcClip"><div class="src" id="srcA" data-layout-allow-occlusion data-layout-allow-overflow>Hello</div><div class="src" id="srcB" data-layout-allow-occlusion data-layout-allow-overflow>Thank you</div></div>
      </div>

      <div id="stage" class="layer clip" data-hold data-start="0" data-duration="11" data-track-index="3">
        <canvas id="gl" width="1920" height="1080" data-layout-allow-overflow></canvas>
      </div>

      <div id="front2" class="layer clip" data-start="3.2" data-duration="4.6" data-track-index="4" data-layout-allow-overlap>
        <div class="cap2" id="title2">Live Translation</div>
        <div class="cap2" id="sub2">On-device translation in 0.4 seconds.</div>
        <div class="dstClip" id="dstClip"><div class="dst jp" id="dstA" data-layout-allow-overflow>こんにちは</div><div class="dst" id="dstB" data-layout-allow-overflow>Gracias</div></div>
        <div class="lang" id="langA">Japanese</div>
        <div class="lang" id="langB">Spanish</div>
      </div>

      <div id="front3" class="layer clip" data-start="7.8" data-duration="3.2" data-track-index="5" data-layout-allow-overlap>
        <div class="w3" id="w3l">Hear 40 languages</div>
        <div class="w3" id="w3r">in real time.</div>
        <div id="wm3">AUREL</div>
        <div id="tag3">Live Translation earbuds</div>
      </div>
    </div>

    <script>
      /* ---------- shared layout (output px) and timing, read by the WebGL scene too ---------- */
      window.CFG = {
        cut2: 3.2, cut3: 7.8, end: 11.0,
        // scene 1: case sinks 15% of frame height (162px) while the wordmark rises behind it
        s1: { reveal: [0.0, 1.0], rise: [0.9, 2.7], caseTop0: 350, caseTop1: 515, wmBase: 580 },
        // scene 2: tip (sound outlet) of the earbud on screen; source text is hidden past hideX
        s2: { tipX: 1060, tipY: 580, hideX: 820,
              a: { fade: 3.35, slide: [3.75, 4.75], out: [4.75, 5.15] },
              b: { slide: [5.85, 6.85], out: [6.85, 7.25] } },
        // scene 3: sentence gap of one earbud width
        s3: { y: 540, gap: 400, light: [8.05, 8.3], drop: [8.55, 9.05], glow: [9.05, 9.65], mark: 8.8 },
      };
      const C = window.CFG;
      const $ = (id) => document.getElementById(id);

      /* static placement */
      $("wm1").style.top = "0px";
      $("wm1clip").style.height = C.s1.wmBase + "px";
      $("intro1").style.top = "0px";
      const s2 = C.s2;
      for (const id of ["srcClip"]) { const e = $(id); e.style.left = "0px"; e.style.width = s2.hideX + "px"; e.style.top = (s2.tipY - 90) + "px"; }
      for (const id of ["dstClip"]) { const e = $(id); e.style.left = s2.tipX + "px"; e.style.width = (1920 - s2.tipX) + "px"; e.style.top = (s2.tipY - 90) + "px"; }
      for (const id of ["langA", "langB"]) { const e = $(id); e.style.left = (s2.tipX + 36) + "px"; e.style.top = (s2.tipY + 62) + "px"; }
      const s3 = C.s3;
      $("w3l").style.left = "0px"; $("w3l").style.width = (960 - s3.gap / 2) + "px"; $("w3l").style.top = (s3.y - 40) + "px";
      $("w3r").style.left = (960 + s3.gap / 2) + "px"; $("w3r").style.top = (s3.y - 40) + "px";
      $("wm3").style.top = "838px";
      $("tag3").style.top = "918px";

      const tl = gsap.timeline({ paused: true });
      const EXPO = "expo.out";

      /* background: black -> light grey -> black (hard cuts) */
      tl.set("#bg", { backgroundColor: "#000000" }, 0);
      tl.set("#bg", { backgroundColor: "#f5f5f7" }, C.cut2);
      tl.set("#bg", { backgroundColor: "#000000" }, C.cut3);

      /* scene 1: wordmark rises from behind the case top edge, 1.8s expo.out */
      const WM_TOP1 = 272, RISE = 255;
      tl.set("#wm1", { y: WM_TOP1 + RISE }, 0);
      tl.to("#wm1", { y: WM_TOP1, duration: C.s1.rise[1] - C.s1.rise[0], ease: EXPO }, C.s1.rise[0]);
      tl.set("#intro1", { y: 270 }, 0);
      tl.to("#intro1", { opacity: 1, y: 260, duration: 0.9, ease: "power2.out" }, 1.55);
      tl.to("#wm1", { y: WM_TOP1 - 4, duration: 0.5, ease: "none" }, 2.7);

      /* scene 2 captions */
      tl.set(["#title2", "#sub2"], { opacity: 0 }, 0);
      tl.to("#title2", { opacity: 1, duration: 0.4, ease: "power2.out" }, C.cut2 + 0.05);
      tl.to("#sub2", { opacity: 1, duration: 0.4, ease: "power2.out" }, C.cut2 + 0.2);

      /* pass A: "Hello" slides (linear) into the earbud body, "こんにちは" comes out of the tip */
      const A = s2.a, B = s2.b;
      const SRC_X0 = 300; // left edge at slide start
      tl.set("#srcA", { opacity: 0, x: SRC_X0 }, 0);
      tl.to("#srcA", { opacity: 1, duration: 0.3, ease: "power1.out" }, A.fade);
      tl.to("#srcA", { x: s2.hideX, duration: A.slide[1] - A.slide[0], ease: "none" }, A.slide[0]);
      tl.set("#dstA", { xPercent: -100, x: 0 }, 0);
      tl.to("#dstA", { xPercent: 0, x: 34, duration: A.out[1] - A.out[0], ease: EXPO }, A.out[0]);
      tl.to("#dstA", { x: 44, duration: 0.6, ease: "none" }, A.out[1]);
      tl.set("#langA", { opacity: 0 }, 0);
      tl.to("#langA", { opacity: 1, duration: 0.3, ease: "power1.out" }, A.out[0] + 0.12);
      tl.to(["#dstA", "#langA"], { opacity: 0, duration: 0.25, ease: "power1.in" }, 5.75);

      /* pass B: "Thank you" -> "Gracias", enters already moving */
      const SRC_XB = 60;
      tl.set("#srcB", { opacity: 0, x: SRC_XB }, 0);
      tl.to("#srcB", { opacity: 1, duration: 0.2, ease: "none" }, B.slide[0]);
      tl.to("#srcB", { x: s2.hideX, duration: B.slide[1] - B.slide[0], ease: "none" }, B.slide[0]);
      tl.set("#dstB", { xPercent: -100, x: 0 }, 0);
      tl.to("#dstB", { xPercent: 0, x: 34, duration: B.out[1] - B.out[0], ease: EXPO }, B.out[0]);
      tl.to("#dstB", { x: 46, duration: 0.75, ease: "none" }, B.out[1]);
      tl.set("#langB", { opacity: 0 }, 0);
      tl.to("#langB", { opacity: 1, duration: 0.3, ease: "power1.out" }, B.out[0] + 0.12);

      /* scene 3: left words light first, right words second, then the earbud drops into the gap */
      tl.set(["#w3l", "#w3r"], { opacity: 0 }, 0);
      tl.to(["#w3l", "#w3r"], { opacity: 1, duration: 0.2, ease: "none" }, C.cut3);
      tl.to("#w3l", { color: "#f5f5f7", duration: 0.25, ease: "power2.out" }, s3.light[0]);
      tl.to("#w3r", { color: "#f5f5f7", duration: 0.25, ease: "power2.out" }, s3.light[1]);
      tl.set(["#wm3", "#tag3"], { opacity: 0 }, 0);
      tl.set("#wm3", { y: 14 }, 0);
      tl.to("#wm3", { opacity: 1, y: 0, duration: 0.7, ease: EXPO }, s3.mark);
      tl.to("#tag3", { opacity: 1, duration: 0.4, ease: "power2.out" }, s3.mark + 0.05);
      tl.to("#wm3", { opacity: 1, duration: 0.01 }, 10.98);

      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = tl;
    </script>

    <script type="module">
      import * as THREE from "three";
      import { mergeVertices } from "three/addons/utils/BufferGeometryUtils.js";

      const C = window.CFG;
      /* ================= time helpers ================= */
      const clamp01 = (x) => Math.min(1, Math.max(0, x));
      const seg = (t, a, b) => clamp01((t - a) / (b - a));
      const power2InOut = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
      const expoOut = (k) => (k >= 1 ? 1 : 1 - Math.pow(2, -10 * k));
      const sineInOut = (k) => -(Math.cos(Math.PI * k) - 1) / 2;
      const lerp = (a, b, k) => a + (b - a) * k;

      /* ================= renderer (transparent: DOM text sits behind and in front) ================= */
      const OW = 1920, OH = 1080, SS = 2;
      const RW = OW * SS, RH = OH * SS;
      const canvas = document.getElementById("gl");
      const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, premultipliedAlpha: true, preserveDrawingBuffer: true, powerPreference: "high-performance" });
      renderer.setPixelRatio(1);
      renderer.setSize(OW, OH, false);
      renderer.setClearColor(0x000000, 0);
      renderer.transmissionResolutionScale = 2;

      const scene = new THREE.Scene();
      scene.background = null;
      const camera = new THREE.PerspectiveCamera(20, OW / OH, 1, 200);

      /* ================= helpers ================= */
      function canvasTex(w, h, draw, srgb = false) {
        const c = document.createElement("canvas");
        c.width = w; c.height = h;
        draw(c.getContext("2d"), w, h);
        const t = new THREE.CanvasTexture(c);
        t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
        t.anisotropy = 8;
        t.needsUpdate = true;
        return t;
      }
      // seeded PRNG for procedural textures
      function rng(seed) { let a = seed | 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
      function arcPts(cr, cz, rr, rz, a0, a1, n) {
        const p = [];
        for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * (i / n); p.push([cr + rr * Math.cos(a), cz + rz * Math.sin(a)]); }
        return p;
      }
      // lathe around local z; profile [[r, z]] walked counter-clockwise (outward normals)
      function lathe(profile, seg = 160) {
        const g = new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(Math.max(r, 1e-4), z)), seg);
        g.rotateX(Math.PI / 2);
        return g;
      }
      function disc(r, z, seg = 128, facing = 1) {
        const g = new THREE.CircleGeometry(r, seg);
        if (facing < 0) g.rotateY(Math.PI);
        g.translate(0, 0, z);
        return g;
      }
      // smooth pebble: sphere directions pushed out per axis with exponents (<1 = flatter faces)
      const sp = (t, e) => Math.sign(t) * Math.pow(Math.abs(t), e);
      function pebble(a, b, c, ex, ey, ez, ws = 320, hs = 220) {
        let g = new THREE.SphereGeometry(1, ws, hs);
        const p = g.attributes.position;
        for (let i = 0; i < p.count; i++) {
          const x = p.getX(i), y = p.getY(i), z = p.getZ(i);
          p.setXYZ(i, a * sp(x, ex), b * sp(y, ey), c * sp(z, ez));
        }
        g.deleteAttribute("normal"); g.deleteAttribute("uv");
        g = mergeVertices(g, 1e-5);
        g.computeVertexNormals();
        // cylindrical uv (seam at the back) so micro-texture maps work
        const q = g.attributes.position, uv = new Float32Array(q.count * 2);
        for (let i = 0; i < q.count; i++) {
          uv[i * 2] = Math.atan2(q.getX(i) / a, q.getZ(i) / c) / (Math.PI * 2) + 0.5;
          uv[i * 2 + 1] = q.getY(i) / (2 * b) + 0.5;
        }
        g.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
        return g;
      }

      /* ================= textures ================= */
      const TEX = {};
      // speaker mesh: hex-perforated grille (normal-ish via bump + dark albedo)
      TEX.meshBump = canvasTex(512, 512, (g, w, h) => {
        g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
        g.fillStyle = "#000";
        const s = 15;
        for (let row = 0; row * s * 0.866 < h + s; row++) {
          for (let col = 0; col * s < w + s; col++) {
            const x = col * s + (row % 2) * s / 2, y = row * s * 0.866;
            g.beginPath(); g.arc(x, y, 4.6, 0, Math.PI * 2); g.fill();
          }
        }
      });
      TEX.meshAlbedo = canvasTex(512, 512, (g, w, h) => {
        g.fillStyle = "#3a3a3c"; g.fillRect(0, 0, w, h);
        g.fillStyle = "#050506";
        const s = 15;
        for (let row = 0; row * s * 0.866 < h + s; row++) {
          for (let col = 0; col * s < w + s; col++) {
            const x = col * s + (row % 2) * s / 2, y = row * s * 0.866;
            g.beginPath(); g.arc(x, y, 4.6, 0, Math.PI * 2); g.fill();
          }
        }
      }, true);
      // micro texture for satin plastic and silicone
      TEX.grain = (() => {
        const r = rng(0x51ab);
        const t = canvasTex(512, 512, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let i = 0; i < w * h; i++) { img.data[i * 4] = 128 + (r() - 0.5) * 70; img.data[i * 4 + 1] = 128 + (r() - 0.5) * 70; img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255; }
          g.putImageData(img, 0, 0);
        });
        t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(6, 6);
        return t;
      })();

      // soft halo around the light ring, drawn additively on the face (ring radius 0.77 of 1.1 half-size)
      TEX.ringGlow = canvasTex(512, 512, (g, w, h) => {
        const img = g.createImageData(w, h);
        for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
          const r = Math.hypot(x - w / 2 + 0.5, y - h / 2 + 0.5) / (w / 2) * 1.1;
          const d = (r - 0.77) / 0.075;
          const v = Math.exp(-d * d) * 0.85 + Math.exp(-(((r - 0.77) / 0.22) ** 2)) * 0.25;
          const k = (y * w + x) * 4;
          img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * Math.min(1, v)); img.data[k + 3] = 255;
        }
        g.putImageData(img, 0, 0);
      }, true);

      // fine speckle for bead-blasted plastic: roughness variation
      function noiseTex(seed, mid, amp, rx, ry, w = 1024, h = 512) {
        const r = rng(seed);
        const t = canvasTex(w, h, (g) => {
          const img = g.createImageData(w, h);
          for (let i = 0; i < w * h; i++) { const v = mid + (r() - 0.5) * amp; img.data[i * 4] = img.data[i * 4 + 1] = img.data[i * 4 + 2] = v; img.data[i * 4 + 3] = 255; }
          g.putImageData(img, 0, 0);
        });
        t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(rx, ry);
        return t;
      }
      TEX.caseRough = noiseTex(0x7a11, 165, 150, 14, 4);
      TEX.caseNormal = (() => {
        const r = rng(0x3c9);
        const t = canvasTex(1024, 512, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let i = 0; i < w * h; i++) { img.data[i * 4] = 128 + (r() - 0.5) * 90; img.data[i * 4 + 1] = 128 + (r() - 0.5) * 90; img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255; }
          g.putImageData(img, 0, 0);
        });
        t.wrapS = t.wrapT = THREE.RepeatWrapping; t.repeat.set(14, 4);
        return t;
      })();
      // concentric brushing for the touch pad: rows along the lathe profile = rings
      TEX.brush = (() => {
        const r = rng(0xb3a);
        const t = canvasTex(32, 2048, (g, w, h) => {
          let v = 0.5;
          for (let y = 0; y < h; y++) {
            v = 0.82 * v + 0.18 * r();
            const c = Math.round(255 * (0.55 + 0.45 * (0.35 * r() + 0.65 * v)));
            g.fillStyle = `rgb(${c},${c},${c})`; g.fillRect(0, y, w, 1);
          }
        });
        t.wrapS = t.wrapT = THREE.RepeatWrapping;
        return t;
      })();
      function gaussTex(inner, width, halo) {
        return canvasTex(256, 256, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
            const r = Math.hypot(x - w / 2 + 0.5, y - h / 2 + 0.5) / (w / 2);
            const d = (r - inner) / width;
            const v = Math.exp(-d * d) + halo * Math.exp(-((r / 0.5) ** 2));
            const k = (y * w + x) * 4;
            img.data[k] = img.data[k + 1] = img.data[k + 2] = Math.round(255 * Math.min(1, v) * Math.max(0, 1 - r)); img.data[k + 3] = 255;
          }
          g.putImageData(img, 0, 0);
        }, true);
      }
      TEX.ledGlow = gaussTex(0, 0.16, 0.6);
      // colour added on top of an opaque surface, alpha untouched
      function addMat(map) {
        return new THREE.MeshBasicMaterial({
          map, color: 0x000000, transparent: true, depthWrite: false,
          blending: THREE.CustomBlending, blendEquation: THREE.AddEquation,
          blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, blendSrcAlpha: THREE.ZeroFactor, blendDstAlpha: THREE.OneFactor,
        });
      }

      /* ================= materials ================= */
      const M = {
        shell: new THREE.MeshPhysicalMaterial({ color: 0xf3f3f1, roughness: 0.36, metalness: 0, clearcoat: 0.55, clearcoatRoughness: 0.2, normalMap: TEX.grain, normalScale: new THREE.Vector2(0.03, 0.03) }),
        pad: new THREE.MeshPhysicalMaterial({ color: 0xdcdad5, metalness: 1, roughness: 0.34, roughnessMap: TEX.brush, bumpMap: TEX.brush, bumpScale: 0.35, anisotropy: 0.6, anisotropyRotation: Math.PI / 2 }),
        bezel: new THREE.MeshPhysicalMaterial({ color: 0xf2f1ee, metalness: 1, roughness: 0.07 }),
        ring: new THREE.MeshPhysicalMaterial({ color: 0xe0874e, roughness: 0.26, metalness: 1, emissive: 0xff5a0a, emissiveIntensity: 0 }),
        silicone: new THREE.MeshPhysicalMaterial({ color: 0xe9e9ea, roughness: 0.62, metalness: 0, sheen: 0.6, sheenRoughness: 0.5, sheenColor: 0xffffff, normalMap: TEX.grain, normalScale: new THREE.Vector2(0.06, 0.06) }),
        grille: new THREE.MeshPhysicalMaterial({ color: 0xffffff, map: TEX.meshAlbedo, bumpMap: TEX.meshBump, bumpScale: 1.2, roughness: 0.5, metalness: 0.3 }),
        dark: new THREE.MeshPhysicalMaterial({ color: 0x0c0c0d, roughness: 0.7, metalness: 0 }),
        sensor: new THREE.MeshPhysicalMaterial({ color: 0x050506, roughness: 0.05, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.03 }),
        gold: new THREE.MeshPhysicalMaterial({ color: 0xd9b26a, roughness: 0.25, metalness: 1 }),
        caseShell: new THREE.MeshPhysicalMaterial({ color: 0xf2f2f0, roughness: 0.5, roughnessMap: TEX.caseRough, metalness: 0, clearcoat: 0.3, clearcoatRoughness: 0.32, normalMap: TEX.caseNormal, normalScale: new THREE.Vector2(0.22, 0.22) }),
        seam: new THREE.MeshPhysicalMaterial({ color: 0x141414, roughness: 0.8, metalness: 0 }),
        ledWin: new THREE.MeshPhysicalMaterial({ color: 0x1a1a1b, roughness: 0.04, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.02, emissive: 0xffa95c, emissiveIntensity: 0 }),
        chamfer: new THREE.MeshPhysicalMaterial({ color: 0xf6f6f4, roughness: 0.12, metalness: 0, clearcoat: 1, clearcoatRoughness: 0.05 }),
        led: new THREE.MeshPhysicalMaterial({ color: 0x222222, roughness: 0.2, emissive: 0xffc27a, emissiveIntensity: 0 }),
      };
      M.grille.map.wrapS = M.grille.map.wrapT = THREE.RepeatWrapping;
      M.grille.bumpMap.wrapS = M.grille.bumpMap.wrapT = THREE.RepeatWrapping;

      /* ================= earbud: AUREL (stemless) ================= */
      // local frame: +z = into the ear, outer touch face at z = 0 facing -z
      const BR = 1.16;          // body radius
      const NZ_O = new THREE.Vector3(-0.3, -0.18, 0.8); // nozzle origin on the bulb
      const NZ_D = new THREE.Vector3(-0.9, -0.22, 1).normalize(); // nozzle axis
      const TIP_L = 0.98;       // outlet distance along the nozzle axis
      function buildBud() {
        const bud = new THREE.Group();
        // shell: flat outer lip -> fillet -> short wall -> bulb closing on the axis
        const prof = [[0.83, 0.0], [0.96, 0.0]];
        prof.push(...arcPts(0.96, 0.2, 0.2, 0.2, -Math.PI / 2, 0, 16));
        prof.push([BR, 0.36]);
        prof.push(...arcPts(0, 0.36, BR, 0.98, 0, Math.PI / 2, 64).slice(1));
        bud.add(new THREE.Mesh(lathe(prof, 192), M.shell));
        // copper light ring with chamfers, set into the face
        const ring = [[0.83, 0.0], [0.835, -0.012], [0.8, -0.022], [0.73, -0.022], [0.715, -0.012], [0.71, -0.004]];
        bud.add(new THREE.Mesh(lathe(ring.slice().reverse(), 192), M.ring));
        // glossy touch pad, slightly domed
        const pad = [[0.0001, -0.05]];
        for (let i = 1; i <= 24; i++) { const r = 0.71 * (i / 24); pad.push([r, -0.05 + 0.044 * (r / 0.71) ** 2]); }
        pad.push([0.71, -0.004]);
        bud.add(new THREE.Mesh(lathe(pad, 192), M.pad));
        // hairline polished bevel between the light ring and the pad
        const bezel = new THREE.Mesh(new THREE.TorusGeometry(0.71, 0.007, 12, 192), M.bezel);
        bezel.position.z = -0.008;
        bud.add(bezel);
        // mic port on the upper wall
        const mic = new THREE.Mesh(new THREE.CircleGeometry(0.034, 32), M.dark);
        mic.position.set(0, BR + 0.002, 0.28); mic.lookAt(0, 3, 0.28);
        bud.add(mic);
        // proximity sensor window on the bulb (glossy black pill)
        const sens = new THREE.Mesh(new THREE.CircleGeometry(0.06, 48), M.sensor);
        sens.scale.set(2.6, 1, 1);
        const th = 0.95, ph = Math.PI * 0.62; // bulb param, angle around axis
        const sr = BR * Math.cos(th) + 0.003, sz = 0.36 + 0.98 * Math.sin(th);
        sens.position.set(sr * Math.cos(ph), sr * Math.sin(ph), sz);
        const sn = new THREE.Vector3(Math.cos(th) / BR * Math.cos(ph), Math.cos(th) / BR * Math.sin(ph), Math.sin(th) / 0.98).normalize();
        sens.lookAt(sens.position.clone().add(sn));
        sens.rotateZ(0.3);
        bud.add(sens);
        // two charging contacts on the lower bulb
        for (const [k, dph] of [[0, -0.09], [1, 0.09]]) {
          const thc = 0.55, phc = -Math.PI * 0.5 + dph;
          const r = BR * Math.cos(thc) + 0.002, z = 0.36 + 0.98 * Math.sin(thc);
          const c = new THREE.Mesh(new THREE.CircleGeometry(0.045, 32), M.gold);
          c.position.set(r * Math.cos(phc), r * Math.sin(phc), z);
          const n = new THREE.Vector3(Math.cos(thc) / BR * Math.cos(phc), Math.cos(thc) / BR * Math.sin(phc), Math.sin(thc) / 0.98).normalize();
          c.lookAt(c.position.clone().add(n));
          bud.add(c);
        }
        // nozzle + silicone tip, on a tilted axis
        const nz = new THREE.Group();
        nz.position.copy(NZ_O);
        nz.lookAt(NZ_O.clone().add(NZ_D));
        nz.add(new THREE.Mesh(lathe([[0.36, -0.1], [0.36, 0.46], [0.3, 0.5], [0.0001, 0.5]]), M.shell));
        const tip = [];
        tip.push([0.37, 0.44], [0.48, 0.3]);
        tip.push(...arcPts(0.52, 0.34, 0.07, 0.06, -Math.PI * 0.75, 0, 8).slice(1));
        tip.push(...arcPts(0, 0.56, 0.64, 0.44, -0.35, Math.PI * 0.5 - 0.33, 40).slice(1));
        // outlet lip curls into the bore
        const last = tip[tip.length - 1];
        tip.push([last[0] - 0.03, TIP_L - 0.02], [0.2, TIP_L - 0.08], [0.2, TIP_L - 0.2]);
        nz.add(new THREE.Mesh(lathe(tip, 160), M.silicone));
        // bore floor: speaker grille
        const gr = new THREE.Mesh(disc(0.205, TIP_L - 0.2, 96, 1), M.grille);
        M.grille.map.repeat.set(2.2, 2.2); M.grille.bumpMap.repeat.set(2.2, 2.2);
        nz.add(gr);
        bud.add(nz);
        // light-ring glow (additive on colour, alpha untouched)
        const glowMat = new THREE.MeshBasicMaterial({
          map: TEX.ringGlow, color: new THREE.Color(1.0, 0.36, 0.08), transparent: true, depthWrite: false,
          blending: THREE.CustomBlending, blendEquation: THREE.AddEquation,
          blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, blendSrcAlpha: THREE.ZeroFactor, blendDstAlpha: THREE.OneFactor,
        });
        const glow = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 2.2), glowMat);
        glow.rotateY(Math.PI);
        glow.position.z = -0.06;
        glow.renderOrder = 10;
        bud.add(glow);
        bud.userData.glow = glow;
        bud.userData.tipLocal = NZ_O.clone().add(NZ_D.clone().multiplyScalar(TIP_L - 0.08));
        bud.userData.centerLocal = new THREE.Vector3(0, 0, 0.5);
        return bud;
      }

      /* ================= charging case ================= */
      const CA = 2.94, CB = 2.1, CC = 1.32, EX = 0.72, EY = 0.66, EZ = 0.8;
      function buildCase() {
        const g = new THREE.Group();
        g.add(new THREE.Mesh(pebble(CA, CB, CC, EX, EY, EZ), M.caseShell));
        // lid seam: closed loop on the surface at fixed height
        const loop = (ys, grow) => {
          const dy = Math.pow(ys / CB, 1 / EY);
          const s = Math.sqrt(1 - dy * dy);
          const pts = [];
          for (let i = 0; i < 360; i++) {
            const a = (i / 360) * Math.PI * 2;
            pts.push(new THREE.Vector3(CA * sp(s * Math.cos(a), EX) * grow, ys, CC * sp(s * Math.sin(a), EZ) * grow));
          }
          return new THREE.CatmullRomCurve3(pts, true);
        };
        // dark parting gap, half sunk into the shell
        g.add(new THREE.Mesh(new THREE.TubeGeometry(loop(0.82, 0.9996), 900, 0.0085, 8, true), M.seam));
        // polished chamfer on the base rim right under the gap: a thin specular catch line
        g.add(new THREE.Mesh(new THREE.TubeGeometry(loop(0.8, 0.9988), 900, 0.011, 10, true), M.chamfer));
        // hinge on the back of the lid, catches the rim light at the silhouette
        const hinge = new THREE.Mesh(new THREE.CapsuleGeometry(0.07, 0.9, 6, 24), M.chamfer);
        hinge.rotation.z = Math.PI / 2;
        hinge.position.set(0, 0.86, -CC + 0.035);
        g.add(hinge);
        // status LED on the front
        // status LED: glossy dark window, small emitter behind it, soft diffusion halo when lit
        const lz = CC * sp(Math.sqrt(1 - Math.pow(0.25 / CB, 2 / EY)), EZ);
        const win = new THREE.Mesh(new THREE.CircleGeometry(0.062, 48), M.ledWin);
        win.position.set(0, 0.25, lz + 0.003); g.add(win);
        const led = new THREE.Mesh(new THREE.CircleGeometry(0.026, 32), M.led);
        led.position.set(0, 0.25, lz + 0.0045); g.add(led);
        const halo = new THREE.Mesh(new THREE.PlaneGeometry(1.3, 1.3), addMat(TEX.ledGlow));
        halo.position.set(0, 0.25, lz + 0.008); halo.renderOrder = 10; g.add(halo);
        g.userData.halo = halo;
        g.userData.topLocal = new THREE.Vector3(0, CB, 0);
        return g;
      }

      const bud = buildBud();
      const kase = buildCase();
      scene.add(bud, kase);

      /* ================= environments ================= */
      const pmrem = new THREE.PMREMGenerator(renderer);
      function softTex(soft) {
        return canvasTex(256, 256, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
            const u = Math.abs(x / (w - 1) * 2 - 1), v = Math.abs(y / (h - 1) * 2 - 1);
            const f = (a) => { const e = clamp01((1 - a) / soft); return e * e * (3 - 2 * e); };
            const val = Math.round(255 * f(u) * f(v));
            const k = (y * w + x) * 4;
            img.data[k] = img.data[k + 1] = img.data[k + 2] = val; img.data[k + 3] = 255;
          }
          g.putImageData(img, 0, 0);
        }, true);
      }
      const SOFT = { hard: softTex(0.06), med: softTex(0.35), soft: softTex(0.9) };
      function makeEnv(panels, bg = 0) {
        const s = new THREE.Scene();
        s.background = new THREE.Color(bg, bg, bg);
        for (const p of panels) {
          const m = new THREE.MeshBasicMaterial({ color: new THREE.Color(...p.c).multiplyScalar(p.i), map: SOFT[p.soft || "hard"], side: THREE.DoubleSide });
          const mesh = new THREE.Mesh(new THREE.PlaneGeometry(p.w, p.h), m);
          mesh.position.set(...p.p);
          mesh.lookAt(0, 0, 0);
          s.add(mesh);
        }
        return pmrem.fromScene(s, 0, 0.1, 100).texture;
      }
      const W = [1, 1, 1];
      const ENV_RIM = makeEnv([
        { p: [6, 9, -7], w: 2, h: 16, i: 10, c: [1, 0.97, 0.94], soft: "med" },
        { p: [-8, 6, -6], w: 1.5, h: 14, i: 5, c: W, soft: "med" },
      ]);
      const ENV_DARK = makeEnv([
        { p: [-6, 9, 9], w: 18, h: 12, i: 8, c: W, soft: "soft" },
        { p: [9, 3, 7], w: 10, h: 12, i: 3, c: W, soft: "soft" },
        { p: [0, 6, 13], w: 16, h: 7, i: 1.9, c: W, soft: "soft" },
        { p: [12, 1, 2], w: 1.6, h: 14, i: 7, c: [1, 0.98, 0.96], soft: "med" },
        { p: [-12, 0, 2], w: 1.4, h: 14, i: 4, c: W, soft: "med" },
        { p: [0, 12, 2], w: 16, h: 2.4, i: 5, c: W, soft: "med" },
        { p: [0, -10, 5], w: 22, h: 10, i: 1.1, c: W, soft: "soft" },
        { p: [-4.5, 1, 12], w: 1.1, h: 18, i: 5, c: W, soft: "med" },
        { p: [5.5, 0, 11], w: 0.8, h: 18, i: 3.2, c: [1, 0.98, 0.95], soft: "med" },
      ]);
      const ENV_LIGHT = makeEnv([
        { p: [-6, 9, 10], w: 20, h: 14, i: 6, c: W, soft: "soft" },
        { p: [10, 4, 7], w: 10, h: 12, i: 3.2, c: W, soft: "soft" },
        { p: [0, 1, 14], w: 16, h: 12, i: 2.4, c: W, soft: "soft" },
        { p: [0, -10, 6], w: 18, h: 8, i: 2.0, c: W, soft: "soft" },
        { p: [0, 0, -12], w: 20, h: 14, i: 1.6, c: W, soft: "soft" },
        { p: [13, 0, 0], w: 1.6, h: 14, i: 5, c: W, soft: "med" },
      ], 0.9);

      let envRotY = 0;
      function setEnv(env, k) {
        for (const n in M) { M[n].envMap = env; M[n].envMapIntensity = k; M[n].envMapRotation.set(0, envRotY, 0); }
      }

      /* ================= post: rim + key -> neutral tonemap -> 2x resolve, alpha kept ================= */
      const rtOpts = { type: THREE.HalfFloatType, samples: 4, colorSpace: THREE.LinearSRGBColorSpace };
      const rtA = new THREE.WebGLRenderTarget(RW, RH, rtOpts);
      const rtB = new THREE.WebGLRenderTarget(RW, RH, rtOpts);
      const finalMat = new THREE.ShaderMaterial({
        uniforms: { tA: { value: rtA.texture }, tB: { value: rtB.texture }, kA: { value: 0 }, texel: { value: new THREE.Vector2(1 / RW, 1 / RH) }, frame: { value: 0 }, grain: { value: 0.012 }, expo: { value: 1.0 } },
        vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
        fragmentShader: `uniform sampler2D tA; uniform sampler2D tB; uniform float kA; uniform vec2 texel; uniform float frame; uniform float grain; uniform float expo; varying vec2 vUv;
          float h12(vec2 p){ vec3 p3 = fract(vec3(p.xyx) * 0.1031); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); }
          vec3 neutral(vec3 color){
            const float S = 0.76; const float D = 0.15;
            float x = min(color.r, min(color.g, color.b));
            float off = x < 0.08 ? x - 6.25 * x * x : 0.04;
            color -= off;
            float peak = max(color.r, max(color.g, color.b));
            if (peak < S) return color;
            float d = 1.0 - S;
            float np = 1.0 - d * d / (peak + d - S);
            color *= np / peak;
            float g = 1.0 - 1.0 / (D * (peak - np) + 1.0);
            return mix(color, vec3(np), g);
          }
          vec3 srgb(vec3 c){ return mix(c * 12.92, 1.055 * pow(c, vec3(1.0/2.4)) - 0.055, step(0.0031308, c)); }
          vec4 tap(vec2 uv){
            vec4 a = texture2D(tA, uv); vec4 b = texture2D(tB, uv);
            float al = max(a.a, b.a);
            vec3 c = kA * a.rgb + b.rgb;               // premultiplied, linear HDR
            vec3 u = al > 0.0001 ? c / al : vec3(0.0);
            u = srgb(clamp(neutral(max(u * expo, vec3(0.0))), 0.0, 1.0));
            return vec4(u * al, al);
          }
          void main(){
            vec4 c = tap(vUv + texel * vec2(-0.75, -0.75)) + tap(vUv + texel * vec2(0.75, -0.75)) + tap(vUv + texel * vec2(-0.75, 0.75)) + tap(vUv + texel * vec2(0.75, 0.75));
            c *= 0.25;
            vec2 px = floor(gl_FragCoord.xy);
            float n = (h12(px + frame * 17.31) + h12(px * 1.37 + frame * 3.7 + 11.0)) - 1.0;
            c.rgb += n * grain * c.a * sqrt(clamp(dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0));
            gl_FragColor = vec4(clamp(c.rgb, 0.0, c.a), c.a);
          }`,
        depthTest: false, depthWrite: false, blending: THREE.NoBlending,
      });
      const fsScene = new THREE.Scene();
      const fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
      fsScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), finalMat));

      /* ================= placement in screen space ================= */
      const ray = new THREE.Raycaster();
      function setCamera(elev, dist, fov) {
        camera.fov = fov; camera.updateProjectionMatrix();
        camera.position.set(0, Math.sin(elev) * dist, Math.cos(elev) * dist);
        camera.lookAt(0, 0, 0);
        camera.updateMatrixWorld();
      }
      // put obj so that its local anchor lands on screen pixel (sx, sy), anchor depth plane = z of anchor offset
      function placeAt(obj, anchorLocal, sx, sy) {
        obj.updateMatrix();
        const off = anchorLocal.clone().multiply(obj.scale).applyQuaternion(obj.quaternion);
        const ndc = new THREE.Vector2(sx / OW * 2 - 1, -(sy / OH * 2 - 1));
        ray.setFromCamera(ndc, camera);
        const r = ray.ray;
        const k = (off.z - r.origin.z) / r.direction.z;
        const hit = r.origin.clone().addScaledVector(r.direction, k);
        obj.position.copy(hit.sub(off));
      }
      function orient(obj, inward, upHint = new THREE.Vector3(0, 1, 0)) {
        const z = inward.clone().normalize();
        const x = new THREE.Vector3().crossVectors(upHint, z).normalize();
        const y = new THREE.Vector3().crossVectors(z, x);
        obj.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(x, y, z));
      }
      function project(obj, local) {
        obj.updateMatrixWorld();
        const v = local.clone().applyMatrix4(obj.matrixWorld).project(camera);
        return [(v.x + 1) / 2 * OW, (1 - v.y) / 2 * OH];
      }

      /* ================= direction ================= */
      function renderAt(t) {
        let env = ENV_DARK, rimK = 0, keyK = 1, expo = 1.0, glowK = 0, ledK = 0;
        envRotY = 0;
        if (t < C.cut2) {
          // scene 1: case lights up in the dark, sinks 15% as the wordmark rises behind it
          setCamera(0.12, 34, 20);
          bud.visible = false; kase.visible = true;
          const s1 = C.s1;
          const sink = expoOut(seg(t, s1.rise[0], s1.rise[1]));
          kase.scale.setScalar(1.82 * (1 + 0.025 * seg(t, 0, C.cut2)));
          kase.rotation.set(-0.02, -0.07 + 0.06 * sineInOut(seg(t, 0, C.cut2)), 0);
          placeAt(kase, kase.userData.topLocal, 960, lerp(s1.caseTop0, s1.caseTop1, sink));
          rimK = sineInOut(seg(t, 0.0, 0.45));
          keyK = 0.03 + 0.97 * power2InOut(seg(t, 0.25, 1.15));
          M.ring.emissiveIntensity = 0;
          const lk = power2InOut(seg(t, 1.1, 1.5));
          M.led.emissiveIntensity = 0;
          M.ledWin.emissiveIntensity = 1.0 * lk;
          ledK = lk;
          // light bands slide across the shell as the studio turns
          envRotY = lerp(-0.32, 0.22, sineInOut(seg(t, 0, C.cut2)));
        } else if (t < C.cut3) {
          // scene 2: earbud close-up, tip (sound outlet) fixed on screen, slow drift around it
          setCamera(0.0, 34, 20);
          bud.visible = true; kase.visible = false;
          env = ENV_LIGHT; expo = 1.0;
          const k = seg(t, C.cut2, C.cut3);
          bud.scale.setScalar(2.3 * (1 + 0.02 * k));
          const yaw = lerp(-0.06, 0.06, k);
          orient(bud, new THREE.Vector3(Math.cos(-0.3 + yaw), -0.12, Math.sin(-0.3 + yaw)).normalize());
          placeAt(bud, bud.userData.tipLocal, C.s2.tipX, C.s2.tipY);
          M.ring.emissiveIntensity = 0;
          rimK = 0; keyK = 1;
        } else {
          // scene 3: earbud drops into the gap in the sentence, then its light ring turns on
          setCamera(0.0, 34, 20);
          bud.visible = true; kase.visible = false;
          const s3 = C.s3;
          const k = expoOut(seg(t, s3.drop[0], s3.drop[1]));
          bud.scale.setScalar(1.3);
          const yaw = lerp(0.62, 0.5, sineInOut(seg(t, s3.drop[0], C.end)));
          orient(bud, new THREE.Vector3(Math.sin(yaw), -0.12, -Math.cos(yaw)).normalize());
          placeAt(bud, bud.userData.centerLocal, 944, lerp(-260, s3.y, k));
          const gk = power2InOut(seg(t, s3.glow[0], s3.glow[1]));
          M.ring.emissiveIntensity = 0.8 * gk;
          glowK = 0.9 * gk;
          rimK = 1; keyK = 1;
        }

        const glow = bud.userData.glow;
        // pass A: rim only
        glow.visible = false;
        const halo = kase.userData.halo;
        halo.visible = false;
        setEnv(ENV_RIM, 1);
        renderer.setRenderTarget(rtA); renderer.setClearColor(0x000000, 0); renderer.clear();
        renderer.render(scene, camera);
        // pass B: key studio
        glow.visible = glowK > 0.001;
        glow.material.opacity = 1;
        glow.material.color.setRGB(1.0 * glowK, 0.36 * glowK, 0.08 * glowK);
        halo.visible = kase.visible && ledK > 0.001;
        halo.material.color.setRGB(0.95 * ledK, 0.62 * ledK, 0.3 * ledK);
        setEnv(env, keyK);
        renderer.setRenderTarget(rtB); renderer.clear();
        renderer.render(scene, camera);
        renderer.setRenderTarget(null);
        finalMat.uniforms.kA.value = rimK;
        finalMat.uniforms.expo.value = expo;
        finalMat.uniforms.frame.value = Math.round(t * 30);
        renderer.clear();
        renderer.render(fsScene, fsCam);
      }

      window.__ti = { renderAt, project, bud, kase, camera };
      window.addEventListener("hf-seek", (e) => renderAt(e.detail.time));
      renderAt(window.__hfThreeTime || 0);
    </script>
  </body>
</html>
```
