# Dispense Line-up

Accessories slide out of the main object one by one into a row while the caption appears word by word, then retract in mirror order.

- Position in a launch film: Features
- Slug: dispense-line-up
- Preview video: https://media.motionboard.app/dispense-lineup.mp4
- Recipe page: https://motionboard.app/recipes/dispense-line-up/
- 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 `dispense-lineup`. 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 10.5s in three shots (A 0–3.9s, B 3.9–7.6s, lockup C 7.6–10.5s), with two dispense passes. Each pass: extend 0.9s (27 frames), hold 1.1s (33 frames), retract 0.9s (27 frames); pass 1 extends 0.5–1.4s and retracts 2.5–3.4s, pass 2 extends 4.3–5.2s and retracts 6.3–7.2s. Accessories start 0.15s apart (about 5 frames), each slide-out takes 0.3s (9 frames) |
| Easing | Slide-out `cubic-bezier(0.16,1,0.3,1)` (expo.out); the retract is the exact time mirror of the extend; camera push sine.inOut; wordmark and subtitle expo.out |
| Camera | Perspective, locked side view at 13° fov with lens shift so the dock sits left and the row centre-right (shots A and C); shot B is a three-quarter view from the left at 23° fov so the row recedes in depth; 3% push-in per shot (scale 1.00→1.03); motion blur from 14 sub-frames over a 180° shutter |
| Subject | 5 power adapters (140W, 96W, 70W, 35W, 20W) leave the dock's right face to the right and form a row sized from large to small, 0.42 units apart; the smallest leaves first and travels furthest, the largest leaves last; on retract they re-enter in the mirrored order, from right to left |
| Sync | Each accessory that slides out adds one word to the caption 0.1s after it starts moving, which appears directly with no fade (5 words, 5 accessories); on retract, words are removed in reverse order at 0.15s intervals |
| Style | Light gray-white background `#f4f4f6`, matte white product (`#f4f4f3`, roughness 0.62), large soft overhead box plus broad front fill, soft contact shadows on an invisible floor; no tone mapping; dark gray type `#1d1d1f` (50px captions, 156px wordmark) |

## Why it works

The number of words in the caption matches the number of accessories one to one, and the forward and reverse runs are symmetric, so the move is reversible. Accessory sizes step down from large to small, which gives the row a sense of perspective. The row has no guide lines or labels.

## Prompt

> Build an accessory showcase called Dispense Line-up as a 10.5-second film. Light gray-white background (#f4f4f6) with a matte white charging dock in a locked side view (13° fov, lens shift so the dock sits left and the row centre-right). 5 power adapters (140W, 96W, 70W, 35W, 20W) slide out of the dock's right face to the right and form a row sized from large to small: the smallest leaves first and travels furthest, each next one starts 0.15 seconds later, each slide-out takes 0.3 seconds with expo.out (cubic-bezier(0.16,1,0.3,1)), 0.9 seconds in total. Each time one starts moving, the caption below gains one word 0.1 seconds later that appears directly with no fade; the word count matches the accessory count. After a 1.1 second hold, retract as the exact time mirror over 0.9 seconds: the accessories return from right to left into the dock while the caption deletes word by word in reverse. Run two passes (0.5s and 4.3s start) with a second caption, cutting to a three-quarter view from the left (23° fov) for the second one, then end on the side view with the wordmark fading in at 7.75s (expo.out, blur 10px→0). Push in 3% over every shot with sine.inOut. Soft overhead light, soft contact shadows, 14-sub-frame motion blur on moving parts. Drive the 3D from a time function so the retract is a mirror, and use GSAP only for the caption words and wordmark.

## Often confused with

- [Text–Object Interleave](https://motionboard.app/r/text-object-interleave.md): Text–Object Interleave is front-and-back occlusion between text and object; the caption in Dispense Line-up syncs only with the accessory count.
- [Converge to Mark](https://motionboard.app/r/converge-to-mark.md): Converge to Mark gathers many elements into one icon; the retract in Dispense Line-up returns to the original subject and creates no new shape.

## Source files

Runnable HyperFrames project, 3 files.

- https://motionboard.app/source/dispense-lineup/hyperframes.json (318 B)
- https://motionboard.app/source/dispense-lineup/index.html (27.3 KB)
- https://motionboard.app/source/dispense-lineup/package.json (304 B)

Download all of them:

```sh
curl -fsSL --create-dirs -o "dispense-lineup/hyperframes.json" "https://motionboard.app/source/dispense-lineup/hyperframes.json"
curl -fsSL --create-dirs -o "dispense-lineup/index.html" "https://motionboard.app/source/dispense-lineup/index.html"
curl -fsSL --create-dirs -o "dispense-lineup/package.json" "https://motionboard.app/source/dispense-lineup/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: #f4f4f6; }
      #root { position: relative; width: 1920px; height: 1080px; background: #f4f4f6; font-family: "Inter", sans-serif; }
      #stage { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      #gl { display: block; width: 1920px; height: 1080px; }
      #type { position: absolute; left: 0; top: 0; width: 1920px; height: 1080px; }
      .cap {
        position: absolute; white-space: nowrap;
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 50px; line-height: 1;
        letter-spacing: -0.012em; color: #1d1d1f;
      }
      .cap span { opacity: 0; }
      #cap1 { left: 1100px; top: 800px; width: 1000px; margin-left: -500px; text-align: center; }
      #cap2 { left: 1060px; top: 836px; width: 1000px; margin-left: -500px; text-align: center; }
      #mark { position: absolute; left: 1212px; top: 428px; width: 900px; margin-left: -450px; text-align: center; }
      #word {
        font-family: "Inter", sans-serif; font-weight: 600; font-size: 156px; line-height: 1;
        letter-spacing: -0.045em; color: #1d1d1f; opacity: 0;
      }
      #sub {
        margin-top: 30px; font-family: "Inter", sans-serif; font-weight: 500; font-size: 34px; line-height: 1;
        letter-spacing: -0.005em; color: #6e6e73; opacity: 0;
      }
    </style>
  </head>
  <body>
    <div id="root" data-composition-id="main" data-start="0" data-duration="10.5" data-width="1920" data-height="1080">
      <div id="stage" data-hold class="clip" data-start="0" data-duration="10.5" data-track-index="0">
        <canvas id="gl" width="1920" height="1080"></canvas>
      </div>
      <div id="type" class="clip" data-start="0" data-duration="10.5" data-track-index="1" data-layout-allow-overlap>
        <div id="cap1" class="cap"><span>Replaces</span> <span>every</span> <span>charger</span> <span>you</span> <span>own.</span></div>
        <div id="cap2" class="cap"><span>240W</span> <span>across</span> <span>five</span> <span>USB‑C</span> <span>ports.</span></div>
        <div id="mark">
          <div id="word">Mira</div>
          <div id="sub">240W Charging Dock</div>
        </div>
      </div>
    </div>

    <script>
      // dispense timing (seconds): out window, retract window
      const PASSES = [
        { s: 0.5, R: 2.5, cap: "#cap1" },
        { s: 4.3, R: 6.3, cap: "#cap2" },
      ];
      const tl = gsap.timeline({ paused: true });
      for (const P of PASSES) {
        const words = document.querySelectorAll(P.cap + " span");
        words.forEach((w, k) => {
          // one word per accessory: appears as accessory k lands its first half, no fade
          tl.set(w, { opacity: 1 }, P.s + 0.15 * k + 0.1);
          // mirrored deletion
          tl.set(w, { opacity: 0 }, P.R + 0.8 - 0.15 * k);
        });
      }
      tl.set("#word", { opacity: 0, y: 14, filter: "blur(10px)" }, 0);
      tl.set("#sub", { opacity: 0, y: 10 }, 0);
      tl.to("#word", { opacity: 1, y: 0, filter: "blur(0px)", duration: 1.0, ease: "expo.out" }, 7.75);
      tl.to("#sub", { opacity: 1, y: 0, duration: 0.8, ease: "expo.out" }, 7.9);
      tl.to("#word", { opacity: 1, duration: 0.01 }, 10.45);
      window.__timelines = window.__timelines || {};
      window.__timelines["main"] = tl;
      window.__PASSES = PASSES;
    </script>

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

      /* ================= time helpers ================= */
      const clamp01 = (x) => Math.min(1, Math.max(0, x));
      const seg = (t, a, b) => clamp01((t - a) / (b - a));
      const expoOut = (k) => (k >= 1 ? 1 : k <= 0 ? 0 : 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.NoToneMapping;
      renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
      renderer.autoClear = false;

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

      /* ================= 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;
      }
      // fine seeded noise for the bead-blasted polycarbonate
      let seed = 0x2f6b1c3d;
      const rnd = () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
      const TEX_GRAIN = 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 + (rnd() - 0.5) * 70;
          img.data[i * 4 + 1] = 128 + (rnd() - 0.5) * 70;
          img.data[i * 4 + 2] = 255; img.data[i * 4 + 3] = 255;
        }
        g.putImageData(img, 0, 0);
      });
      TEX_GRAIN.wrapS = TEX_GRAIN.wrapT = THREE.RepeatWrapping;
      TEX_GRAIN.repeat.set(3, 3);
      // roughness variation: per-pixel speckle over soft low-frequency blotches (G channel)
      const TEX_ROUGH = canvasTex(512, 512, (g, w, h) => {
        const img = g.createImageData(w, h);
        const ph = [rnd() * 6.28, rnd() * 6.28, rnd() * 6.28];
        for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
          const lf = 0.5 + 0.25 * Math.sin(x * 0.029 + ph[0]) * Math.sin(y * 0.023 + ph[1]) + 0.25 * Math.sin(x * 0.067 + y * 0.051 + ph[2]);
          const v = 0.84 + 0.1 * lf + (rnd() - 0.5) * 0.1;
          const k = (y * w + x) * 4;
          img.data[k] = 255; img.data[k + 1] = Math.round(255 * Math.min(1, v)); img.data[k + 2] = 255; img.data[k + 3] = 255;
        }
        g.putImageData(img, 0, 0);
      });

      function printTex(text, px, weight = 600, color = "#c3c3c8", track = 0) {
        return canvasTex(1024, 256, (g, w, h) => {
          g.clearRect(0, 0, w, h);
          g.fillStyle = color;
          g.font = `${weight} ${px}px Inter, Helvetica, Arial, sans-serif`;
          g.textAlign = "center"; g.textBaseline = "middle";
          if (track) g.letterSpacing = track + "px";
          g.fillText(text, w / 2, h / 2);
        }, true);
      }

      /* ================= materials ================= */
      const M = {};
      M.shell = new THREE.MeshPhysicalMaterial({
        color: 0xf4f4f3, metalness: 0, roughness: 0.62, roughnessMap: TEX_ROUGH,
        clearcoat: 0.1, clearcoatRoughness: 0.6, specularIntensity: 0.42,
        sheen: 0.2, sheenRoughness: 0.75, sheenColor: new THREE.Color(1.0, 0.96, 0.91),
        normalMap: TEX_GRAIN, normalScale: new THREE.Vector2(0.03, 0.03),
      });
      // overhead key falls off toward the floor; lit grazing edges pick up a faint warm translucency
      M.shell.onBeforeCompile = (sh) => {
        sh.vertexShader = sh.vertexShader
          .replace("#include <common>", "#include <common>\nvarying float vWY;")
          .replace("#include <project_vertex>", "#include <project_vertex>\nvWY = (modelMatrix * vec4(transformed, 1.0)).y;");
        sh.fragmentShader = sh.fragmentShader
          .replace("#include <common>", "#include <common>\nvarying float vWY;")
          .replace("#include <opaque_fragment>", [
            "outgoingLight *= mix(0.86, 1.03, smoothstep(-2.6, 2.2, vWY));",
            "float sssF = pow(1.0 - clamp(dot(normal, normalize(vViewPosition)), 0.0, 1.0), 2.2);",
            "outgoingLight += outgoingLight * vec3(0.04, 0.018, -0.004) * sssF;",
            "#include <opaque_fragment>",
          ].join("\n"));
      };
      M.portRing = new THREE.MeshPhysicalMaterial({ color: 0xcdcdd0, metalness: 0, roughness: 0.4, specularIntensity: 0.6 });
      M.metal = new THREE.MeshPhysicalMaterial({ color: 0xc3c6cc, metalness: 1, roughness: 0.3 });
      M.portHole = new THREE.MeshPhysicalMaterial({ color: 0x09090a, metalness: 0, roughness: 0.4 });
      M.tongue = new THREE.MeshPhysicalMaterial({ color: 0x232326, metalness: 0.2, roughness: 0.45 });
      M.pocket = new THREE.MeshPhysicalMaterial({ color: 0x2e2e31, metalness: 0, roughness: 0.7 });
      M.blade = new THREE.MeshPhysicalMaterial({ color: 0x80848a, metalness: 1, roughness: 0.4 });
      M.led = new THREE.MeshBasicMaterial({ color: new THREE.Color(1.6, 1.6, 1.62) });
      M.ledRing = new THREE.MeshPhysicalMaterial({ color: 0xcfcfd1, metalness: 0, roughness: 0.25 });

      /* ================= procedural parts ================= */
      function rrShape(w, h, r) {
        const s = new THREE.Shape();
        r = Math.min(r, w / 2, h / 2);
        s.moveTo(-w / 2 + r, -h / 2);
        s.lineTo(w / 2 - r, -h / 2);
        s.absarc(w / 2 - r, -h / 2 + r, r, -Math.PI / 2, 0, false);
        s.lineTo(w / 2, h / 2 - r);
        s.absarc(w / 2 - r, h / 2 - r, r, 0, Math.PI / 2, false);
        s.lineTo(-w / 2 + r, h / 2);
        s.absarc(-w / 2 + r, h / 2 - r, r, Math.PI / 2, Math.PI, false);
        s.lineTo(-w / 2, -h / 2 + r);
        s.absarc(-w / 2 + r, -h / 2 + r, r, Math.PI, Math.PI * 1.5, false);
        return s;
      }
      function flat(shape, z, mat, seg = 24) {
        const g = new THREE.ShapeGeometry(shape, seg);
        g.translate(0, 0, z);
        return new THREE.Mesh(g, mat);
      }
      const stadium = (w, h, z, mat) => flat(rrShape(w, h, h / 2), z, mat);
      // USB-C receptacle head-on: plastic chamfer, metal shell lining, dark cavity, tongue
      function usbc(scale, z) {
        const g = new THREE.Group();
        const W = 0.46 * scale, H = 0.15 * scale;
        g.add(stadium(W + 0.07 * scale, H + 0.07 * scale, z + 0.001, M.portRing));
        g.add(stadium(W, H, z + 0.002, M.metal));
        g.add(stadium(W - 0.036 * scale, H - 0.036 * scale, z + 0.003, M.portHole));
        const tongue = new THREE.Mesh(new THREE.PlaneGeometry(W * 0.6, H * 0.26), M.tongue);
        tongue.position.z = z + 0.004;
        g.add(tongue);
        return g;
      }
      // raised front plate with a 45-degree chamfer: the second edge level on top of the rounded body
      const LIFT = 0.035;
      function frontPlate(w, h, r, fz, inset) {
        const bev = 0.032, depth = 0.1;
        const pw = w - 2 * inset, ph = h - 2 * inset;
        const geo = new THREE.ExtrudeGeometry(rrShape(pw - 2 * bev, ph - 2 * bev, Math.max(r - inset - bev, 0.02)), {
          depth, bevelEnabled: true, bevelThickness: bev, bevelSize: bev, bevelSegments: 1, curveSegments: 20,
        });
        const m = new THREE.Mesh(geo, M.shell);
        m.position.z = fz + LIFT - (depth + bev);
        return m;
      }
      // US prongs folded out straight back from the rear face, away from camera
      function prongs(it, s) {
        const g = new THREE.Group();
        const L = 0.5 * s, bw = 0.21 * s, bt = 0.055 * s;
        for (const dx of [-0.225 * s, 0.225 * s]) {
          const piv = new THREE.Group();
          piv.position.set(dx, it.h * 0.06, -it.d / 2 + 0.01);
          piv.rotation.x = -Math.PI / 2;
          const b = new THREE.Mesh(new RoundedBoxGeometry(bw, L, bt, 2, Math.min(0.02, bt * 0.45)), M.blade);
          b.position.y = L / 2;
          piv.add(b);
          g.add(piv);
        }
        // dark hinge slot in the back face
        const slot = flat(rrShape(0.75 * s, 0.16 * s, 0.05 * s), 0, M.pocket);
        slot.rotation.y = Math.PI;
        slot.position.set(0, it.h * 0.06, -it.d / 2 - 0.002);
        g.add(slot);
        return g;
      }
      function decal(tex, w, z, opacity = 1) {
        const m = new THREE.MeshPhysicalMaterial({ map: tex, transparent: true, opacity, roughness: 0.5, metalness: 0, depthWrite: false });
        const mesh = new THREE.Mesh(new THREE.PlaneGeometry(w, w / 4), m);
        mesh.position.z = z;
        return mesh;
      }

      /* ---------- contact shadows on an invisible floor ---------- */
      function contactShadow(w, d, r, strength) {
        const m = 1.1, PW = w + 2 * m, PD = d + 2 * m, R = 512;
        const tex = canvasTex(R, R, (g, cw, ch) => {
          const sx = cw / PW, sy = ch / PD;
          const x = m * sx, y = m * sy, fw = w * sx, fd = d * sy;
          const rr = r * sx;
          g.clearRect(0, 0, cw, ch);
          g.filter = "blur(28px)"; g.fillStyle = `rgba(0,0,0,${0.42 * strength})`;
          rrPath(g, x + 2, y + 2, fw - 4, fd - 4, rr); g.fill();
          g.filter = "blur(8px)"; g.fillStyle = `rgba(0,0,0,${0.55 * strength})`;
          rrPath(g, x + 8, y + 8, fw - 16, fd - 16, rr); g.fill();
          g.filter = "blur(3px)"; g.fillStyle = `rgba(0,0,0,${0.45 * strength})`;
          rrPath(g, x + 10, y + 10, fw - 20, fd - 20, rr); g.fill();
          g.filter = "none";
        }, true);
        const mat = new THREE.MeshBasicMaterial({ color: 0x000000, map: tex, transparent: true, depthWrite: false });
        const mesh = new THREE.Mesh(new THREE.PlaneGeometry(PW, PD), mat);
        mesh.rotation.x = -Math.PI / 2;
        mesh.renderOrder = -1;
        return mesh;
      }
      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();
      }

      /* ---------- dock: Mira ---------- */
      const DW = 3.2, DH = 4.6, DD = 3.2, DR = 0.42, DY = -0.2;
      const FLOOR = DY - DH / 2;
      const dock = new THREE.Group();
      {
        dock.add(new THREE.Mesh(new RoundedBoxGeometry(DW, DH, DD, 10, DR), M.shell));
        dock.add(frontPlate(DW, DH, DR, DD / 2, 0.15));
        const fz = DD / 2 + LIFT;
        // five USB-C ports in a row, vertical orientation
        for (let i = 0; i < 5; i++) {
          const p = usbc(1.0, fz);
          p.rotation.z = Math.PI / 2;
          p.position.set((i - 2) * 0.36, -1.42, 0);
          dock.add(p);
        }
        // status light
        const ring = new THREE.Mesh(new THREE.CircleGeometry(0.075, 48), M.ledRing);
        ring.position.set(0, 1.52, fz + 0.001);
        dock.add(ring);
        const led = new THREE.Mesh(new THREE.CircleGeometry(0.05, 48), M.led);
        led.position.set(0, 1.52, fz + 0.002);
        dock.add(led);
        // printed wordmark
        const word = decal(printTex("mira", 150, 600, "#b4b4b9", -4), 1.25, fz + 0.002);
        word.position.y = 1.06;
        dock.add(word);
        const sh = contactShadow(DW, DD, DR, 1.0);
        sh.position.y = -DH / 2 + 0.002;
        dock.add(sh);
        dock.position.set(0, DY, 0);
      }
      scene.add(dock);

      /* ---------- five power adapters, large to small ---------- */
      // 140W tall block, 96W / 70W cubes, 35W low dual-port pill, 20W compact cube
      const GAP = 0.42, FIRST = 0.5;
      const ITEMS = [
        { w: 2.1, h: 2.75, d: 2.1, r: 0.26, label: "140W", ports: "one" },
        { w: 2.0, h: 2.1, d: 2.0, r: 0.25, label: "96W", ports: "one" },
        { w: 1.75, h: 1.8, d: 1.75, r: 0.23, label: "70W", ports: "one" },
        { w: 1.62, h: 1.3, d: 1.45, r: 0.44, label: "35W", ports: "pair" },
        { w: 1.25, h: 1.25, d: 1.25, r: 0.2, label: "20W", ports: "one" },
      ];
      {
        let x = DW / 2 + FIRST;
        for (const it of ITEMS) {
          it.X = x + it.w / 2;
          x += it.w + GAP;
          const g = new THREE.Group();
          const s = 0.62 + 0.38 * (Math.min(it.w, it.h) / 2.1);
          g.add(new THREE.Mesh(new RoundedBoxGeometry(it.w, it.h, it.d, 8, it.r), M.shell));
          g.add(frontPlate(it.w, it.h, it.r, it.d / 2, Math.max(0.07, it.r * 0.32)));
          const fz = it.d / 2 + LIFT;
          if (it.ports === "pair") {
            for (const dx of [-0.27, 0.27]) { const p = usbc(s * 0.9, fz); p.position.set(dx * s, it.h * 0.1, 0); g.add(p); }
          } else {
            const p = usbc(s, fz); p.position.y = it.h * 0.14; g.add(p);
          }
          const lab = decal(printTex(it.label, 150, 600, "#a3a3a9", -2), 1.45 * s, fz + 0.002);
          lab.position.y = it.ports === "pair" ? -it.h * 0.3 : -it.h * 0.24;
          g.add(lab);
          g.add(prongs(it, s));
          const sh = contactShadow(it.w, it.d, it.r, 0.9);
          sh.position.y = -it.h / 2 + 0.002;
          g.add(sh);
          it.g = g;
          scene.add(g);
        }
        window.__ROW_END = x - GAP;
      }

      /* ================= lighting ================= */
      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, ambient) {
        const s = new THREE.Scene();
        s.background = new THREE.Color(ambient, ambient, ambient * 1.01);
        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];
      // soft overhead box, broad front fill, two vertical strips for edge definition, dim floor
      scene.environment = makeEnv([
        { p: [0, 14, 1], w: 20, h: 16, i: 4.2, c: W, soft: "soft" },
        { p: [7, 8, 15], w: 16, h: 12, i: 6.5, c: W, soft: "soft" },
        { p: [-4, 2, 16], w: 16, h: 8, i: 1.6, c: W, soft: "soft" },
        { p: [-13, 3, 3], w: 3.6, h: 14, i: 2.2, c: [1, 0.96, 0.9], soft: "soft" },
        { p: [13, 3, 3], w: 3.6, h: 14, i: 1.8, c: [1, 0.99, 0.98], soft: "soft" },
        { p: [0, -12, 4], w: 22, h: 12, i: 0.1, c: W, soft: "soft" },
      ], 0.34);
      scene.environmentIntensity = 1.0;
      const key = new THREE.DirectionalLight(0xffffff, 0.9);
      key.position.set(-2, 10, 4);
      scene.add(key);

      /* ================= post: motion-blur accumulation -> tonemap -> composite on #F4F4F6 ================= */
      const rtS = new THREE.WebGLRenderTarget(RW, RH, { type: THREE.HalfFloatType, samples: 4, colorSpace: THREE.LinearSRGBColorSpace });
      const rtAcc = new THREE.WebGLRenderTarget(RW, RH, { type: THREE.HalfFloatType, colorSpace: THREE.LinearSRGBColorSpace });
      const fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
      const quadVS = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
      const accMat = new THREE.ShaderMaterial({
        uniforms: { tSrc: { value: rtS.texture }, w: { value: 1 } },
        vertexShader: quadVS,
        fragmentShader: `uniform sampler2D tSrc; uniform float w; varying vec2 vUv; void main(){ gl_FragColor = texture2D(tSrc, vUv) * w; }`,
        transparent: true, depthTest: false, depthWrite: false,
        blending: THREE.CustomBlending, blendEquation: THREE.AddEquation,
        blendSrc: THREE.OneFactor, blendDst: THREE.OneFactor, blendSrcAlpha: THREE.OneFactor, blendDstAlpha: THREE.OneFactor,
      });
      const accScene = new THREE.Scene();
      accScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), accMat));

      const finalMat = new THREE.ShaderMaterial({
        uniforms: {
          tSrc: { value: rtAcc.texture }, texel: { value: new THREE.Vector2(1 / RW, 1 / RH) }, frame: { value: 0 },
          exposure: { value: 1.75 }, bg: { value: new THREE.Vector3(244 / 255, 244 / 255, 246 / 255) },
        },
        vertexShader: quadVS,
        fragmentShader: `uniform sampler2D tSrc; uniform vec2 texel; uniform float frame; uniform float exposure; uniform vec3 bg; 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 startCompression = 0.8 - 0.04;
            const float desaturation = 0.15;
            float x = min(color.r, min(color.g, color.b));
            float offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
            color -= offset;
            float peak = max(color.r, max(color.g, color.b));
            if (peak < startCompression) return color;
            const float d = 1.0 - startCompression;
            float newPeak = 1.0 - d * d / (peak + d - startCompression);
            color *= newPeak / peak;
            float g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
            return mix(color, newPeak * vec3(1.0), g);
          }
          vec3 toSRGB(vec3 c){ c = clamp(c, 0.0, 1.0); return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); }
          void main(){
            vec4 c = vec4(0.0);
            c += texture2D(tSrc, vUv + texel * vec2(-0.75, -0.75));
            c += texture2D(tSrc, vUv + texel * vec2( 0.75, -0.75));
            c += texture2D(tSrc, vUv + texel * vec2(-0.75,  0.75));
            c += texture2D(tSrc, vUv + texel * vec2( 0.75,  0.75));
            c *= 0.25;
            float a = clamp(c.a, 0.0, 1.0);
            vec3 col = a > 1e-4 ? c.rgb / a : vec3(0.0);
            col = toSRGB(neutral(col * exposure));
            vec3 o = mix(bg, col, a);
            vec2 px = floor(gl_FragCoord.xy);
            float n = (h12(px + frame * 17.31) + h12(px * 1.37 + frame * 3.7 + 11.0)) - 1.0;
            o += n * 0.0045;
            gl_FragColor = vec4(o, 1.0);
          }`,
        depthTest: false, depthWrite: false,
      });
      const fsScene = new THREE.Scene();
      fsScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), finalMat));

      /* ================= direction ================= */
      // Shot A / C: locked side view; camera sits on the dock's right-face plane so that face stays edge-on,
      // a lens shift moves the frame to the right to hold the dock left and the row centre-right.
      const ROW_C = (-DW / 2 + window.__ROW_END) / 2;
      const SHOT_A = { pos: [DW / 2, 3.0, 42.7], look: [DW / 2, -0.74, 0], fov: 13, shiftX: ROW_C - DW / 2, shiftY: 0 };
      // Shot B: three-quarter from the left, the row recedes in depth
      const SHOT_B = { pos: [-6.5, 5.4, 25], look: [4.9, -1.3, 0], fov: 23, shiftX: 0, shiftY: 0 };

      // Shot C: same locked side view, framed for the dock + wordmark lockup
      const SHOT_C = { pos: [0, 2.04, 42.7], look: [0, -0.2, 0], fov: 13, shiftX: 2.16, shiftY: 0 };
      const PASSES = window.__PASSES;
      const T = { cutB: 3.9, cutC: 7.6, end: 10.5 };
      function shotAt(t) {
        if (t < T.cutB) return { cam: SHOT_A, t0: 0, t1: T.cutB, pass: PASSES[0] };
        if (t < T.cutC) return { cam: SHOT_B, t0: T.cutB, t1: T.cutC, pass: PASSES[1] };
        return { cam: SHOT_C, t0: T.cutC, t1: T.end, pass: null };
      }

      function setCamera(cam, push) {
        const look = new THREE.Vector3(...cam.look);
        const pos = new THREE.Vector3(...cam.pos);
        const dir = pos.clone().sub(look);
        const dist = dir.length() / push;
        camera.position.copy(look).addScaledVector(dir.normalize(), dist);
        camera.up.set(0, 1, 0);
        camera.lookAt(look);
        // lens shift (world units at the look distance -> pixels)
        const worldH = 2 * dist * Math.tan(THREE.MathUtils.degToRad(cam.fov / 2));
        const pxPerU = OH / worldH;
        const ox = Math.round(cam.shiftX * pxPerU), oy = Math.round(-cam.shiftY * pxPerU);
        const fullW = OW + 2 * Math.abs(ox), fullH = OH + 2 * Math.abs(oy);
        camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(cam.fov / 2)) * fullH / OH));
        camera.aspect = fullW / fullH;
        if (ox || oy) camera.setViewOffset(fullW, fullH, ox > 0 ? 2 * ox : 0, oy > 0 ? 2 * oy : 0, OW, OH);
        else camera.clearViewOffset();
        camera.updateProjectionMatrix();
      }

      // accessory j (0 = nearest, largest). The smallest leaves first and travels furthest, so the row
      // builds outward without collisions; the retract is the exact time-mirror of the dispense.
      function itemP(t, pass, j) {
        if (!pass) return 0;
        const order = ITEMS.length - 1 - j;
        let tt = t;
        if (t >= pass.R) tt = pass.s + 0.9 - (t - pass.R);
        else if (t > pass.s + 1.0) tt = pass.s + 1.0;
        return expoOut(seg(tt, pass.s + 0.15 * order, pass.s + 0.15 * order + 0.3));
      }

      function applyState(sh, t) {
        ITEMS.forEach((it, j) => {
          const p = itemP(t, sh.pass, j);
          it.g.visible = p > 0.0005;
          it.g.position.set(it.X * p, FLOOR + it.h / 2 + 0.004, 0);
        });
        const k = seg(t, sh.t0, sh.t1);
        setCamera(sh.cam, 1 + 0.03 * sineInOut(k));
      }

      const SHUTTER = 0.5 / 30;
      function moving(sh, t) {
        if (!sh.pass) return false;
        let m = 0;
        ITEMS.forEach((it, j) => { m = Math.max(m, Math.abs(itemP(t, sh.pass, j) - itemP(t - SHUTTER, sh.pass, j)) * it.X); });
        return m > 0.01;
      }

      function renderAt(t) {
        const sh = shotAt(t);
        const N = moving(sh, t) ? 14 : 1;
        renderer.setRenderTarget(rtAcc);
        renderer.setClearColor(0x000000, 0);
        renderer.clear();
        for (let i = 0; i < N; i++) {
          const ts = N === 1 ? t : Math.max(sh.t0, t - SHUTTER * (i / (N - 1)));
          applyState(sh, ts);
          renderer.setRenderTarget(rtS);
          renderer.setClearColor(0x000000, 0);
          renderer.clear();
          renderer.render(scene, camera);
          accMat.uniforms.w.value = 1 / N;
          renderer.setRenderTarget(rtAcc);
          renderer.render(accScene, fsCam);
        }
        applyState(sh, t);
        finalMat.uniforms.frame.value = Math.round(t * 30);
        renderer.setRenderTarget(null);
        renderer.setClearColor(0xf4f4f6, 1);
        renderer.clear();
        renderer.render(fsScene, fsCam);
      }

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