Showreel

The code of the Showreel demo, 2 files.

Run the demo All demos

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Showreel | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <!-- The framework loads, then Trillium for the one three.js shot, then
         Kinetic with its preview bar. Kinetic runs the clock and the render
         itself. The scenes come last, as a module that imports three.js. -->
    <script defer src="/js/dist/wildflower.lite.min.js"></script>
    <script defer src="/js/extensions/three.wf.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script type="importmap">
    {
        "imports": {
            "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
        }
    }
    </script>
    <script type="module" src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
        .demo-toolbar .logo { height: 32px; }
        .demo-toolbar .brand-wf { font-weight: 520; }
        .demo-toolbar .brand-js { font-weight: 700; }
        .demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }

        /* The composition is 1280 by 720. With data-preview, Kinetic scales
           the stage down to the window; the stage's layout stays 1280 by
           720, so a render captures the layout the preview shows. */
        /* Hidden until Kinetic marks the root ready: before that, the stage
           holds unbound markup and every clip at once. */
        #stage:not([data-kinetic-ready]) { visibility: hidden; }

        .frame { padding: 24px; }

        #stage {
            --ink: #0c0c10; --paper: #f2eee6; --violet: #aa3de6; --deep: #892dbf; --lime: #d9ff43;
            --grotesk: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            --serif: Didot, "Bodoni 72", "Bodoni MT", Georgia, "Times New Roman", serif;
            --mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
            /* Outlined type uses a static font, because a variable font's
               overlapping contours show through -webkit-text-stroke. */
            --outline: "Arial Black", "Helvetica Neue", Arial, sans-serif;
            position: relative; width: 1280px; height: 720px; overflow: hidden; background: var(--ink);
            font-family: var(--grotesk);
        }
        .layer { position: absolute; inset: 0; overflow: hidden; }
        .ch { display: inline-block; white-space: pre; }

        /* 00 Open */
        .s1 { background: var(--ink); }
        .s1 .floor { position: absolute; left: 0; top: 470px; width: 1280px; height: 2px; background: rgba(242,238,230,0.38); }
        .s1 .guide { position: absolute; left: 639px; width: 0; border-left: 1.5px dashed rgba(242,238,230,0.38); }
        .s1 .cap { position: absolute; top: 490px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
        .s1 .cap.l { left: 160px; }
        .s1 .cap.r { right: 160px; }
        .s1 .iris { position: absolute; inset: 0; background: var(--violet); }
        .s1 .open { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--ink); }
        .s1 .line { display: flex; overflow: hidden; padding: 0.07em 0.04em 0.01em; font-size: 200px; font-weight: 900; line-height: 0.82; letter-spacing: -0.05em; }

        /* 01 Type */
        .s2 .zoom { position: absolute; inset: 0; transform-origin: 640px 360px; }
        .s2 .band { position: absolute; left: 0; width: 1280px; height: 144px; background: var(--paper); overflow: hidden; }
        .s2 .run { position: absolute; left: 0; top: 50%; white-space: pre; line-height: 1; color: var(--ink); }
        .s2 .run.g { font-size: 116px; font-weight: 900; letter-spacing: -0.035em; }
        .s2 .run.outline { font-family: var(--outline); font-size: 104px; letter-spacing: -0.02em; color: transparent; -webkit-text-stroke: 2.5px var(--ink); }
        /* Didot's italic descenders run about 0.3 em below the baseline, so
           the serif line is set smaller and a little high to keep them
           inside its 144 px band. */
        .s2 .run.serif { top: calc(50% - 4px); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 120px; letter-spacing: -0.01em; }
        .s2 .hl { position: absolute; left: 0; top: 288px; width: 1280px; height: 144px; background: var(--violet); overflow: hidden; }
        .s2 .scr { position: absolute; top: 26px; display: flex; font: 700 92px/92px var(--mono); color: var(--paper); }
        .s2 .scr .ch { width: 0.6em; text-align: center; }
        .s2 .dot { position: absolute; left: 594px; top: 314px; width: 92px; height: 92px; border-radius: 50%; background: var(--ink); }

        /* 02 Pattern. Each cell holds two quarter-circle arcs, so neighbours
           join at the middle of each edge whichever way a cell is turned. A
           cell is 82 px around an 80 px tile, so its arcs run 1 px under its
           neighbours and no seam shows where they meet. */
        .s3 { background: var(--ink); }
        /* The grid is its own layer, so its cells are drawn square to the
           pixels and the camera's turn applies to the drawn layer as a whole. */
        .s3 .grid { position: absolute; inset: 0; transform-origin: 640px 360px; will-change: transform; }
        .s3 .cell {
            position: absolute; width: 82px; height: 82px; margin: -1px 0 0 -1px; --c: var(--paper);
            background:
                radial-gradient(circle at 1px 1px, transparent 30.5px, var(--c) 31.5px 48.5px, transparent 49.5px),
                radial-gradient(circle at 81px 81px, transparent 30.5px, var(--c) 31.5px 48.5px, transparent 49.5px);
        }

        /* 03 Easing */
        .s4 { color: var(--paper); }
        .s4 .gl { position: absolute; }
        .s4 .gl i { position: absolute; inset: 0; background: rgba(242,238,230,0.16); }
        .s4 .gl span { position: absolute; font: 500 13px/1 var(--mono); color: rgba(242,238,230,0.5); white-space: nowrap; }
        .s4 .gl.v span { top: 100%; left: 0; transform: translate(-50%, 12px); }
        .s4 .gl.h span { left: -14px; top: 0; transform: translate(-100%, -50%); }
        .s4 svg { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; overflow: visible; }
        .s4 .curve { fill: none; stroke: var(--paper); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1 1; }
        .s4 .linear { fill: none; stroke: rgba(242,238,230,0.24); stroke-width: 1.5; stroke-dasharray: 5 8; }
        .s4 .kicker { position: absolute; left: 250px; top: 46px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.55); }
        .s4 .names { position: absolute; left: 250px; top: 66px; width: 600px; height: 56px; overflow: hidden; }
        .s4 .names .nm { position: absolute; left: 0; top: 0; font: 600 44px/56px var(--mono); letter-spacing: -0.01em; }
        .s4 .proj { position: absolute; left: 0; top: 0; }
        .s4 .proj.v { width: 0; border-left: 1.5px dashed rgba(242,238,230,0.35); }
        .s4 .proj.h { height: 0; border-top: 1.5px dashed rgba(242,238,230,0.35); }
        .s4 .track { position: absolute; left: 1039px; top: 290px; width: 2px; height: 300px; background: rgba(242,238,230,0.2); }
        .s4 .marker { position: absolute; left: 1031px; top: 0; width: 18px; height: 18px; margin-top: -9px; background: var(--violet); }
        .s4 .read { position: absolute; left: 1066px; top: 0; margin-top: -8px; font: 600 16px/16px var(--mono); white-space: pre; }
        .s4 .tread { position: absolute; top: 612px; margin-left: -40px; width: 80px; text-align: center; font: 600 14px/1 var(--mono); color: rgba(242,238,230,0.7); }
        .s4 .trail { position: absolute; left: 0; top: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: var(--paper); }
        .s4 .ball, .s6 .core { position: absolute; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50%; background: var(--violet); }

        /* 04 Focus. The phrases sit in a 3D field, each on a full-frame plane
           at its own depth; the lights behind them are flat discs, sized and
           softened by their own depth. */
        .s5 { background: var(--ink); }
        .s5 .light { position: absolute; left: 0; top: 0; border-radius: 50%; }
        .s5 .field { position: absolute; inset: 0; perspective: 900px; transform-style: preserve-3d; }
        .s5 .plane { position: absolute; inset: 0; }
        .s5 .phrase { position: absolute; transform: translate(-50%, -50%); white-space: nowrap; line-height: 1; color: var(--paper); }
        .s5 .phrase.big { font-weight: 900; letter-spacing: -0.045em; }
        .s5 .phrase.serif { font-family: var(--serif); font-style: italic; font-weight: 400; letter-spacing: -0.01em; }
        .s5 .phrase.word { font-family: var(--mono); font-weight: 600; letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
        .s5 .cap { position: absolute; top: 628px; font: 600 12px/1 var(--mono); letter-spacing: 0.16em; color: rgba(242,238,230,0.6); }
        .s5 .cap.l { left: 160px; }
        .s5 .cap.r { right: 160px; }

        /* 05 Space */
        .s6 { background: var(--ink); }
        .s6 canvas { position: absolute; left: 0; top: 0; width: 1280px; height: 720px; }

        /* 06 Rhythm */
        .s7 .cut { position: absolute; inset: 0; display: flex; align-items: center; color: var(--fg); }
        .s7 .acc { position: absolute; }
        .s7 .mword { position: relative; display: flex; white-space: pre; line-height: 0.8; font-weight: 900; letter-spacing: -0.05em; }
        .s7 .mword.outline { font-family: var(--outline); letter-spacing: -0.02em; color: transparent; -webkit-text-stroke: 4px var(--fg); }
        .s7 .mword.serif { font-family: var(--serif); font-style: italic; font-weight: 400; letter-spacing: -0.02em; }

        /* 07 End. The lockup is flush right, and the ball is its full stop. */
        .s8 { background: var(--ink); color: var(--paper); }
        .s8 .drift { overflow: visible; transform-origin: 1100px 300px; }
        .s8 .ln { position: absolute; right: 180px; display: flex; align-items: baseline; justify-content: flex-end; white-space: pre; }
        .s8 .big { font-size: 150px; font-weight: 900; line-height: 1; letter-spacing: -0.045em; }
        .s8 .l1 { top: 104px; }
        .s8 .l2 { top: 262px; font: italic 400 62px/1 var(--serif); color: rgba(242,238,230,0.78); }
        .s8 .l3 { top: 330px; }
        .s8 .stop { display: inline-block; width: 32px; height: 32px; margin-left: 10px; border-radius: 50%; background: var(--violet); }
        .s8 .ring { position: absolute; left: 0; top: 0; width: 80px; height: 80px; margin: -40px 0 0 -40px; border-radius: 50%; border: 2px solid var(--violet); }
        .s8 .meta { position: absolute; left: 160px; font: 600 13px/1 var(--mono); letter-spacing: 0.14em; white-space: pre; }
        .s8 .c1 { top: 566px; }
        .s8 .c2 { top: 590px; color: rgba(242,238,230,0.6); }
        .s8 .c3 { top: 614px; color: rgba(242,238,230,0.6); letter-spacing: 0.04em; }

        /* The overlay */
        .hud { font: 600 11px/1 var(--mono); letter-spacing: 0.16em; }
        .hud .mark { position: absolute; width: 18px; height: 18px; border: 0 solid currentColor; }
        .hud .mark.tl { left: 28px; top: 28px; border-width: 1.5px 0 0 1.5px; transform-origin: 0 0; }
        .hud .mark.tr { right: 28px; top: 28px; border-width: 1.5px 1.5px 0 0; transform-origin: 100% 0; }
        .hud .mark.bl { left: 28px; bottom: 28px; border-width: 0 0 1.5px 1.5px; transform-origin: 0 100%; }
        .hud .mark.br { right: 28px; bottom: 28px; border-width: 0 1.5px 1.5px 0; transform-origin: 100% 100%; }
        .hud .label { position: absolute; left: 56px; top: 36px; }
        .hud .chapters { position: absolute; right: 56px; top: 34px; width: 220px; height: 15px; overflow: hidden; }
        .hud .chapters span { position: absolute; right: 0; top: 2px; white-space: pre; }
        .hud .tc { position: absolute; left: 56px; bottom: 36px; white-space: pre; }
        .hud .fc { position: absolute; right: 56px; bottom: 36px; white-space: pre; }

        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about b { color: #e6e6f0; font-weight: 600; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </style>
</head>
<body>
    <div class="demo-toolbar">
        <a href="/demos/">
            <img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
            <span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
        </a>
        <div class="demo-info"><strong>Showreel</strong>: a motion reel</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="showreel" data-start="0" data-duration="29" data-width="1280" data-height="720" data-fps="30">

            <div class="layer s1" data-component="reel-open" data-start="0" data-duration="3.46666667">
                <div class="floor" data-bind-style="floor"></div>
                <div class="guide" data-bind-style="guide"></div>
                <div class="cap l" data-bind-style="caption">FIG. 01</div>
                <div class="cap r" data-bind-style="caption">SQUASH AND STRETCH</div>
                <div class="iris" data-bind-style="iris"></div>
                <div class="open" data-bind-style="block">
                    <div class="line" data-list="line1" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="line" data-list="line2" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                </div>
            </div>

            <div class="layer s2" data-component="reel-type" data-start="3" data-duration="3.56666667">
                <div class="zoom" data-bind-style="zoom">
                    <div data-list="bands" data-key="key">
                        <template><div class="band" data-bind-style="band"><div class="run" data-bind-class="cls" data-bind="text" data-bind-style="run"></div></div></template>
                    </div>
                    <div class="hl" data-bind-style="highlight">
                        <div class="scr" data-bind-style="leftWord" data-list="leftLetters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                        <div class="scr" data-bind-style="rightWord" data-list="rightLetters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                    </div>
                    <div class="dot" data-bind-style="dot"></div>
                </div>
            </div>

            <div class="layer s3" data-component="reel-pattern" data-start="6.5" data-duration="3.06666667">
                <div class="grid" data-bind-style="camera" data-list="cells" data-key="key"><template><i class="cell" data-bind-style="style"></i></template></div>
            </div>

            <div class="layer s4" data-component="reel-ease" data-start="9.26666667" data-duration="3.73333333">
                <div data-list="grid" data-key="key"><template><div data-bind-class="cls" data-bind-style="style"><i data-bind-style="line"></i><span data-bind="label" data-bind-style="labelStyle"></span></div></template></div>
                <svg viewBox="0 0 1280 720" aria-hidden="true">
                    <path class="linear" d="M250 590 L930 290" data-bind-style="faint"></path>
                    <path class="curve" pathLength="1" data-bind-attr="curve" data-bind-style="stroke"></path>
                </svg>
                <div class="kicker" data-bind-style="faint">EASE</div>
                <div class="names" data-list="names" data-key="key"><template><span class="nm" data-bind="text" data-bind-style="style"></span></template></div>
                <div class="proj v" data-bind-style="dropLine"></div>
                <div class="proj h" data-bind-style="reachLine"></div>
                <div class="track" data-bind-style="faint"></div>
                <div class="marker" data-bind-style="marker"></div>
                <div class="read" data-bind="valueText" data-bind-style="valueRead"></div>
                <div class="tread" data-bind="timeText" data-bind-style="timeRead"></div>
                <div data-list="trail" data-key="key"><template><i class="trail" data-bind-style="style"></i></template></div>
                <div class="ball" data-bind-style="ball"></div>
            </div>

            <div class="layer s5" data-component="reel-focus" data-start="13" data-duration="4">
                <div data-pool="lights" data-key="id"><template><i class="light" data-bind-style="style"></i></template></div>
                <i class="light" data-bind-style="core"></i>
                <div class="field" data-bind-style="lens" data-list="rows" data-key="key">
                    <template><div class="plane" data-bind-style="layer"><div class="phrase" data-bind-class="cls" data-bind="text" data-bind-style="style"></div></div></template>
                </div>
                <div class="cap l" data-bind-style="caption">FIG. 05</div>
                <div class="cap r" data-bind-style="caption">RACK FOCUS</div>
            </div>

            <div class="layer s6" data-component="reel-space" data-start="17" data-duration="5">
                <canvas width="1280" height="720"></canvas>
            </div>

            <div class="layer s7" data-component="reel-rhythm" data-start="22" data-duration="3.4">
                <div class="cut" data-bind-style="card">
                    <div class="acc" data-bind-style="accent"></div>
                    <div class="mword" data-bind-class="wordClass" data-bind-style="wordStyle" data-list="letters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                </div>
            </div>

            <div class="layer s8" data-component="reel-end" data-start="25.4" data-duration="3.6">
                <div class="layer drift" data-bind-style="drift">
                    <div class="ln big l1" data-list="line1" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="ln l2" data-list="line2" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="ln big l3"><span data-list="line3" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></span><span class="stop" data-bind-style="stop"></span></div>
                    <div data-list="rings" data-key="key"><template><i class="ring" data-bind-style="style"></i></template></div>
                </div>
                <div class="meta c1" data-bind-style="credit1">SHOWREEL 2026</div>
                <div class="meta c2" data-bind-style="credit2">TYPE  PATTERN  EASING  FOCUS  SPACE  RHYTHM</div>
                <div class="meta c3" data-bind-style="credit3">Made with WildflowerJS, the Kinetic extension, and the Trillium (three.js) extension</div>
            </div>

            <div class="layer hud" data-component="reel-hud" data-start="0" data-duration="29">
                <div class="layer" data-bind-style="ink">
                    <i class="mark tl" data-bind-style="mark"></i>
                    <i class="mark tr" data-bind-style="mark"></i>
                    <i class="mark bl" data-bind-style="mark"></i>
                    <i class="mark br" data-bind-style="mark"></i>
                    <div class="label">SHOWREEL 2026</div>
                    <div class="chapters" data-list="chapters" data-key="key"><template><span data-bind="text" data-bind-style="style"></span></template></div>
                    <div class="tc" data-bind="timecode"></div>
                    <div class="fc" data-bind="frameCount"></div>
                </div>
            </div>
        </div>
    </div>

    <div class="about">
        <p>Eight scenes in 29 seconds. Play, scrub or step through it with the bar at the bottom.</p>
    </div>
</body>
</html>

app.js

// The showreel runs 29 seconds in eight scenes, with an overlay on top. Each
// scene is a component on its own clip of the composition, and every value
// it shows is computed from the clip's local time, so any frame renders on
// its own and in any order. The space scene draws with three.js through
// Trillium, with a depth-of-field pass. In a render, or with ?embed, Kinetic
// shows the stage alone.

import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

const {
    scene, styleAt, interpolate, ramp, mix, ease, spring, focus, depth, shake, trails,
    random, split, scramble, format, textMetrics, moment,
} = wildflower.kinetic;

wildflower.three.use(THREE);

// Film grain over the whole frame, in the preview and in every render.
wildflower.kinetic.config({ finish: { grain: 0.2 } });

// ---- Shared -----------------------------------------------------------------

const INK = '#0c0c10';
const PAPER = '#f2eee6';
const VIOLET = '#aa3de6';
const DEEP = '#892dbf';
const LIME = '#d9ff43';

const fx = (v, digits) => v.toFixed(digits === undefined ? 2 : digits);

// ---- 00 Open ------------------------------------------------------------------

const FLOOR = 470;            // px, the line the ball lands on
const BALL = 22;              // px, the ball's radius
const HANG = 150;             // px, where the ball's centre is held on the first frame
// Seeks land on whole frames, so every landing is put on one: the ball is
// let go at frame 9, falls for 10 frames, and bounces for 6, 3 and 2. Each
// arc's launch speed comes from its length, so it lands exactly at the end
// and the contact frame shows the ball on the floor.
const FRAME = 1 / 30;
const DROP = 9 * FRAME;
const FALL = 10 * FRAME;
const ARCS = [6, 3, 2];
const REST_Y = FLOOR - BALL;
const G = 2 * (REST_Y - HANG) / (FALL * FALL);
const V0 = G * FALL;
const LANDS = [DROP + FALL];  // s, each landing
const UPS = [V0];             // px/s, the speed the ball meets each landing with
for (const n of ARCS) { LANDS.push(LANDS[LANDS.length - 1] + n * FRAME); UPS.push(G * n * FRAME / 2); }
const EPS = 1e-6;             // a frame that is a landing counts as landed

// The ball's centre and speed at t. It is held, falls, then bounces three
// times.
function bounce(t) {
    if (t <= DROP) return { y: HANG, v: 0 };
    if (t < LANDS[0] - EPS) { const u = t - DROP; return { y: HANG + 0.5 * G * u * u, v: G * u }; }
    for (let k = 1; k < LANDS.length; k++) {
        if (t < LANDS[k] - EPS) {
            const u = t - LANDS[k - 1], up = UPS[k];
            return { y: REST_Y - (up * u - 0.5 * G * u * u), v: G * u - up };
        }
    }
    return { y: REST_Y, v: 0 };
}

const RISE = {
    translateY: { at: [0, 0.55], to: [112, 0], ease: 'power4.out', unit: '%' },
    rotate: { at: [0, 0.55], to: [8, 0], ease: 'power3.out' },
};

// Builds letter rows for words that each start at their own time, with a
// space row between words.
function wordRows(words, starts, t, gap, tracks) {
    const rows = [];
    words.forEach((w, wi) => {
        if (wi) rows.push({ key: rows.length, text: ' ', style: { width: '0.3em' } });
        for (const r of split(w)) rows.push({ key: rows.length, text: r.text, style: styleAt(t - starts[wi] - r.i * gap, tracks) });
    });
    return rows;
}

scene('reel-open', {
    computed: {
        t() { return this.local; },
        floor() { return { opacity: fx(1 - ramp(this.t, 1.4, 1.6)) }; },
        caption() { return { opacity: fx(1 - ramp(this.t, 1.35, 1.5)) }; },
        // A dashed line runs from the ball to the floor until the first
        // landing.
        guide() {
            const y = bounce(this.t).y;
            return { top: fx(y) + 'px', height: fx(Math.max(0, FLOOR - y)) + 'px', opacity: fx(1 - ramp(this.t, LANDS[0] - 0.08, LANDS[0])) };
        },
        // The ball is the violet layer seen through an ellipse. Its speed
        // stretches it, each landing squashes it with its bottom on the
        // floor, and from 1.45 s the ellipse opens into an iris.
        iris() {
            const t = this.t;
            const b = bounce(t);
            let squash = 0;
            // Each landing squashes in proportion to the speed it is met with.
            LANDS.forEach((at, k) => { if (t >= at - EPS) squash += 0.42 * Math.sqrt(UPS[k] / V0) * Math.max(0, 1 - (t - at) / 0.09); });
            squash += 0.3 * ramp(t, 1.3, 1.45, 'power2.inOut') * (1 - ramp(t, 1.45, 1.52));
            const sy = (1 + 0.3 * Math.min(1, Math.abs(b.v) / V0)) * (1 - squash);
            const open = ramp(t, 1.45, 1.8, 'power2.in');
            const rx = mix(BALL / sy, 1500, open), ry = mix(BALL * sy, 1500, open);
            const cy = mix(Math.min(b.y + BALL, FLOOR) - BALL * sy, 360, ease('power3.inOut')(open));
            return { clipPath: 'ellipse(' + fx(rx) + 'px ' + fx(ry) + 'px at 640px ' + fx(cy) + 'px)' };
        },
        line1() { return wordRows(['MAKE', 'IT'], [1.72, 1.9], this.t, 0.035, RISE); },
        line2() { return wordRows(['MOVE.'], [2.06], this.t, 0.035, RISE); },
        // The last word lands with a shake and a swell that die away, then
        // the block slides left under the next scene's bands. The shake's
        // roll is left out, so the block stays level.
        block() {
            const t = this.t;
            const hit = t > 2.12 ? Math.exp(-(t - 2.12) * 8) : 0;
            const s = shake(t - 2.12, 'impact', { amount: 0.3, speed: 2.7, seed: 3 });
            const out = ramp(t, 2.97, 3.45, 'power3.in');
            return styleAt(t, { translateX: s.x - 320 * out, translateY: s.y, scale: 1 + 0.04 * hit });
        },
    },
});

// ---- 01 Type ------------------------------------------------------------------

const RUNS = [
    { text: 'MOTION DESIGN • ', cls: 'g', speed: -150, x: -60 },
    { text: 'TYPE IN MOTION • ', cls: 'g outline', speed: 140, x: -1700 },
    { text: 'KINETIC • ', cls: 'g', speed: -190, x: -240 },
    { text: 'every frame  ', cls: 'serif', speed: 120, x: -1500 },
    { text: 'A FUNCTION OF TIME • ', cls: 'g', speed: -170, x: -100 },
].map((r, i) => Object.assign({}, r, { key: i, text: r.text.repeat(5) }));

const GLYPHS = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&*+=/<>';

// The two words scramble as one line, 'KINETICTYPE'. Letter k settles at
// 1.3 + 0.065k s, left to right, and until then changes glyph 24 times a
// second. A settled letter pops from 1.3 times its size.
const SCRAMBLED = 'KINETICTYPE';
const SETTLE = 0.065;
const POP = { opacity: 1, scale: { at: [0, 0.2], to: [1.3, 1], ease: 'power3.out' } };

function scrambleRows(word, first, t, shown) {
    return split(word).map((r) => {
        const k = first + r.i, at = 1.3 + k * SETTLE;
        return { key: r.key, text: shown[k], style: t < at ? styleAt(t, { opacity: 0.45, scale: 1 }) : styleAt(t - at, POP) };
    });
}

scene('reel-type', {
    keyframes: {
        // The scene scales about the dot's centre until the dot covers the
        // frame, which is where the next scene starts.
        zoom: {
            rotate: { at: [2.97, 3.5], to: [0, 8], ease: 'expo.in' },
            scale: { at: [2.97, 3.5], to: [1, 28], ease: 'expo.in' },
        },
    },
    computed: {
        t() { return this.local; },
        // Five bands open from the right one after another, each carrying a
        // line of type at its own speed.
        bands() {
            const t = this.t;
            return RUNS.map((r, i) => {
                const open = ramp(t, i * 0.06, 0.5 + i * 0.06, 'power4.out');
                return {
                    key: r.key, text: r.text, cls: r.cls,
                    band: { top: i * 144 + 'px', clipPath: 'inset(0 0 0 ' + fx(100 * (1 - open)) + '%)' },
                    run: { transform: 'translate(' + fx(r.x + r.speed * t + 260 * (1 - open)) + 'px, -50%)' },
                };
            });
        },
        highlight() { return { clipPath: 'inset(0 ' + fx(100 * (1 - ramp(this.t, 0.95, 1.35, 'power3.inOut'))) + '% 0 0)' }; },
        gap() { return 26 + 44 * ramp(this.t, 2.12, 2.45, 'power3.inOut'); },
        leftWord() { return { right: fx(640 + this.gap) + 'px' }; },
        rightWord() { return { left: fx(640 + this.gap) + 'px' }; },
        // scramble() settles letter k of 11 at duration * (k + 1) / 11 from
        // its time 0, so a start 0.065 s before 1.3 s settles letter k at
        // 1.3 + 0.065k.
        scrambled() { return scramble(this.t - 1.3 + SETTLE, SCRAMBLED, { duration: SETTLE * SCRAMBLED.length, fps: 24, chars: GLYPHS }); },
        leftLetters() { return scrambleRows('KINETIC', 0, this.t, this.scrambled); },
        rightLetters() { return scrambleRows('TYPE', 7, this.t, this.scrambled); },
        dot() { return styleAt(this.t, { scale: Math.max(0, spring(this.t - 2.2, { stiffness: 200, damping: 14 })) }); },
    },
});

// ---- 02 Pattern ---------------------------------------------------------------

const COLS = 18, ROWS = 11, CELL = 80;   // the grid runs one cell past each edge, for the camera's turn
const WAVES = [
    { at: 0.8, x: -80, y: -80, speed: 1900, tint: VIOLET },
    { at: 1.5, x: 1360, y: 800, speed: 1900, tint: LIME },
    { at: 2.0, x: 640, y: 360, speed: 1300, tint: null },
];
const CELLS = [];
{
    const rnd = random(23);
    for (let r = 0; r < ROWS; r++) {
        for (let c = 0; c < COLS; c++) {
            const x = c * CELL - 40, y = r * CELL - 40;   // the cell's centre
            const fromCentre = Math.hypot(x - 640, y - 360);
            CELLS.push({
                key: r * COLS + c,
                left: (x - 40) + 'px', top: (y - 40) + 'px',
                turn: rnd() < 0.5 ? 0 : 1,
                bloom: 0.04 + fromCentre / 760 * 0.55,
                leave: 2.2 + (c + r) / (COLS + ROWS - 2) * 0.36,
                hits: WAVES.map((w) => w.at + Math.hypot(x - w.x, y - w.y) / w.speed),
                core: fromCentre < 250,
            });
        }
    }
}

scene('reel-pattern', {
    keyframes: {
        // The camera turns and settles. It does not scale, so the cells stay
        // on whole pixels.
        camera: { rotate: { at: [0, 3.05], to: [-6, 0], ease: 'power3.out' } },
    },
    computed: {
        t() { return this.local; },
        // Each cell grows in from the centre outward, turns a quarter when a
        // wave reaches it, flashes the wave's colour as it turns, and shrinks
        // away on a diagonal at the end. The last wave, a ring from the
        // centre, leaves the cells inside it violet.
        cells() {
            const t = this.t;
            return CELLS.map((c) => {
                const size = ramp(t, c.bloom, c.bloom + 0.42, 'back.out') * (1 - ramp(t, c.leave, c.leave + 0.38, 'back.in'));
                let turns = c.turn;
                let colour = PAPER;
                WAVES.forEach((w, i) => {
                    const hit = c.hits[i];
                    turns += ramp(t, hit, hit + 0.34, 'back.out');
                    if (w.tint) colour = mix(colour, w.tint, Math.max(0, 1 - Math.abs(t - hit - 0.12) / 0.22));
                    else if (c.core) colour = mix(colour, VIOLET, ramp(t, hit, hit + 0.25));
                });
                return {
                    key: c.key,
                    style: Object.assign({ left: c.left, top: c.top, '--c': colour }, styleAt(t, { rotate: turns * 90, scale: size })),
                };
            });
        },
    },
});

// ---- 03 Easing ----------------------------------------------------------------

const GX0 = 250, GX1 = 930, GY0 = 590, GK = 300;   // t runs 0 to 1 across; a value of 1 is 300 px up
const gx = (p) => GX0 + p * (GX1 - GX0);
const gy = (v) => GY0 - v * GK;
const NAMES = ['power4.inOut', 'elastic.out', 'bounce.out'];
const CURVES = NAMES.map((n) => ease(n));
// The ball rides each curve, forward, back, then forward, and between rides
// the curve on the graph becomes the next one.
const RIDE = { at: [0.55, 1.3, 1.48, 2.23, 2.41, 3.1], to: [0, 1, 1, 0, 0, 1] };
const MORPHS = [1.3, 1.48, 2.23, 2.41];
// The curve on the graph as a fractional index into CURVES, and the names
// list's place, which moves on a steeper ease.
const SHAPE = { at: MORPHS, to: [0, 1, 1, 2], ease: 'power2.inOut' };
const NAME = { at: MORPHS, to: [0, 1, 1, 2], ease: 'power3.inOut' };
const OUT = 3;   // s, when the graph starts to clear

// shapeAt() gives the curve on the graph at t as two neighbouring eases and
// the share of the second.
function shapeAt(t) {
    const s = interpolate(t, SHAPE), a = Math.floor(s);
    return { a, b: Math.min(a + 1, CURVES.length - 1), m: s - a };
}
const valueOn = (p, s) => mix(CURVES[s.a](p), CURVES[s.b](p), s.m);

// rider() gives the ball's progress along the ride at t, its value on the
// curve and its place in the frame. From 3.1 s it flies to the centre, where
// the focus scene starts from a violet dot of the same size.
function rider(t) {
    const p = interpolate(t, RIDE);
    const v = valueOn(p, shapeAt(t));
    let x = gx(p), y = gy(v);
    const fly = ramp(t, OUT + 0.1, OUT + 0.5, 'power3.inOut');
    if (fly > 0) { x = mix(x, 640, fly); y = mix(y, 360, fly) - Math.sin(fly * Math.PI) * 70; }
    return { p, v, x, y };
}

// Fades in and out, as tracks of the scene's t.
const FAINT = { opacity: { at: [0.1, 0.4, OUT, OUT + 0.3], to: [0, 1, 1, 0] } };
const NAMES_SHOWN = { at: [0.25, 0.5, OUT, OUT + 0.25], to: [0, 1, 1, 0] };
const GUIDES = { at: [0.5, 0.65, OUT, OUT + 0.15], to: [0, 1, 1, 0] };
const TRAIL_SHOWN = { at: [0.55, 0.65, OUT + 0.1, OUT + 0.3], to: [0, 1, 1, 0] };
const BALL_IN = { at: [0.4, 0.55], to: [0, 1] };

const GRID = [0, 0.25, 0.5, 0.75, 1].map((p) => ({ across: true, at: p, label: p === 0 || p === 1 ? String(p) : '' }))
    .concat([0, 0.5, 1].map((v) => ({ across: false, at: v, label: String(v) })))
    .map((g, i) => Object.assign(g, { key: i }));

// The clip starts 0.25 s before the 9.5 s chapter mark, so the grid draws
// over the last of the pattern, and t counts from that mark.
const EASE_LEAD = 0.25;

scene('reel-ease', {
    computed: {
        t() { return this.local - EASE_LEAD; },
        // Grid lines draw in one after another and retract at the end.
        grid() {
            const t = this.t;
            return GRID.map((g) => {
                const i = g.key;
                const draw = ramp(t, i * 0.04 - EASE_LEAD, 0.12 + i * 0.04, 'power3.out') * (1 - ramp(t, OUT - 0.05 + i * 0.02, OUT + 0.25 + i * 0.02, 'power3.in'));
                return {
                    key: g.key, label: g.label, cls: g.across ? 'gl v' : 'gl h',
                    style: g.across
                        ? { left: gx(g.at) + 'px', top: gy(1.4) + 'px', width: '1px', height: (GY0 + 16 - gy(1.4)) + 'px' }
                        : { left: (GX0 - 16) + 'px', top: gy(g.at) + 'px', width: (GX1 - GX0 + 32) + 'px', height: '1px' },
                    line: Object.assign({ transformOrigin: g.across ? '50% 100%' : '0 50%' }, styleAt(t, g.across ? { scaleY: draw } : { scaleX: draw })),
                    labelStyle: { opacity: fx(draw) },
                };
            });
        },
        shape() { return shapeAt(this.t); },
        curve() {
            const s = this.shape;
            let d = '';
            for (let i = 0; i <= 120; i++) {
                const p = i / 120;
                d += (i ? ' L' : 'M') + fx(gx(p), 1) + ' ' + fx(gy(valueOn(p, s)), 1);
            }
            return { d };
        },
        // The path has pathLength 1 and a dash pattern of 1 1, so an offset
        // of 1 hides it, 0 shows all of it, and 2 - e erases the first e.
        stroke() {
            const drawn = ramp(this.t, 0.2, 0.7, 'power2.inOut'), erased = ramp(this.t, OUT, OUT + 0.35, 'power2.inOut');
            return { strokeDashoffset: fx(erased > 0 ? 2 - erased : 1 - drawn, 4) };
        },
        faint() { return styleAt(this.t, FAINT); },
        names() {
            const t = this.t;
            const at = interpolate(t, NAME), shown = interpolate(t, NAMES_SHOWN);
            return NAMES.map((n, i) => ({
                key: i, text: n,
                style: styleAt(t, { translateY: (i - at) * 56, opacity: Math.max(0, 1 - Math.abs(i - at)) * shown }),
            }));
        },
        ride() { return rider(this.t); },
        guides() { return interpolate(this.t, GUIDES); },
        dropLine() { const r = this.ride; return { left: fx(r.x) + 'px', top: fx(Math.min(r.y, GY0)) + 'px', height: fx(Math.abs(GY0 - r.y)) + 'px', opacity: fx(this.guides) }; },
        reachLine() { const r = this.ride; return { left: fx(r.x) + 'px', top: fx(r.y) + 'px', width: fx(Math.max(0, 1040 - r.x)) + 'px', opacity: fx(this.guides) }; },
        marker() { return { top: fx(this.ride.y) + 'px', opacity: fx(this.guides) }; },
        valueRead() { return { top: fx(this.ride.y) + 'px', opacity: fx(this.guides) }; },
        valueText() { return format(this.ride.v, { minimumFractionDigits: 3, maximumFractionDigits: 3 }); },
        timeRead() { return { left: fx(this.ride.x) + 'px', opacity: fx(this.guides) }; },
        timeText() { return 't ' + format(this.ride.p, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); },
        ball() { const r = this.ride; return styleAt(this.t, { translateX: r.x, translateY: r.y, opacity: BALL_IN }); },
        // The trail is eight ghosts of the ball at 22 ms steps into the past,
        // and while the ball is still they sit under it. Ghost k is
        // 1 - 0.09k of the ball's size at 0.5 - 0.055k opacity.
        trail() {
            const shown = interpolate(this.t, TRAIL_SHOWN);
            const style = (s) => { const r = rider(s); return styleAt(s, { translateX: r.x, translateY: r.y, opacity: 0.5 * shown }); };
            return trails('ghosts', this.t, { style, count: 8, lag: 0.022, fade: 0.88, scale: 0.09 });
        },
    },
});

// ---- 04 Focus -----------------------------------------------------------------

// The reel's sentence hangs in a 3D field, far to near, and the focal plane
// racks from the back to the front, so each fragment comes sharp in turn and
// the sentence reads in order. Depth is in CSS translateZ pixels, 0 at the
// stage and negative away from the camera.
const LENS = 900;         // px, the field's perspective
const FOCUS_LEN = 4;      // s, the clip's length
// How much smaller a thing at depth z is drawn, measured from the vanishing
// point.
const depthScale = (z) => LENS / (LENS - z);
// Each phrase is placed by where it lands on the stage and how big its type
// looks there. It sits on a full-frame plane that depth() puts at its depth
// and scales back up to fill the frame, so its place, its size and its blur
// are all in stage pixels.
const PHRASES = [
    { text: 'EVERY FRAME', cls: 'big', z: -1200, at: [-200, -150], size: 96 },
    { text: 'IS A FUNCTION', cls: 'serif', z: -560, at: [60, 6], size: 82 },
    { text: 'OF TIME', cls: 'big', z: 0, at: [200, 168], size: 132 },
    { text: 'depth', cls: 'word', z: -900, at: [420, -232], size: 22 },
    { text: 'plane', cls: 'word', z: -330, at: [-430, 104], size: 22 },
    { text: 'lens', cls: 'word', z: -130, at: [-250, 236], size: 22 },
].map((p, i) => Object.assign({}, p, { key: i, layer: depth(p.z, { perspective: LENS }) }));

// bokeh() draws a point of light at (x, y) on the stage, `base` px across in
// focus. blur runs 0 to 1, from focus(). Out of focus the light spreads into
// a disc eight times as wide, its edge softens and it dims, which is how a
// lens draws a point. The disc is a radial gradient, so no light has a filter.
function bokeh(x, y, base, blur, alpha, colour) {
    const size = base * (1 + 7 * blur), edge = 96 - 44 * blur;
    return {
        width: fx(size, 1) + 'px', height: fx(size, 1) + 'px',
        transform: 'translate(' + fx(x - size / 2, 1) + 'px, ' + fx(y - size / 2, 1) + 'px)',
        opacity: fx((1 - 0.75 * blur) * alpha, 3),
        background: 'radial-gradient(circle closest-side, ' + colour + ' ' + fx(edge, 0) + '%, transparent 100%)',
    };
}

scene('reel-focus', {
    keyframes: {
        // The plane starts on the easing scene's ball, at the stage, racks to
        // the back as the phrases fade in, then pulls forward one phrase at
        // a time.
        plane: { at: [0.1, 0.5, 1.25, 1.85, 2.45, 2.85], to: [0, -1200, -1200, -560, -560, 0], ease: 'power2.inOut' },
    },
    pools: { lights: {} },
    computed: {
        t() { return this.local; },
        // The vanishing point drifts across the shot, so far things slide
        // against near ones.
        drift() { return mix(-40, 40, this.t / FOCUS_LEN); },
        lens() { return { perspectiveOrigin: fx(640 + this.drift) + 'px 360px' }; },
        // The ball goes soft as the plane leaves it, and fades.
        core() { return bokeh(640, 360, 26, focus(0, this.plane, { range: 250, max: 1 }), 1 - ramp(this.t, 0.3, 0.75), VIOLET); },
        rows() {
            const plane = this.plane, shown = ramp(this.t, 0.35, 0.8);
            return PHRASES.map((p) => {
                const blur = focus(p.z, plane, { range: 250, max: 14 });
                return { key: p.key, text: p.text, cls: p.cls, layer: p.layer, style: {
                    left: (640 + p.at[0]) + 'px', top: (360 + p.at[1]) + 'px', fontSize: p.size + 'px',
                    filter: 'blur(' + fx(blur) + 'px)',
                    opacity: fx((1 - blur / 28) * shown, 3),
                } };
            });
        },
        caption() { return { opacity: fx(ramp(this.t, 0.4, 0.7)) }; },
    },
    // 300 lights at depths through the field, one in eight of them paper and
    // the rest violet.
    init() {
        const rnd = random(23);
        for (let i = 0; i < 300; i++) {
            const paper = rnd() < 0.125;
            this.pools.lights.add({ id: i, x: rnd() * 1280, y: rnd() * 720, z: -rnd() * 1000, base: 3 + rnd() * 3, tint: paper ? PAPER : VIOLET, alpha: paper ? 0.5 : 0.75, style: {} });
        }
    },
    // The lights are a static pool, written here on every seek. Each takes
    // its blur from its own depth, and shifts with the vanishing point by as
    // much as a phrase at that depth would. Kinetic skips this seek when
    // local has not changed, so a seek that changes nothing writes nothing.
    seek(local) {
        const lights = this.pools.lights, plane = this.plane, drift = this.drift, shown = ramp(local, 0.1, 0.6);
        lights.forEach((l) => {
            const blur = focus(l.z, plane, { range: 250, max: 1 });
            lights.update(l.id, { style: bokeh(l.x + drift * (1 - depthScale(l.z)), l.y, l.base, blur, l.alpha * shown, l.tint) });
        });
    },
});

// ---- 05 Space -----------------------------------------------------------------

const BEADS = 600;
const SPHERE = 2.2, RING = 2.4, TUBE = 0.6;   // world units
// The logo, built as the flower it is rather than sampled from the image:
// eight light petals in front, eight dark ones behind at half a step
// round, the disc, and the yellow centre. Each petal is an ellipse from
// the disc's edge to the rim, filled with beads on a grid tight enough to
// read as a solid, with the gaps between petals left clear. Classes: 0 a
// front petal, 1 a back petal, 2 the disc, 3 the centre. The dark classes
// take the reel's darker purple rather than the logo's own, so the two
// layers read apart at bead size.
const LOGO_COLOURS = [0xaa3de6, 0x521a78, 0x521a78, 0xffd21e];
// Depth toward the camera per class, so the front layer and the centre
// catch the key light over the back layer, and bead size per class.
const LOGO_DEPTH = [0.16, 0, 0.08, 0.24];
const LOGO_SIZE = [0.068, 0.068, 0.066, 0.082];

// 600 points of the logo, [x, y, class], sorted by angle round the centre.
function logoPoints() {
    const pts = [];
    const S = 0.148;                              // bead spacing
    const petal = (angle, k, r0, r1, halfWidth) => {
        const len = r1 - r0, rows = Math.round(len / S);
        for (let i = 0; i <= rows; i++) {
            // The tip bead sits a little short of the ellipse's end, so the
            // petal ends rounded rather than in a point.
            const u = i / rows, r = r0 + len * u - (i === rows ? S * 0.45 : 0);
            const w = halfWidth * Math.sqrt(Math.max(0, 1 - Math.pow(2 * u - 1, 2)));
            const across = Math.max(1, Math.round(2 * w / S));
            for (let j = 0; j < across; j++) {
                const off = across === 1 ? 0 : -w + (2 * w) * j / (across - 1);
                pts.push([r * Math.cos(angle) - off * Math.sin(angle), r * Math.sin(angle) + off * Math.cos(angle), k]);
            }
        }
    };
    for (let p = 0; p < 8; p++) {
        petal(p * Math.PI / 4, 0, 0.55, 2.25, 0.3);
        petal(p * Math.PI / 4 + Math.PI / 8, 1, 0.6, 2.05, 0.28);
    }
    const ring = (r0, r1, k) => {
        for (let r = r0; r <= r1 + 1e-6; r += S) {
            const n = Math.max(1, Math.round(2 * Math.PI * r / S));
            for (let j = 0; j < n; j++) pts.push([r * Math.cos(j / n * 2 * Math.PI), r * Math.sin(j / n * 2 * Math.PI), k]);
        }
    };
    ring(0.3, 0.72, 2);
    pts.push([0, 0, 3]);
    ring(0.13, 0.27, 3);
    // Exactly 600: drop or repeat disc points, which the petals overlap.
    const disc = () => pts.findIndex((q) => q[2] === 2);
    while (pts.length > BEADS) pts.splice(disc(), 1);
    while (pts.length < BEADS) pts.push(pts[disc()].slice());
    return pts.sort((a, b) => Math.atan2(a[1], a[0]) - Math.atan2(b[1], b[0]));
}
const LOGO_POINTS = logoPoints();
// Each bead has a place on a sphere and a place on a torus around the same
// axis, and the morph moves it from one to the other.
const BEAD_SHAPES = [];
{
    const rnd = random(41);
    const golden = Math.PI * (3 - Math.sqrt(5));
    for (let i = 0; i < BEADS; i++) {
        const y = 1 - 2 * (i + 0.5) / BEADS, r = Math.sqrt(1 - y * y), around = i * golden;
        const tube = 2 * Math.acos(y);
        const pick = rnd();
        BEAD_SHAPES.push({
            sphere: [Math.cos(around) * r * SPHERE, y * SPHERE, Math.sin(around) * r * SPHERE],
            torus: [(RING + TUBE * Math.cos(tube)) * Math.cos(around), TUBE * Math.sin(tube), (RING + TUBE * Math.cos(tube)) * Math.sin(around)],
            height: y,
            delay: rnd() * 0.22,
            size: 0.05 + rnd() * 0.07,
            colour: pick < 0.74 ? 0x521a78 : pick < 0.9 ? 0xaa3de6 : 0xf2eee6,
            lag: (1 - y) / 2 * 0.3,   // the morph runs from the top down
            angle: around % (2 * Math.PI),
        });
    }
    // The third shape is the logo (logoPoints(), 600 points sorted by
    // angle). Beads are paired with points by their angle rank, so the
    // petals form out of the ring rather than across it. The points are
    // laid in the formation's own x-z plane, which the tip turns to face
    // the camera, with z the negative of the logo's y so it stands upright.
    const byAngle = BEAD_SHAPES.map((b, i) => i).sort((a, b) => BEAD_SHAPES[a].angle - BEAD_SHAPES[b].angle);
    byAngle.forEach((bead, rank) => {
        const [lx, ly, k] = LOGO_POINTS[rank];
        BEAD_SHAPES[bead].logo = [lx, LOGO_DEPTH[k], -ly];
        BEAD_SHAPES[bead].logoColour = LOGO_COLOURS[k];
        BEAD_SHAPES[bead].logoSize = LOGO_SIZE[k];
    });
}

// A colour between two, as a hex number, for the beads taking the logo's
// colours as they arrive.
function mixHex(a, b, p) {
    if (p <= 0) return a;
    if (p >= 1) return b;
    const ch = (s) => [(s >> 16) & 255, (s >> 8) & 255, s & 255];
    const [ar, ag, ab] = ch(a), [br, bg, bb] = ch(b);
    return (Math.round(ar + (br - ar) * p) << 16) | (Math.round(ag + (bg - ag) * p) << 8) | Math.round(ab + (bb - ab) * p);
}

// pulse() is a damped sine from `at`, for an outward push that fades over
// a few oscillations.
const pulse = (t, at) => (t < at ? 0 : Math.exp(-(t - at) * 5) * Math.sin((t - at) * 16));

// beadsAt() gives the beads at t in world space. They burst from the centre
// on a spring, spin about the formation's axis, take a pulse that runs from
// the top of the sphere to the bottom, become the torus, tip over until the
// ring faces the camera, become the logo and take its colours, hold with a
// slow turn, then fly out past the camera.
function beadsAt(t) {
    const spin = 0.4 + 1.1 * (t <= 3 ? t : 3 + (t - 3) * 0.2);
    const tip = mix(0.35, Math.PI / 2, ramp(t, 1.75, 2.95, 'power3.inOut'));
    const cs = Math.cos(spin), sn = Math.sin(spin), ct = Math.cos(tip), st = Math.sin(tip);
    // The fly-out spreads the beads a little and pushes them down the z
    // axis toward the camera, each at its own speed, so they stream past the
    // lens on every side and are behind it before the cut.
    const flyOut = ramp(t, 4.3, 5, 'power3.in');
    const fly = 1 + 2 * flyOut;
    const push = 14 * flyOut;
    const gone = 1 - ramp(t, 4.7, 4.97);
    return BEAD_SHAPES.map((b, i) => {
        const out = spring(t - b.delay, { stiffness: 120, damping: 11 }) * (1 + 0.22 * pulse(t, 0.95 + (1 - b.height) * 0.18));
        const m = ramp(t, 1.6 + b.lag, 2.35 + b.lag, 'power3.inOut');
        const m3 = ramp(t, 3.0 + b.lag * 0.5, 3.7 + b.lag * 0.5, 'power3.inOut');
        const x = mix(mix(b.sphere[0], b.torus[0], m), b.logo[0], m3) * out * fly;
        const y = mix(mix(b.sphere[1], b.torus[1], m), b.logo[1], m3) * out * fly;
        const z = mix(mix(b.sphere[2], b.torus[2], m), b.logo[2], m3) * out * fly;
        const x1 = x * cs + z * sn, z1 = z * cs - x * sn;
        return { id: i, x: x1, y: y * ct - z1 * st, z: y * st + z1 * ct + push * (0.7 + 0.6 * b.height), scale: mix(b.size, b.logoSize, m3) * Math.min(1, out * 3) * gone, colour: mixHex(b.colour, b.logoColour, m3) };
    });
}

// The dust stays where it is, so the camera's flight shows against it, and
// none of it is near the camera's path down the z axis.
const DUST = [];
{
    const rnd = random(57);
    while (DUST.length < 260) {
        const x = (rnd() - 0.5) * 30, y = (rnd() - 0.5) * 18, z = 8 - rnd() * 40;
        if (z > -3 && Math.hypot(x, y) < 3.5) continue;
        DUST.push({ x, y, z, scale: 0.02 + rnd() * 0.035 });
    }
}

const FOV = 40;          // degrees, vertical
const CAM_START = 9.5;   // world units from the centre
// CORE is the violet dot the shot opens on after the cut, 13 px in radius
// on a 720 px frame, as a radius in world units at the camera's starting
// distance.
const CORE = 13 / 360 * CAM_START * Math.tan(FOV / 2 * Math.PI / 180);
// The camera pushes in slowly, holds, and pushes in a little more while the
// logo holds.
const CAM_Z = { at: [0, 3, 3.3, 4.6], to: [CAM_START, 8.2, 8.2, 5.9], ease: ['linear', 'linear', 'power3.inOut'] };

scene('reel-space', {
    computed: {
        t() { return this.local; },
        beads() { return beadsAt(this.t); },
        // The stars fade as the beads fly out, so the last frame is the
        // plain dark ground the TYPE card opens on.
        shot() {
            const t = this.t;
            return {
                camZ: interpolate(t, CAM_Z),
                core: CORE * (1 - ramp(t, 0.05, 0.4, 'power3.in')),
                stars: 0.5 * (1 - ramp(t, 4.2, 4.9, 'power3.inOut')),
                // The aperture opens for the fly-out, once the logo has let
                // go, so the beads passing the lens blur as a camera would
                // blur them while the formation stayed sharp before.
                aperture: mix(0.0004, 0.004, ramp(t, 4.3, 4.65, 'power3.inOut')),
                // The beads are drawn in flat cel bands while they burst,
                // spin and become the ring, and turn to polished metal as
                // the logo forms, with the studio's reflections arriving
                // with the metal.
                look: ramp(t, 2.95, 3.85, 'power3.inOut'),
            };
        },
    },
    init() {
        const canvas = this.element.querySelector('canvas');
        const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
        renderer.setSize(1280, 720, false);

        // The ground is the scene's background. A clear colour on the
        // renderer would be left in screen values by the pass that draws to
        // the screen, and the first pass of the next frame would clear its
        // linear target with it.
        const world = new THREE.Scene();
        world.background = new THREE.Color(0x0c0c10);
        world.fog = new THREE.Fog(0x0c0c10, 8, 15);
        world.add(new THREE.HemisphereLight(0xfff6ea, 0x101018, 0.5));
        const key = new THREE.DirectionalLight(0xffffff, 3.2);
        key.position.set(3, 5, 6);
        const fill = new THREE.DirectionalLight(0xf2eee6, 0.6);
        fill.position.set(-6, 1, 3);
        const rim = new THREE.DirectionalLight(0xaa3de6, 4);
        rim.position.set(-5, -2, -4);
        world.add(key, fill, rim);

        // One material carries both looks. three.js's standard shader is
        // patched with a `uLook` uniform and a cel version of its own lit
        // result, the direct light on the pixel quantised to three bands of
        // the bead's colour, and the pixel is a mix of the two by uLook: 0
        // is cel, 1 is the polished metal with its reflections. Per-instance
        // colours feed both.
        const look = { value: 1 };
        const beadMaterial = new THREE.MeshStandardMaterial({ roughness: 0, metalness: 0.55 });
        beadMaterial.onBeforeCompile = (shader) => {
            shader.uniforms.uLook = look;
            shader.fragmentShader = shader.fragmentShader
                .replace('#include <common>', '#include <common>\nuniform float uLook;')
                .replace('#include <opaque_fragment>', [
                    'float celLight = dot(reflectedLight.directDiffuse, vec3(0.3333)) / max(dot(diffuseColor.rgb, vec3(0.3333)), 1e-4);',
                    'float celBand = floor(clamp(celLight, 0.0, 1.0) * 3.0 + 0.5) / 3.0;',
                    'vec3 cel = diffuseColor.rgb * (0.28 + 0.72 * celBand);',
                    'outgoingLight = mix(cel, outgoingLight, uLook);',
                    '#include <opaque_fragment>',
                ].join('\n'));
        };
        const beads = new THREE.InstancedMesh(new THREE.SphereGeometry(1, 20, 14), beadMaterial, BEADS);
        const core = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 20), new THREE.MeshBasicMaterial({ color: 0xaa3de6, fog: false }));
        const dust = new THREE.InstancedMesh(new THREE.IcosahedronGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0xf2eee6, fog: false, transparent: true, opacity: 0.5 }), DUST.length);
        world.add(beads, core, dust);

        // The beads are polished metal, and what they reflect is a studio
        // built here rather than an HDR file fetched from the network, so
        // frame 0 never waits on a download and every render sees the same
        // room. It is a black room with a wide white softbox above and in
        // front, a tall violet strip to one side for the rim, and a dim warm
        // panel behind, baked once into an environment map. The panels
        // streak across the beads as the formation turns. Only the
        // environment is set, so the background stays the dark ground and
        // the stars.
        const studio = new THREE.Scene();
        const panel = (w, h, color, x, y, z) => {
            const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }));
            m.position.set(x, y, z);
            m.lookAt(0, 0, 0);
            studio.add(m);
        };
        panel(7, 3.5, new THREE.Color(3.5, 3.5, 3.5), 0, 5, 4);
        panel(1.4, 9, new THREE.Color(0xaa3de6).multiplyScalar(3), -7, 0, -2);
        panel(3, 3, new THREE.Color(0.45, 0.32, 0.5), 6, -2, -5);
        const pmrem = new THREE.PMREMGenerator(renderer);
        world.environment = pmrem.fromScene(studio).texture;
        pmrem.dispose();

        const camera = new THREE.PerspectiveCamera(FOV, 1280 / 720, 0.05, 100);
        // The frame goes through three.js's bokeh pass for depth of field.
        // The pass blurs each pixel by its distance from `focus` along the
        // camera's view. The blur grows with aperture times that distance and
        // stops at maxblur. The aperture is small enough that the formation,
        // about 4.4 units deep as a sphere, stays sharp, and the stars, 10 to
        // 40 units from the focus, soften into discs.
        // The passes work in linear colour, and the output pass converts to
        // the screen's, as a plain render does.
        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(world, camera));
        const bokeh = new BokehPass(world, camera, { focus: CAM_START, aperture: 0.0004, maxblur: 0.006 });
        composer.addPass(bokeh);
        composer.addPass(new OutputPass());
        const ctx = this;
        const view = wildflower.three.view('reel-space-view', {
            renderer, scene: world, camera, render: 'change',
            draw: () => composer.render(),
            before() {
                const s = ctx.shot;
                camera.position.set(0, 0, s.camZ);
                camera.lookAt(0, 0, 0);
                // The focus follows the camera's push, so it stays on the
                // formation at the centre.
                bokeh.uniforms.focus.value = s.camZ;
                bokeh.uniforms.aperture.value = s.aperture;
                core.scale.setScalar(Math.max(s.core, 1e-4));
                dust.material.opacity = s.stars;
                look.value = s.look;
                beadMaterial.envMapIntensity = s.look;
            },
        });
        view.instanced(() => ctx.beads, beads, { position: ['x', 'y', 'z'], scale: 'scale', color: 'colour', pickable: false });
        view.instanced(() => DUST, dust, { position: ['x', 'y', 'z'], scale: 'scale', pickable: false });
        // Shaders compile now, ahead of the first seek that draws.
        // renderer.compile() covers the scene's, and one render through the
        // composer covers the passes' own.
        renderer.compile(world, camera);
        composer.render();
    },
});

// ---- 06 Rhythm ----------------------------------------------------------------

// The cuts fall on whole frames and shorten from 14 frames to 4, 102 frames
// in all.
const CUTS = [
    { at: 0, word: 'TYPE', bg: INK, fg: PAPER, size: 300, move: 'zoom' },
    { at: 14, word: 'SHAPE', bg: DEEP, fg: PAPER, size: 240, left: 90, acc: 'shape' },
    { at: 27, word: 'TIME', bg: INK, fg: VIOLET, size: 300, acc: 'hand' },
    { at: 39, word: 'SPACE', bg: VIOLET, fg: INK, size: 400, kind: 'outline', move: 'slide' },
    { at: 50, word: 'COLOR', bg: INK, fg: PAPER, size: 260, acc: 'bars' },
    { at: 60, word: 'RHYTHM', bg: PAPER, fg: INK, size: 220, move: 'hop' },
    { at: 69, word: 'SPRING', bg: LIME, fg: INK, size: 230, move: 'spring' },
    { at: 77, word: 'ease', bg: DEEP, fg: PAPER, size: 330, kind: 'serif' },
    { at: 84, word: 'LOOP', bg: PAPER, fg: VIOLET, size: 300, move: 'zoom' },
    { at: 90, word: '', bg: VIOLET, fg: INK },
    { at: 94, word: '', bg: DEEP, fg: PAPER },
    { at: 98, word: '', bg: INK, fg: PAPER },
];

// cutAt() gives the cut on screen at a local time and the seconds since it
// started. The cuts are timed in frames, so the time is read as its frame.
function cutAt(local) {
    const cut = moment(Math.round(local / FRAME), CUTS).row;
    return { cut, u: local - cut.at * FRAME };
}

// The square turns a quarter as its corners sharpen.
const SHAPE_IN = {
    rotate: { at: [0, 0.26], to: [0, 90], ease: 'power3.out' },
    scale: { at: [0, 0.26], to: [0.6, 1], ease: 'power3.out' },
    borderRadius: { at: [0, 0.26], to: [50, 0], ease: 'power3.out', unit: '%' },
};

function accentOf(kind, u) {
    if (kind === 'shape') {
        return Object.assign({ left: '820px', top: '190px', width: '340px', height: '340px', background: VIOLET }, styleAt(u, SHAPE_IN));
    }
    if (kind === 'hand') {
        return Object.assign({ left: '637px', top: '40px', width: '6px', height: '320px', background: PAPER, transformOrigin: '50% 100%' }, styleAt(u, { rotate: u * 900 }));
    }
    if (kind === 'bars') {
        return { left: '0px', top: '520px', width: '1280px', height: '28px', background: 'linear-gradient(90deg, ' + VIOLET + ' 0 33.3%, ' + DEEP + ' 0 66.6%, ' + LIME + ' 0)', clipPath: 'inset(0 ' + fx(100 * (1 - ramp(u, 0, 0.16, 'power3.out'))) + '% 0 0)' };
    }
    return { display: 'none' };
}

const WORD_ZOOM = { scale: { at: [0, 0.25], to: [1.14, 1], ease: 'power3.out' } };
const LETTER_UP = { translateY: { at: [0, 0.12], to: [40, 0], ease: 'power4.out' } };

scene('reel-rhythm', {
    computed: {
        t() { return this.local; },
        now() { return cutAt(this.t); },
        card() {
            const c = this.now.cut;
            return { background: c.bg, '--fg': c.fg, justifyContent: c.left ? 'flex-start' : 'center', paddingLeft: (c.left || 0) + 'px' };
        },
        accent() { return accentOf(this.now.cut.acc, this.now.u); },
        wordClass() { return this.now.cut.kind || ''; },
        wordStyle() {
            const { cut, u } = this.now;
            const move = cut.move === 'zoom' ? styleAt(u, WORD_ZOOM) : cut.move === 'slide' ? styleAt(u, { translateX: 70 - 600 * u }) : { transform: 'none' };
            return Object.assign({ fontSize: (cut.size || 200) + 'px' }, move);
        },
        letters() {
            const { cut, u } = this.now;
            return split(cut.word).map((r) => {
                const style = cut.move === 'hop' ? styleAt(u, { translateY: (r.i % 2 ? -1 : 1) * 34 * Math.sin(u / 0.2 * Math.PI) })
                    : cut.move === 'spring' ? styleAt(u, { scaleY: spring(u - r.i * 0.015, { stiffness: 320, damping: 9, from: 0.4 }) })
                    : styleAt(u - r.i * 0.012, LETTER_UP);
                style.transformOrigin = cut.move === 'spring' ? '50% 100%' : '50% 50%';
                return { key: r.key, text: r.text, style };
            });
        },
    },
});

// ---- 07 End -------------------------------------------------------------------

const L1 = 104, L2 = 262, L3 = 330;   // the lines' tops, as in the CSS
const RIGHT = 1100;                   // the lockup's right edge
const STOP = 16;                      // the full stop's radius
// The full stop's centre at rest, estimated from the line's metrics. The
// ball's path is drawn relative to it, so at rest the ball sits where the
// baseline falls.
const STOP_X = RIGHT - STOP, STOP_Y = L3 + 128 - STOP;
const GRAVITY = 4200;                 // px/s²
// Each hop is a chord with a lift, and it takes the time a ball under
// GRAVITY needs to rise that far above the chord and come back.
const HOPS = [];
{
    const legs = [
        { to: [700, L1 + 6], lift: 40 },
        { to: [1030, L2 + 8], lift: 90 },
        { to: [STOP_X, STOP_Y], lift: 80 },
        { to: [STOP_X, STOP_Y], lift: 12 },
        { to: [STOP_X, STOP_Y], lift: 4 },
    ];
    let from = [380, -70], a = 0.95;
    for (const leg of legs) {
        const b = a + Math.sqrt(8 * leg.lift / GRAVITY);
        HOPS.push({ a, b, from, to: leg.to, lift: leg.lift });
        from = leg.to;
        a = b;
    }
}

// hopAt() gives the ball's centre in the frame at t and how hard it is
// squashed.
function hopAt(t) {
    let squash = 0;
    HOPS.forEach((h, k) => { if (t >= h.b) squash += (k < 3 ? 0.4 : 0.15) * Math.max(0, 1 - (t - h.b) / 0.08); });
    if (t <= HOPS[0].a) return { x: HOPS[0].from[0], y: HOPS[0].from[1], squash };
    for (const h of HOPS) {
        if (t < h.b) {
            const u = (t - h.a) / (h.b - h.a);
            return { x: mix(h.from[0], h.to[0], u), y: mix(h.from[1], h.to[1], u) - 4 * h.lift * u * (1 - u), squash };
        }
    }
    return { x: STOP_X, y: STOP_Y, squash };
}

// dip() is how far a letter at x dips when the ball lands near it, a damped
// wobble that is largest under the ball.
function dip(t, impact, x) {
    if (t < impact.at) return 0;
    const dt = t - impact.at, dx = (x - impact.x) / 130;
    return impact.depth * Math.exp(-dx * dx) * Math.exp(-dt * 9) * Math.sin(dt * 30);
}
const IMPACTS = HOPS.slice(0, 3).map((h, k) => ({ at: h.b, x: h.to[0], depth: [22, 14, 10][k] }));

// The lines' fonts, as in the CSS, for measuring where each letter sits.
const BIG_FONT = '900 150px system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const BIG_TRACKING = -0.045;          // em, the CSS letter-spacing
const SERIF_FONT = 'italic 400 62px Didot, "Bodoni 72", "Bodoni MT", Georgia, "Times New Roman", serif';
const LINE2 = 'is a function of';
// The last line ends at the full stop, 32 px wide with a 10 px margin.
const TIME_RIGHT = RIGHT - 2 * STOP - 10;

// droppedRows() drops letters in on a spring. The lines are flush right, so
// each letter's centre is measured back from the line's right edge. CSS
// letter-spacing follows every letter, the last too, so the line is one
// spacing wider than textMetrics() measures it.
function droppedRows(text, start, t, impact, right) {
    const m = textMetrics(text, BIG_FONT, { letters: true, tracking: BIG_TRACKING });
    const left = right - m.width - BIG_TRACKING * 150;
    return m.letters.map((r) => {
        const at = start + r.i * 0.045;
        const k = spring(t - at, { stiffness: 170, damping: 13 });
        const y = -120 * (1 - k) + dip(t, impact, left + r.x + r.width / 2);
        return { key: r.key, text: r.text, style: styleAt(t, { translateY: y, opacity: ramp(t, at, at + 0.12) }) };
    });
}

const FADE_UP = {
    opacity: { at: [0, 0.35], to: [0, 1] },
    translateY: { at: [0, 0.45], to: [12, 0], ease: 'power3.out' },
};

scene('reel-end', {
    keyframes: {
        // The lockup pushes in slowly for the whole scene.
        drift: { scale: { at: [0, 4], to: [1, 1.035], ease: 'sine.inOut' } },
    },
    computed: {
        t() { return this.local; },
        line1() { return droppedRows('MOTION', 0.05, this.t, IMPACTS[0], RIGHT); },
        line2() {
            const t = this.t;
            const m = textMetrics(LINE2, SERIF_FONT, { letters: true });
            const left = RIGHT - m.width;
            let first = 0;
            return split(LINE2, 'word').map((w, i) => {
                // The word's centre, from its first letter's left edge to its
                // last letter's right edge.
                const a = m.letters[first], b = m.letters[first + w.text.length - 1];
                first += w.text.length;
                const at = 0.3 + i * 0.05;
                const y = 26 * (1 - ramp(t, at, at + 0.5, 'power3.out')) + dip(t, IMPACTS[1], left + (a.x + b.x + b.width) / 2);
                return { key: w.key, text: w.text, style: styleAt(t, { translateY: y, opacity: ramp(t, at, at + 0.3) }) };
            });
        },
        line3() { return droppedRows('TIME', 0.5, this.t, IMPACTS[2], TIME_RIGHT); },
        stop() {
            const h = hopAt(this.t);
            const style = styleAt(this.t, { translateX: h.x - STOP_X, translateY: h.y - STOP_Y, scaleX: 1 + h.squash * 0.8, scaleY: 1 - h.squash });
            return Object.assign({ transformOrigin: '50% 100%' }, style);
        },
        // A ring spreads from each landing.
        rings() {
            const t = this.t;
            return IMPACTS.map((im, k) => {
                const p = ramp(t, im.at, im.at + 0.5), s = 0.2 + 2 * ease('power3.out')(p);
                return { key: k, style: styleAt(t, { translateX: im.x, translateY: HOPS[k].to[1] + STOP, scaleX: s, scaleY: s * 0.35, opacity: p > 0 && p < 1 ? 0.9 * (1 - p) : 0 }) };
            });
        },
        credit1() { return styleAt(this.t - 2.3, FADE_UP); },
        credit2() { return styleAt(this.t - 2.38, FADE_UP); },
        credit3() { return styleAt(this.t - 2.46, FADE_UP); },
    },
});

// ---- The overlay -------------------------------------------------------------

const CHAPTERS = ['00 INTRO', '01 TYPE', '02 PATTERN', '03 EASING', '04 FOCUS', '05 SPACE', '06 RHYTHM', '07 END'];
const CHAPTER_AT = [0, 3.2, 6.5, 9.5, 13, 17, 22, 25.4];
// The chapter list's place: it moves on one name over 0.3 s around each
// mark after the first.
const CHAPTER_PLACE = {
    at: CHAPTER_AT.slice(1).flatMap((c) => [c - 0.08, c + 0.22]),
    to: CHAPTER_AT.slice(1).flatMap((c, i) => [i, i + 1]),
    ease: 'power3.inOut',
};
// The overlay is light on dark grounds and dark on light ones, and it
// switches when a scene's background covers the corner marks. During the
// cuts it takes each cut's colour.
const INK_KEYS = { at: [0, 1.66, 1.74, 6.42, 6.5], to: [PAPER, PAPER, INK, INK, PAPER] };

function overlayInk(time) {
    if (time >= 25.4) return PAPER;
    if (time >= 22) return cutAt(time - 22).cut.fg;
    return interpolate(time, INK_KEYS.at, INK_KEYS.to);
}

const pad = (n, w) => String(n).padStart(w, '0');

scene('reel-hud', {
    keyframes: {
        mark: { scale: { at: [0.05, 0.5], to: [0, 1], ease: 'power3.out' } },
    },
    computed: {
        ink() { return { color: overlayInk(this.local) }; },
        chapters() {
            const at = interpolate(this.local, CHAPTER_PLACE);
            return CHAPTERS.map((text, i) => ({ key: i, text, style: styleAt(this.local, { translateY: (i - at) * 16 }) }));
        },
        timecode() { const f = this.clipFrame; return '00:00:' + pad(Math.floor(f / 30), 2) + ':' + pad(f % 30, 2); },
        frameCount() { return 'FRAME ' + pad(Math.min(this.clipFrame, 869), 3) + ' / 870'; },
    },
});