# Light Reveal

The product emerges from pure black as exposure rises, with a barely perceptible push-in, and the closing shot runs the same move in reverse.

- Position in a launch film: Opening
- Slug: light-reveal
- Preview video: https://media.motionboard.app/light-reveal.mp4
- Recipe page: https://motionboard.app/recipes/light-reveal/
- 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 `light-reveal`. 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. Rim light on 0.0–0.4s; fade-up 0.4–1.6s (1.2s, 36 frames); hold with slow push 1.6–4.4s (2.8s, 84 frames). Cut at 4.4s to three colorways: each fades up over 1.5s (45 frames), 0.1s apart from the left, starting 4.55s; cut back to the single phone at 7.0s; fade-out in reverse 7.35–8.55s (1.2s, 36 frames), rim light off 8.55–8.9s; wordmark 8.78–9.58s, held to 11s |
| Easing | Fade-up and fade-out `cubic-bezier(0.65,0,0.35,1)` (power2.inOut); rim light and the hold push use sine.inOut; multi-color fade-up `cubic-bezier(0.16,1,0.3,1)` (expo.out); wordmark power2.out |
| Camera | Perspective, 24° fov. Single-phone shot: close-up on the camera bar from slightly above, distance 13 units, push-in during the hold scale 1.00→1.03, no depth of field. Three-color shot: straight-on at distance 47 units, push-in 1.00→1.015 linear over 4.4–7.0s |
| Subject | A 3D phone (Three.js) that never moves. Exposure runs from 3.5% to 100% on one power2.inOut curve applied per material in order: lens glass and optics 0–0.55 of the curve, flash 0.18–0.72, metal edges and camera bar 0.30–0.95, back glass 0.45–1.0. Three colorways, left to right: silver, orange, blue, 8.7 units apart |
| Style | Pure black background. Rim pass: one hard strip light high and behind on the right, grazing the top and right edges. Key pass: large soft box upper left plus a hard strip on the right. ACES tone mapping at exposure 1.25, bloom, 0.28 vignette, 0.022 film grain; wordmark in Inter 600 76px, `#ecebe8`, blur 6px→0 |

## Why it works

The reveal comes from light alone, with no fade or cross-dissolve. The camera hole, flash, and edge reflections appear in sequence, and the different reflections of each material (silver, orange, deep blue) show up one by one during the fade-up. The opening and the closing use the same camera position, so the film starts and ends on the same frame.

## Prompt

> Build a product opening called Light Reveal. Pure black background, one phone in a close-up on its camera bar (24° fov, distance 13 units, from slightly above). Render two light passes and add them: a rim pass (one hard strip light high and behind on the right, grazing the top and right edges) that fades in over 0–0.4s, and a key pass (large soft box upper left plus a hard strip on the right) that starts at 3.5% and rises to 100% over 1.2 seconds (0.4–1.6s) with power2.inOut (cubic-bezier(0.65,0,0.35,1)). Stage that one curve per material: the camera lenses first, then the flash, then the metal edges and camera bar, and the back glass last, with no fade or cross-dissolve. Hold for 2.8 seconds (1.6–4.4s) and push in slowly by 3% (scale 1.00→1.03, sine.inOut); the phone does not move. At 4.4s cut to a straight-on shot of three colorways (silver, orange, blue) 8.7 units apart; starting 4.55s each fades up over 1.5 seconds with expo.out (cubic-bezier(0.16,1,0.3,1)), 0.1 seconds after the previous one from the left, while the camera pushes in 1.5% linearly. At 7.0s cut back to the single phone and run the key light down in reverse over 1.2 seconds (7.35–8.55s), then turn the rim light off over 8.55–8.9s. The wordmark fades in over 8.78–9.58s (power2.out, blur 6px→0, y 8px→0) and holds to 11s. Use ACES tone mapping, bloom, a 0.28 vignette, 0.022 grain, and no depth of field. Build it with Three.js (MeshPhysicalMaterial, per-material envMapIntensity) driven by a seekable time function, with GSAP for the wordmark.

## Often confused with

- [Rotate to Lockup](https://motionboard.app/r/rotate-to-lockup.md): Light Reveal keeps the subject still and changes only the light; Rotate to Lockup turns the object to a target angle.
- [X-ray Dissolve](https://motionboard.app/r/x-ray-dissolve.md): X-ray Dissolve switches between an inner layer and an outer shell, while Light Reveal only adjusts exposure.

## Source files

Runnable HyperFrames project, 3 files.

- https://motionboard.app/source/light-reveal/hyperframes.json (353 B)
- https://motionboard.app/source/light-reveal/index.html (34.4 KB)
- https://motionboard.app/source/light-reveal/package.json (301 B)

Download all of them:

```sh
curl -fsSL --create-dirs -o "light-reveal/hyperframes.json" "https://motionboard.app/source/light-reveal/hyperframes.json"
curl -fsSL --create-dirs -o "light-reveal/index.html" "https://motionboard.app/source/light-reveal/index.html"
curl -fsSL --create-dirs -o "light-reveal/package.json" "https://motionboard.app/source/light-reveal/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>
      * { 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; }
      #stage { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #gl { display: block; width: 1920px; height: 1080px; }
      #mark {
        position: absolute; left: 0; top: 0; width: 1920px; height: 1080px;
        display: flex; align-items: center; justify-content: center;
      }
      #word {
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 76px; line-height: 1;
        letter-spacing: -0.02em; color: #ecebe8; opacity: 0;
      }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="11" data-width="1920" data-height="1080">
      <div id="stage" data-hold class="clip" data-start="0" data-duration="11" data-track-index="0">
        <canvas id="gl" width="1920" height="1080"></canvas>
      </div>
      <div id="mark" class="clip" data-start="0" data-duration="11" data-track-index="1">
        <div id="word">Strata</div>
      </div>
    </div>

    <script>
      const tl = gsap.timeline({ paused: true });
      tl.fromTo("#word", { opacity: 0, y: 8, filter: "blur(6px)" }, { opacity: 1, y: 0, filter: "blur(0px)", duration: 0.8, ease: "power2.out" }, 8.78);
      tl.to("#word", { opacity: 1, duration: 0.01 }, 10.9);
      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = tl;
    </script>

    <script type="module">
      import * as THREE from "three";
      import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";
      import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
      import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
      import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
      import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

      /* ================= 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;

      /* ================= renderer ================= */
      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: false, preserveDrawingBuffer: true, powerPreference: "high-performance" });
      renderer.setPixelRatio(1);
      renderer.setSize(OW, OH, false);
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 1.25;
      renderer.outputColorSpace = THREE.SRGBColorSpace;
      renderer.transmissionResolutionScale = 2;

      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x000000);
      const camera = new THREE.PerspectiveCamera(24, OW / OH, 0.5, 400);

      /* ================= procedural geometry ================= */
      // closed rounded-rectangle outline, CCW, with outward 2D normals
      function rrOutline(w, h, r, arcSeg = 24) {
        const pts = [];
        const cs = [[w / 2 - r, h / 2 - r, 0], [-w / 2 + r, h / 2 - r, 0.5], [-w / 2 + r, -h / 2 + r, 1], [w / 2 - r, -h / 2 + r, 1.5]];
        for (const [cx, cy, a0] of cs) {
          for (let i = 0; i <= arcSeg; i++) {
            const a = (a0 + (i / arcSeg) * 0.5) * Math.PI;
            const nx = Math.cos(a), ny = Math.sin(a);
            pts.push({ x: cx + nx * r, y: cy + ny * r, nx, ny });
          }
        }
        return pts;
      }
      // slab swept along an outline: flat back cap at zb, flat front cap at zf,
      // elliptical fillets (rbo x rbz) at the back edge and (rfo x rfz) at the front edge
      function slab(w, h, r, zf, zb, rbo, rbz, rfo, rfz, opts = {}) {
        const out = rrOutline(w, h, r, opts.arcSeg || 28);
        const N = out.length;
        const prof = [];
        const S = opts.filletSeg || 14;
        for (let i = 0; i <= S; i++) {
          const th = (i / S) * Math.PI / 2;
          const n = [rbz * Math.sin(th), rbo * Math.cos(th)];
          const l = Math.hypot(n[0], n[1]) || 1;
          prof.push({ o: rbo * (1 - Math.sin(th)), z: zb - rbz * (1 - Math.cos(th)), nr: n[0] / l, nz: n[1] / l });
        }
        if (rfo > 0) {
          for (let i = 0; i <= S; i++) {
            const ph = (i / S) * Math.PI / 2;
            const n = [rfz * Math.cos(ph), -rfo * Math.sin(ph)];
            const l = Math.hypot(n[0], n[1]) || 1;
            prof.push({ o: rfo * (1 - Math.cos(ph)), z: zf + rfz * (1 - Math.sin(ph)), nr: n[0] / l, nz: n[1] / l });
          }
        } else {
          prof.push({ o: 0, z: zf, nr: 1, nz: 0 });
        }
        const P = prof.length;
        const pos = [], nor = [], uv = [], idx = [];
        const U = (x, y) => [x / w + 0.5, y / h + 0.5];
        for (let j = 0; j < P; j++) {
          const p = prof[j];
          for (let i = 0; i < N; i++) {
            const q = out[i];
            const x = q.x - q.nx * p.o, y = q.y - q.ny * p.o;
            pos.push(x, y, p.z);
            const nx = q.nx * p.nr, ny = q.ny * p.nr, nz = p.nz;
            const l = Math.hypot(nx, ny, nz);
            nor.push(nx / l, ny / l, nz / l);
            uv.push(...U(x, y));
          }
        }
        for (let j = 0; j < P - 1; j++) {
          for (let i = 0; i < N; i++) {
            const a = j * N + i, b = j * N + ((i + 1) % N), c = (j + 1) * N + i, d = (j + 1) * N + ((i + 1) % N);
            idx.push(a, c, b, b, c, d);
          }
        }
        // caps
        const cap = (ring, z, nz) => {
          const base = pos.length / 3;
          pos.push(0, 0, z); nor.push(0, 0, nz); uv.push(...U(0, 0));
          for (let i = 0; i < N; i++) {
            const k = (ring * N + i) * 3;
            pos.push(pos[k], pos[k + 1], z); nor.push(0, 0, nz); uv.push(...U(pos[k], pos[k + 1]));
          }
          for (let i = 0; i < N; i++) {
            const a = base + 1 + i, b = base + 1 + ((i + 1) % N);
            if (nz > 0) idx.push(base, a, b); else idx.push(base, b, a);
          }
        };
        if (opts.holes) {
          // back cap with circular holes, triangulated with ShapeUtils (earcut)
          const contour = [];
          for (let i = 0; i < N; i++) { const k = i * 3; contour.push(new THREE.Vector2(pos[k], pos[k + 1])); }
          const holes = opts.holes.map(({ x, y, r: hr }) => {
            const hp = [];
            for (let i = 0; i < 96; i++) { const a = -(i / 96) * Math.PI * 2; hp.push(new THREE.Vector2(x + hr * Math.cos(a), y + hr * Math.sin(a))); }
            return hp;
          });
          const tris = THREE.ShapeUtils.triangulateShape(contour, holes);
          const all = contour.concat(...holes);
          const base = pos.length / 3;
          for (const v of all) { pos.push(v.x, v.y, zb); nor.push(0, 0, 1); uv.push(...U(v.x, v.y)); }
          for (const [a, b, c] of tris) idx.push(base + a, base + b, base + c);
        } else {
          cap(0, zb, 1);
        }
        if (!opts.noFront) cap(P - 1, zf, -1);
        const g = new THREE.BufferGeometry();
        g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
        g.setAttribute("normal", new THREE.Float32BufferAttribute(nor, 3));
        g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
        g.setIndex(idx);
        return g;
      }

      // lathe around z axis; profile = [[r, z], ...] ; arcs given as helpers
      function arcPts(cx, cz, rad, a0, a1, n) {
        const p = [];
        for (let i = 0; i <= n; i++) {
          const a = a0 + (a1 - a0) * (i / n);
          p.push([cx + rad * Math.cos(a), cz + rad * Math.sin(a)]);
        }
        return p;
      }
      function lathe(profile, seg = 160) {
        const g = new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(r, z)), seg);
        g.rotateX(Math.PI / 2); // lathe y-axis -> z-axis
        return g;
      }
      function disc(r, z, seg = 160) {
        const g = new THREE.CircleGeometry(r, seg);
        g.translate(0, 0, z);
        return g;
      }

      /* ================= procedural textures ================= */
      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;
      }
      function rrPath(g, x, y, w, h, r) {
        g.beginPath();
        g.moveTo(x + r, y);
        g.arcTo(x + w, y, x + w, y + h, r);
        g.arcTo(x + w, y + h, x, y + h, r);
        g.arcTo(x, y + h, x, y, r);
        g.arcTo(x, y, x + w, y, r);
        g.closePath();
      }

      /* ================= phone design: STRATA ================= */
      const PW = 7.2, PH = 15.0, PR = 1.12, PD = 0.8;
      const ZB = PD / 2; // back plane of the aluminium body
      const GW = PW - 0.24, GH = PH - 0.24, GR = PR - 0.12; // back glass
      const GZ = ZB + 0.016; // glass top
      const BW = 6.56, BH = 2.6, BR = 1.05; // camera bar
      const BY = PH / 2 - 0.34 - BH / 2;
      const BZ = GZ + 0.11; // camera bar top
      const LENSES = [
        { x: -2.06, y: 0, R: 0.94 },
        { x: -0.05, y: 0, R: 0.82 },
        { x: 1.62, y: 0, R: 0.62 },
      ];
      const FLASH = { x: 2.7, y: 0.5, R: 0.27 };
      const MIC = { x: 2.7, y: -0.07, R: 0.055 };
      const TOF = { x: 2.7, y: -0.6, R: 0.22 };

      const VARIANTS = {
        orange: { metal: 0xe8793a, metalRough: 0.42, glass: 0xf27a34, glassRough: 0.55 },
        silver: { metal: 0xadadab, metalRough: 0.46, glass: 0xdedcd8, glassRough: 0.55 },
        blue: { metal: 0x4c6290, metalRough: 0.4, glass: 0x34466a, glassRough: 0.55 },
      };

      // textures shared by all phones
      const TEX = {};
      function drawGlassAO(g, w, h, falloff) {
        g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
        if (falloff === "diag") {
          // line-up: soft key from the upper left, glass falls off toward the lower right
          const lg = g.createLinearGradient(0, 0, w * 0.6, h);
          lg.addColorStop(0.0, "#ffffff"); lg.addColorStop(0.35, "#e4e4e4"); lg.addColorStop(1.0, "#6a6a6a");
          g.fillStyle = lg; g.fillRect(0, 0, w, h);
        } else if (falloff) {
          // close-up only: the key pools on the camera bar and falls off down the back
          const lg = g.createLinearGradient(0, 0, 0, h);
          lg.addColorStop(0.0, "#ffffff"); lg.addColorStop(0.2, "#f6f6f6"); lg.addColorStop(0.44, "#3c3c3c"); lg.addColorStop(1.0, "#202020");
          g.fillStyle = lg; g.fillRect(0, 0, w, h);
        }
        const sx = w / GW, sy = h / GH;
        const x = (-BW / 2 + GW / 2) * sx, y = (GH / 2 - (BY + BH / 2)) * sy;
        g.filter = "blur(10px)";
        g.fillStyle = "rgba(0,0,0,0.55)";
        rrPath(g, x - 4, y - 4, BW * sx + 8, BH * sy + 8, BR * sx + 4); g.fill();
        g.filter = "blur(3px)";
        g.fillStyle = "rgba(0,0,0,0.5)";
        rrPath(g, x, y, BW * sx, BH * sy, BR * sx); g.fill();
        g.filter = "none";
        // edge darkening where the glass meets the frame lip
        g.strokeStyle = "rgba(0,0,0,0.35)"; g.lineWidth = 3;
        rrPath(g, 1.5, 1.5, w - 3, h - 3, GR * sx); g.stroke();
      }
      // satin-etched glass: fine seeded noise as a normal map
      TEX.frost = (() => {
        let a = 0x9e3779b9;
        const rnd = () => { a |= 0; 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; };
        const t = canvasTex(1024, 1024, (g, w, h) => {
          const img = g.createImageData(w, h);
          for (let i = 0; i < w * h; i++) {
            img.data[i * 4] = 128 + (rnd() - 0.5) * 120;
            img.data[i * 4 + 1] = 128 + (rnd() - 0.5) * 120;
            img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255;
          }
          g.putImageData(img, 0, 0);
        });
        t.wrapS = t.wrapT = THREE.RepeatWrapping;
        return t;
      })();
      TEX.glassAO = canvasTex(512, 1024, (g, w, h) => drawGlassAO(g, w, h, false));
      TEX.glassFall = canvasTex(512, 1024, (g, w, h) => drawGlassAO(g, w, h, true));
      TEX.glassDiag = canvasTex(512, 1024, (g, w, h) => drawGlassAO(g, w, h, "diag"));
      TEX.barAO = canvasTex(1024, 512, (g, w, h) => {
        g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
        const sx = w / BW, sy = h / BH;
        const P = (o) => [(o.x + BW / 2) * sx, (BH / 2 - o.y) * sy];
        g.filter = "blur(7px)";
        for (const L of LENSES) {
          const [cx, cy] = P(L);
          g.fillStyle = "rgba(0,0,0,0.6)";
          g.beginPath(); g.arc(cx, cy, (L.R + 0.07) * sx, 0, Math.PI * 2); g.fill();
        }
        for (const o of [FLASH]) {
          const [cx, cy] = P(o);
          g.fillStyle = "rgba(0,0,0,0.45)";
          g.beginPath(); g.arc(cx, cy, (o.R + 0.08) * sx, 0, Math.PI * 2); g.fill();
        }
        g.filter = "none";
      });
      // etched wordmark on the frosted glass: glossy letters (roughness map, G channel)
      TEX.glassRough = canvasTex(1024, 2048, (g, w, h) => {
        g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);
        g.fillStyle = "#3a3a3a";
        g.font = "600 46px Inter, Helvetica, Arial, sans-serif";
        g.textAlign = "center"; g.textBaseline = "middle";
        g.save();
        g.translate(w / 2, h * 0.58);
        g.fillText("S T R A T A", 0, 0);
        g.restore();
      });
      TEX.flashBump = canvasTex(512, 512, (g, w, h) => {
        // fresnel diffuser: fine concentric grooves
        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, y - h / 2) / (w / 2);
          const v = r > 0.97 ? 128 : Math.round(128 + 110 * Math.sin(r * 2 * Math.PI * 16));
          const k = (y * w + x) * 4;
          img.data[k] = img.data[k + 1] = img.data[k + 2] = v; img.data[k + 3] = 255;
        }
        g.putImageData(img, 0, 0);
      });
      TEX.flashAlbedo = canvasTex(512, 512, (g, w, h) => {
        const rg = g.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, w / 2);
        rg.addColorStop(0, "#f4ecd6"); rg.addColorStop(0.8, "#e6dcc2"); rg.addColorStop(1, "#b9b09a");
        g.fillStyle = rg; g.fillRect(0, 0, w, h);
        // two LED dies behind the diffuser
        for (const dx of [-26, 26]) {
          g.fillStyle = "#cdb27a"; g.fillRect(w / 2 + dx - 17, h / 2 - 17, 34, 34);
          g.fillStyle = "#e2cf98"; g.fillRect(w / 2 + dx - 11, h / 2 - 11, 22, 22);
        }
      }, true);

      function makeMaterials(v) {
        const M = {};
        M.metal = new THREE.MeshPhysicalMaterial({ color: v.metal, metalness: 1, roughness: v.metalRough, clearcoat: 0.35, clearcoatRoughness: 0.22 });
        M.polish = new THREE.MeshPhysicalMaterial({ color: new THREE.Color(v.metal).lerp(new THREE.Color(0xffffff), 0.25), metalness: 1, roughness: 0.12 });
        M.glass = new THREE.MeshPhysicalMaterial({
          color: v.glass, metalness: 0, roughness: v.glassRough, roughnessMap: TEX.glassRough,
          clearcoat: 0.5, clearcoatRoughness: 0.45, ior: 1.5, specularIntensity: 0.8,
          aoMap: TEX.glassAO, aoMapIntensity: 1,
          normalMap: TEX.frost, normalScale: new THREE.Vector2(0.05, 0.05),
        });
        M.glass.normalMap = TEX.frost.clone(); M.glass.normalMap.repeat.set(3, 6); M.glass.normalMap.needsUpdate = true;
        M.bar = M.metal.clone();
        M.bar.aoMap = TEX.barAO; M.bar.aoMapIntensity = 1;
        M.front = new THREE.MeshPhysicalMaterial({ color: 0x050506, metalness: 0, roughness: 0.05, clearcoat: 1 });
        M.barrel = new THREE.MeshPhysicalMaterial({ color: 0x040405, metalness: 0.3, roughness: 0.5 });
        M.barrelDark = new THREE.MeshPhysicalMaterial({ color: 0x030303, metalness: 0, roughness: 0.75 });
        M.chrome = new THREE.MeshPhysicalMaterial({ color: 0x9a9ca0, metalness: 1, roughness: 0.16 });
        M.coverGlass = new THREE.MeshPhysicalMaterial({
          color: 0xffffff, metalness: 0, roughness: 0.0, transmission: 1, thickness: 0.02, ior: 1.52,
          specularIntensity: 0.18,
          transparent: false,
        });
        M.element = new THREE.MeshPhysicalMaterial({
          color: 0x010102, metalness: 0, roughness: 0.03, specularIntensity: 0.45,
          iridescence: 1, iridescenceIOR: 1.85, iridescenceThicknessRange: [150, 350], ior: 1.8,
        });
        M.element2 = new THREE.MeshPhysicalMaterial({ color: 0x010102, metalness: 0, roughness: 0.03, clearcoat: 1, clearcoatRoughness: 0.02, iridescence: 1, iridescenceIOR: 2.0, iridescenceThicknessRange: [420, 900], ior: 1.9 });
        M.flash = new THREE.MeshPhysicalMaterial({ color: 0xffffff, map: TEX.flashAlbedo, metalness: 0, roughness: 0.42, bumpMap: TEX.flashBump, bumpScale: 0.5 });
        M.flashCover = new THREE.MeshPhysicalMaterial({ color: 0xffffff, metalness: 0, roughness: 0.0, transmission: 1, thickness: 0.02, ior: 1.5, specularIntensity: 0.7 });
        M.tof = new THREE.MeshPhysicalMaterial({ color: 0x030305, metalness: 0, roughness: 0.04, specularIntensity: 0.3 });
        M.button = M.metal;
        return M;
      }

      /* ---------- assemblies ---------- */
      const LENS_RI = 0.86; // aperture / outer radius
      function lensAssembly(L, M) {
        const g = new THREE.Group();
        const R = L.R;
        const H = 0.15; // ring height above bar top
        const ri = R * LENS_RI;
        // anodised ring body: outer wall, rounded crown, narrow flat top
        const body = [[R + 0.012, -0.004], [R, 0.004]];
        body.push(...arcPts(R - 0.03, H - 0.03, 0.03, 0, Math.PI / 2, 10));
        body.push([ri + 0.04, H]);
        g.add(new THREE.Mesh(lathe(body), M.metal));
        // polished diamond-cut chamfer facing up and inward
        const ch = [[ri + 0.04, H], [ri + 0.004, H - 0.036], [ri, H - 0.042]];
        g.add(new THREE.Mesh(lathe(ch), M.polish));
        // short dark inner wall down to the cover glass
        g.add(new THREE.Mesh(lathe([[ri, H - 0.042], [ri, -0.11]]), M.barrelDark));
        // sapphire cover glass, recessed
        g.add(new THREE.Mesh(disc(ri, H - 0.055), M.coverGlass));
        // optical stack below: matte step, chrome retaining ring, second step, two coated elements
        const z1 = -0.02, z2 = -0.06;
        const step1 = new THREE.Mesh(new THREE.RingGeometry(ri * 0.80, ri, 160, 1), M.barrel);
        step1.position.z = z1; g.add(step1);
        g.add(new THREE.Mesh(lathe([[ri * 0.80, z1], [ri * 0.785, z1 - 0.006], [ri * 0.785, z2 + 0.004], [ri * 0.76, z2]]), M.chrome));
        const step2 = new THREE.Mesh(new THREE.RingGeometry(ri * 0.62, ri * 0.76, 160, 1), M.barrel);
        step2.position.z = z2; g.add(step2);
        const dome = (rad, z0, h) => {
          const pts = [];
          for (let i = 0; i <= 32; i++) {
            const r = rad * (1 - i / 32);
            pts.push([Math.max(r, 1e-4), z0 + h * Math.sqrt(Math.max(0, 1 - (r / rad) ** 2))]);
          }
          return lathe(pts);
        };
        const em = M.element.clone(); em.iridescenceThicknessRange = [100, [560, 640, 720][LENSES.indexOf(L)]]; M['el' + LENSES.indexOf(L)] = em;
        g.add(new THREE.Mesh(dome(ri * 0.62, z2, 0.12), em));
        g.add(new THREE.Mesh(disc(ri * 0.62, z2 - 0.001), M.barrelDark));
        g.position.set(L.x, L.y, BZ);
        return g;
      }

      function flashAssembly(M) {
        const g = new THREE.Group();
        const R = FLASH.R;
        const ring = [[R + 0.03, -0.01], [R + 0.03, 0.015], ...arcPts(R + 0.015, 0.015, 0.015, 0, Math.PI / 2, 6), [R, 0.03], [R, 0.022]];
        g.add(new THREE.Mesh(lathe(ring, 96), M.polish));
        g.add(new THREE.Mesh(lathe([[R, 0.022], [R, 0.004]], 96), M.barrelDark));
        g.add(new THREE.Mesh(disc(R, 0.004, 96), M.flash));
        g.add(new THREE.Mesh(disc(R, 0.024, 96), M.flashCover));
        g.position.set(FLASH.x, FLASH.y, BZ);
        return g;
      }
      function tofAssembly(M) {
        const g = new THREE.Group();
        const R = TOF.R;
        g.add(new THREE.Mesh(disc(R, 0.002, 96), M.tof));
        const ring = [[R + 0.012, -0.005], [R + 0.012, 0.003], [R, 0.003], [R, 0.0]];
        g.add(new THREE.Mesh(lathe(ring, 96), M.metal));
        g.position.set(TOF.x, TOF.y, BZ);
        const mic = new THREE.Mesh(lathe([[MIC.R, 0.001], [MIC.R, -0.03], [0.0001, -0.03]], 48), M.barrelDark);
        mic.position.set(MIC.x - TOF.x, MIC.y - TOF.y, 0);
        g.add(mic);
        return g;
      }

      function buildPhone(variantKey) {
        const M = makeMaterials(VARIANTS[variantKey]);
        const phone = new THREE.Group();
        // aluminium body
        const body = new THREE.Mesh(slab(PW, PH, PR, -PD / 2, ZB, 0.18, 0.2, 0.18, 0.2, { arcSeg: 36 }), M.metal);
        phone.add(body);
        // front glass (barely visible from behind)
        const front = new THREE.Mesh(slab(PW - 0.2, PH - 0.2, PR - 0.1, -PD / 2 - 0.012, -PD / 2 + 0.02, 0.02, 0.02, 0.02, 0.012), M.front);
        phone.add(front);
        // frosted back glass
        const glass = new THREE.Mesh(slab(GW, GH, GR, ZB - 0.05, GZ, 0.028, 0.022, 0, 0, { arcSeg: 36, noFront: true }), M.glass);
        phone.add(glass);
        // camera bar
        const bar = new THREE.Mesh(slab(BW, BH, BR, GZ - 0.02, BZ, 0.11, 0.1, 0, 0, { arcSeg: 36, noFront: true, holes: [...LENSES.map((L) => ({ x: L.x, y: L.y, r: L.R * LENS_RI + 0.004 })), { x: MIC.x, y: MIC.y, r: MIC.R }] }), M.bar);
        bar.position.y = BY;
        phone.add(bar);
        const cam = new THREE.Group();
        cam.position.y = BY;
        cam.position.z = 0;
        for (const L of LENSES) cam.add(lensAssembly(L, M));
        cam.add(flashAssembly(M));
        cam.add(tofAssembly(M));
        phone.add(cam);
        // side keys
        const keyGeo = (len) => new RoundedBoxGeometry(0.12, len, 0.34, 5, 0.05);
        const power = new THREE.Mesh(keyGeo(1.9), M.metal);
        power.position.set(PW / 2 + 0.005, 2.6, 0);
        phone.add(power);
        const ctl = new THREE.Mesh(keyGeo(1.25), M.metal);
        ctl.position.set(PW / 2 + 0.005, -1.6, 0);
        phone.add(ctl);
        for (const [y, len] of [[4.3, 0.7], [2.9, 1.2], [1.4, 1.2]]) {
          const k = new THREE.Mesh(keyGeo(len), M.metal);
          k.position.set(-PW / 2 - 0.005, y, 0);
          phone.add(k);
        }
        // antenna bands: thin dark inserts across the frame sides
        const bandMat = new THREE.MeshPhysicalMaterial({ color: 0x2a2a2a, roughness: 0.55, metalness: 0 });
        M.band = bandMat;
        for (const y of [PH / 2 - 1.6, -PH / 2 + 1.6]) {
          for (const sx of [-1, 1]) {
            const b = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.07, PD - 0.42), bandMat);
            b.position.set(sx * (PW / 2 + 0.0005), y, 0);
            phone.add(b);
          }
        }
        phone.userData.M = M;
        return phone;
      }

      /* ================= 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) {
        const s = new THREE.Scene();
        s.background = new THREE.Color(0x000000);
        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);
          if (p.rot) mesh.rotateZ(p.rot);
          s.add(mesh);
        }
        const rt = pmrem.fromScene(s, 0, 0.1, 100);
        return rt.texture;
      }
      const WHITE = [1, 1, 1];
      // rim: one hard strip high and behind on the right, grazing the top/right edges
      const ENV_RIM = makeEnv([
        { p: [7, 9, -6], w: 1.2, h: 16, i: 14, c: [1, 0.97, 0.94], rot: 0.9 },
      ]);
      // key studio: large soft box upper-left in front, hard strip right, faint floor bounce
      const ENV_KEY = makeEnv([
        { p: [-5, 9, 9.5], w: 14, h: 10, i: 5, c: WHITE, soft: "soft" },
        { p: [8, 4, 8], w: 6, h: 10, i: 1.2, c: WHITE, soft: "soft" },
        { p: [10, 1, 5], w: 1.4, h: 14, i: 6, c: [1, 0.98, 0.96], soft: "med" },
        { p: [0, 10, 3], w: 14, h: 1.2, i: 4, c: WHITE, soft: "med" },
        { p: [0, -9, 6], w: 16, h: 6, i: 0.35, c: WHITE, soft: "soft" },
        { p: [0, -6, 13], w: 16, h: 8, i: 0.5, c: WHITE, soft: "soft" },
      ]);

      // key studio for the frontal line-up: one big gradient softbox upper-left, edge strips both sides
      const ENV_KEY2 = makeEnv([
        { p: [-4, 5, 15], w: 22, h: 16, i: 3.0, c: WHITE, soft: "soft" },
        { p: [9, -5, 12], w: 10, h: 10, i: 0.5, c: WHITE, soft: "soft" },
        { p: [-13, 0, 3], w: 1.2, h: 16, i: 5, c: WHITE, soft: "med" },
        { p: [13, 0, 3], w: 1.2, h: 16, i: 3, c: [1, 0.98, 0.96], soft: "med" },
        { p: [0, 13, 4], w: 16, h: 1.2, i: 4, c: WHITE, soft: "med" },
      ]);

      /* ================= scene ================= */
      const phones = { silver: buildPhone("silver"), orange: buildPhone("orange"), blue: buildPhone("blue") };
      for (const p of Object.values(phones)) scene.add(p);

      /* ================= post: A (rim) + B (key) -> bloom -> tonemap -> downsample + grain ================= */
      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 composer = new EffectComposer(renderer, new THREE.WebGLRenderTarget(RW, RH, { type: THREE.HalfFloatType }));
      composer.renderToScreen = false;
      composer.setPixelRatio(SS);
      const combine = new ShaderPass({
        uniforms: { tDiffuse: { value: null }, tA: { value: null }, tB: { value: null }, kA: { value: 1 }, kB: { value: 1 } },
        vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
        fragmentShader: `uniform sampler2D tA; uniform sampler2D tB; uniform float kA; uniform float kB; varying vec2 vUv;
          void main(){ vec3 c = kA * texture2D(tA, vUv).rgb + kB * texture2D(tB, vUv).rgb; c = max(c, vec3(0.0)); c = min(c, vec3(64.0)); gl_FragColor = vec4(c, 1.0); }`,
      });
      combine.uniforms.tA.value = rtA.texture;
      combine.uniforms.tB.value = rtB.texture;
      composer.addPass(combine);
      const bloom = new UnrealBloomPass(new THREE.Vector2(RW, RH), 0.3, 0.35, 1.6);
      composer.addPass(bloom);
      composer.addPass(new OutputPass());
      composer.setPixelRatio(SS);

      const finalMat = new THREE.ShaderMaterial({
        uniforms: { tSrc: { value: null }, texel: { value: new THREE.Vector2(1 / RW, 1 / RH) }, frame: { value: 0 }, grain: { value: 0.022 }, vig: { value: 0.28 } },
        vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
        fragmentShader: `uniform sampler2D tSrc; uniform vec2 texel; uniform float frame; uniform float grain; uniform float vig; 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); }
          void main(){
            // 2x supersample resolve: 4 bilinear taps = 4x4 tent over the source texels
            vec3 c = vec3(0.0);
            c += texture2D(tSrc, vUv + texel * vec2(-0.75, -0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2( 0.75, -0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2(-0.75,  0.75)).rgb;
            c += texture2D(tSrc, vUv + texel * vec2( 0.75,  0.75)).rgb;
            c *= 0.25;
            vec2 q = vUv - 0.5; q.x *= 1.777;
            float v = 1.0 - vig * smoothstep(0.35, 1.05, length(q));
            c *= v;
            vec2 px = floor(gl_FragCoord.xy);
            float n = (h12(px + frame * 17.31) + h12(px * 1.37 + frame * 3.7 + 11.0)) - 1.0;
            float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
            c += n * grain * (0.35 + 0.65 * sqrt(clamp(lum, 0.0, 1.0))) ;
            gl_FragColor = vec4(c, 1.0);
          }`,
        depthTest: false, depthWrite: false,
      });
      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));

      /* ================= direction ================= */
      // Shot 1 / 3: single orange phone, camera-bar close-up
      const S1 = {
        target: new THREE.Vector3(0.25, 5.3, 0.4),
        dir: new THREE.Vector3(0.03, -0.75, 1).normalize(),
        dist: 13,
        fov: 24,
      };
      // Shot 2: three colours side by side
      const S2 = {
        target: new THREE.Vector3(0, 0.0, 0),
        dir: new THREE.Vector3(0, 0.0, 1).normalize(),
        dist: 47,
        fov: 24,
      };
      const ORDER = ["silver", "orange", "blue"];

      function setShot(shot, push) {
        camera.fov = shot.fov;
        camera.updateProjectionMatrix();
        camera.position.copy(shot.target).addScaledVector(shot.dir, shot.dist / push);
        camera.lookAt(shot.target);
      }

      // per-material key weights for the staged reveal inside one phone
      // order: lens holes -> flash -> metal edges -> glass body
      const STAGE = [
        { mats: ["coverGlass", "el0", "el1", "el2", "chrome", "barrel", "tof"], a: 0.0, b: 0.55 },
        { mats: ["flash", "flashCover"], a: 0.18, b: 0.72 },
        { mats: ["polish", "metal", "bar", "band"], a: 0.3, b: 0.95 },
        { mats: ["glass", "front", "barrelDark"], a: 0.45, b: 1.0 },
      ];
      const KEY_FLOOR = 0.035;
      function setKeyWeights(phone, k, staged, env, floorK) {
        const M = phone.userData.M;
        for (const n in M) if ("envMapIntensity" in M[n]) M[n].envMap = env;
        for (const st of STAGE) {
          const w = staged ? power2InOut(seg(k, st.a, st.b)) : k;
          const val = KEY_FLOOR * floorK + (1 - KEY_FLOOR) * w;
          for (const n of st.mats) if (M[n]) M[n].envMapIntensity = val;
        }
      }
      function setRimWeights(phone) {
        const M = phone.userData.M;
        for (const n in M) if ("envMapIntensity" in M[n]) { M[n].envMap = ENV_RIM; M[n].envMapIntensity = 1; }
      }

      // timeline (seconds)
      const T = {
        rimOn: [0.0, 0.4],
        reveal: [0.4, 1.6], // 1.2s power2.inOut
        hold: [1.6, 4.4], // 2.8s slow push 1.00 -> 1.03
        cut2: 4.4,
        multi: 4.55, // each 1.5s expo.out, 0.1s apart, from the left
        cut3: 7.0,
        out: [7.35, 8.55], // reverse 1.2s
        rimOff: [8.55, 8.9],
      };

      function frameState(t) {
        if (t < T.cut2) {
          const push = 1 + 0.03 * sineInOut(seg(t, T.hold[0], T.hold[1]));
          const k = power2InOut(seg(t, T.reveal[0], T.reveal[1]));
          return { shot: 1, push, rim: sineInOut(seg(t, T.rimOn[0], T.rimOn[1])), keys: { orange: k }, staged: true };
        }
        if (t < T.cut3) {
          const push = 1 + 0.015 * seg(t, T.cut2, T.cut3);
          const keys = {};
          ORDER.forEach((n, i) => { keys[n] = expoOut(seg(t, T.multi + 0.1 * i, T.multi + 0.1 * i + 1.5)); });
          return { shot: 2, push, rim: 1, keys, staged: false };
        }
        const k = 1 - power2InOut(seg(t, T.out[0], T.out[1]));
        const rim = 1 - sineInOut(seg(t, T.rimOff[0], T.rimOff[1]));
        return { shot: 1, push: 1.03, rim, keys: { orange: k }, staged: true };
      }

      function layout(shot) {
        if (shot === 1) {
          phones.orange.visible = true; phones.silver.visible = false; phones.blue.visible = false;
          phones.orange.position.set(0, 0, 0);
          phones.orange.rotation.set(0, 0, 0);
        } else {
          ORDER.forEach((n, i) => {
            const p = phones[n];
            p.visible = true;
            p.position.set((i - 1) * 8.7, 0, 0);
            p.rotation.set(0, 0, 0);
          });
        }
      }

      function renderAt(t) {
        const st = frameState(t);
        layout(st.shot);
        for (const p of Object.values(phones)) p.userData.M.glass.aoMap = st.shot === 1 ? TEX.glassFall : TEX.glassDiag;
        setShot(st.shot === 1 ? S1 : S2, st.push);
        const visible = Object.entries(phones).filter(([, p]) => p.visible);

        // pass A: rim light only
        for (const [, p] of visible) setRimWeights(p);
        renderer.setRenderTarget(rtA);
        renderer.clear();
        renderer.render(scene, camera);
        // pass B: key studio, staged per material / per phone
        for (const [n, p] of visible) setKeyWeights(p, st.keys[n] ?? 0, st.staged, st.shot === 1 ? ENV_KEY : ENV_KEY2, st.rim);
        renderer.setRenderTarget(rtB);
        renderer.clear();
        renderer.render(scene, camera);
        renderer.setRenderTarget(null);

        combine.uniforms.kA.value = st.rim;
        combine.uniforms.kB.value = 1;
        composer.render();
        finalMat.uniforms.tSrc.value = composer.readBuffer.texture;
        finalMat.uniforms.frame.value = Math.round(t * 30);
        renderer.setRenderTarget(null);
        renderer.render(fsScene, fsCam);
      }

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