diff --git a/assets/css/_home.scss b/assets/css/_home.scss
index 18453b62..4d8d9549 100644
--- a/assets/css/_home.scss
+++ b/assets/css/_home.scss
@@ -1231,6 +1231,90 @@
animation: home-rise .9s var(--ease) .1s both;
}
+ // ---------- intro film (shortcodes/film.html, tools/film/) ----------
+ // The box has its size before anything loads (aspect-ratio), so no shift.
+ .film {
+ position: relative;
+ margin: clamp(48px, 8vh, 96px) 0 0;
+ aspect-ratio: 16 / 9;
+ border-radius: 12px;
+ overflow: hidden;
+ background: #09090a;
+ box-shadow: 0 0 0 1px var(--line);
+
+ img,
+ video {
+ position: absolute;
+ inset: 0;
+ display: block;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ object-fit: cover;
+ }
+
+ video {
+ opacity: 0;
+ transition: opacity .3s var(--ease);
+ }
+
+ &.on video {
+ opacity: 1;
+ }
+ }
+
+ .film-btn {
+ position: absolute;
+ right: 14px;
+ bottom: 14px;
+ z-index: 2;
+ display: grid;
+ place-items: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, .16);
+ border-radius: 50%;
+ background: rgba(9, 9, 10, .62);
+ color: #ecebe7;
+ cursor: pointer;
+ opacity: .72;
+ transition: opacity .2s var(--ease), transform .2s var(--ease);
+
+ &:hover,
+ &:focus-visible {
+ opacity: 1;
+ }
+
+ &:active {
+ transform: scale(.94);
+ }
+
+ svg {
+ display: none;
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+ }
+
+ &[data-state="play"] .i-play,
+ &[data-state="pause"] .i-pause,
+ &[data-state="replay"] .i-replay {
+ display: block;
+ }
+ }
+
+ @media (max-width: 640px) {
+ .film {
+ aspect-ratio: 4 / 5;
+ }
+
+ .film-btn {
+ right: 10px;
+ bottom: 10px;
+ }
+ }
+
// ---------- small screens ----------
@media (max-width: 960px) {
.case {
diff --git a/assets/js/home.js b/assets/js/home.js
index 1fd5daab..5a6e3243 100644
--- a/assets/js/home.js
+++ b/assets/js/home.js
@@ -148,6 +148,32 @@
}, { threshold: .3 }).observe(vid);
});
+ /* intro film: plays once while on screen, pauses off screen. Reduced motion
+ and Save-Data / slow connections keep the poster until someone presses play. */
+ var film = document.getElementById('film');
+ if (film) {
+ var fv = film.querySelector('video'), fb = film.querySelector('.film-btn');
+ var conn = navigator.connection || {};
+ var auto = !reduce && !conn.saveData && !/(^|-)2g|3g/.test(conn.effectiveType || '');
+ var wanted = false;
+ var setBtn = function (s, label) { fb.dataset.state = s; fb.setAttribute('aria-label', label); };
+ var play = function () {
+ fv.preload = 'auto';
+ fv.play().then(function () { film.classList.add('on'); setBtn('pause', 'Pause the intro film'); }, function () {});
+ };
+ fv.addEventListener('ended', function () { wanted = false; setBtn('replay', 'Play the intro film again'); });
+ fv.addEventListener('pause', function () { if (!fv.ended) setBtn('play', 'Play the intro film'); });
+ fb.addEventListener('click', function () {
+ if (fv.paused) { wanted = true; if (fv.ended) fv.currentTime = 0; play(); }
+ else { wanted = false; auto = false; fv.pause(); }
+ });
+ new IntersectionObserver(function (es) {
+ var vis = es[0].isIntersecting;
+ if (vis && (auto || wanted) && fv.paused && !fv.ended) { wanted = true; play(); }
+ else if (!vis && !fv.paused) fv.pause();
+ }, { threshold: .5 }).observe(film);
+ }
+
/* contact: click copies the address */
var mail = document.getElementById('mail'), hint = document.getElementById('copyhint');
if (mail && hint) {
diff --git a/content/_index.md b/content/_index.md
index b9ab4541..7cdffc00 100644
--- a/content/_index.md
+++ b/content/_index.md
@@ -8,7 +8,8 @@ description = "Isaac Lins. Application developer and security analyst at Swissco
{{< meta >}}
@@ -28,6 +29,8 @@ engineer and build systems that make it to production.
AI | tools & agents, from prompt to production
{{< /proof >}}
+{{< film >}}
+
{{< sec "about" >}}
{{< about pull="I started as a developer and moved into security. Both stuck." >}}
diff --git a/images/home/intro-16x9.webp b/images/home/intro-16x9.webp
new file mode 100644
index 00000000..138b5348
Binary files /dev/null and b/images/home/intro-16x9.webp differ
diff --git a/images/home/intro-4x5.webp b/images/home/intro-4x5.webp
new file mode 100644
index 00000000..ddfd4721
Binary files /dev/null and b/images/home/intro-4x5.webp differ
diff --git a/layouts/shortcodes/film.html b/layouts/shortcodes/film.html
new file mode 100644
index 00000000..66ae4b69
--- /dev/null
+++ b/layouts/shortcodes/film.html
@@ -0,0 +1,21 @@
+{{- /* Home: the intro film (source and render script in tools/film/).
+ 16:9 on wide screens, the 4:5 cut on phones. The picture underneath is
+ the poster; the video only loads when home.js decides to play it:
+ on screen, no reduced motion, no Save-Data. One button: play / pause / replay. */ -}}
+
+
+
+
+
+
+
+
diff --git a/static/video/intro-16x9.mp4 b/static/video/intro-16x9.mp4
new file mode 100644
index 00000000..b060523e
Binary files /dev/null and b/static/video/intro-16x9.mp4 differ
diff --git a/static/video/intro-16x9.webm b/static/video/intro-16x9.webm
new file mode 100644
index 00000000..fe9dcb41
Binary files /dev/null and b/static/video/intro-16x9.webm differ
diff --git a/static/video/intro-4x5.mp4 b/static/video/intro-4x5.mp4
new file mode 100644
index 00000000..e1bc1675
Binary files /dev/null and b/static/video/intro-4x5.mp4 differ
diff --git a/static/video/intro-4x5.webm b/static/video/intro-4x5.webm
new file mode 100644
index 00000000..b66eaa45
Binary files /dev/null and b/static/video/intro-4x5.webm differ
diff --git a/tools/film/.gitignore b/tools/film/.gitignore
new file mode 100644
index 00000000..3a0d929e
--- /dev/null
+++ b/tools/film/.gitignore
@@ -0,0 +1,2 @@
+out/
+node_modules
diff --git a/tools/film/README.md b/tools/film/README.md
new file mode 100644
index 00000000..10bf61bf
--- /dev/null
+++ b/tools/film/README.md
@@ -0,0 +1,49 @@
+# Intro film
+
+The 18-second intro film on the home page, made entirely in code: one canvas,
+a fixed timeline, rendered frame by frame. No stock footage, no AI video.
+
+| File | What |
+|---|---|
+| `film.html` | Loads the two scripts. Open `film.html?w=1920&h=1080&f=500` (via any static server from the repo root) to see frame 500. |
+| `film.js` | The film. `T` at the top is the timeline (seconds); one `shotN(t)` function per shot. |
+| `icons.js` | The cast: one 16×16 pixel icon per project on the home page, drawn as grids or shape functions. |
+| `render.mjs` | Playwright drives the page frame by frame (`FILM.render(n)`), pipes PNGs into ffmpeg. |
+| `build.sh` | Renders both cuts and encodes everything into `out/` (git-ignored). |
+
+## Script
+
+| Time | Shot |
+|---|---|
+| 0.0 | black: "I build applications." |
+| 2.2 | paper: "and ship AI tools.", the seven projects print in around it |
+| 4.8 | the ring folds into a `git log --graph` with the real commit hashes; `HEAD -> main` lands on Stewie |
+| 9.3 | black: "from a prompt", a prompt types a real request from Stewie's post |
+| 11.6 | paper: "to production.", the prompt becomes `minecraft.isaaclins.com · live` |
+| 14.3 | black: the live dot pulls the cast in and types `////////// isaaclins.com` |
+
+Palette, font and the `//////////` mark come from the site (`assets/css/main.scss`,
+`static/fonts/`). Time is driven, never read from a clock, so frame n is
+always the same picture.
+
+## Render
+
+```sh
+cd tools/film
+npm i playwright && npx playwright install chromium-headless-shell # once
+./build.sh # masters + every web file
+./build.sh encode # only re-encode from the masters
+```
+
+About 1,080 frames per cut. Run it on a strong machine. Outputs in `out/`:
+
+| File | Use |
+|---|---|
+| `intro-16x9.mp4` / `.webm` | home page, wide screens → `static/video/` |
+| `intro-4x5-720.mp4` / `.webm` | home page, phones → `static/video/intro-4x5.*` |
+| `intro-16x9-poster.webp`, `intro-4x5-poster.webp` | posters → `images/home/intro-16x9.webp`, `intro-4x5.webp` |
+| `intro-4x5-linkedin.mp4` | 1080×1350, silent, for LinkedIn |
+| `intro-16x9-hq.mp4` | 1080p high quality, for sharing |
+
+The film block on the home page is `layouts/shortcodes/film.html` (+ `.film` in
+`assets/css/_home.scss`, the play/pause logic in `assets/js/home.js`).
diff --git a/tools/film/build.sh b/tools/film/build.sh
new file mode 100755
index 00000000..67af4890
--- /dev/null
+++ b/tools/film/build.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Render both cuts and encode every deliverable into tools/film/out/.
+# ./build.sh masters + web files + poster
+# ./build.sh encode only re-encode from existing masters
+# ./build.sh masters only render the masters
+# Needs node + playwright (chromium) + ffmpeg on PATH. Heavy: run it on a
+# strong machine, not a laptop on battery (1080 frames per cut).
+set -euo pipefail
+cd "$(dirname "$0")"
+mkdir -p out
+if [ "${1:-}" != "encode" ]; then
+ node render.mjs --w 1920 --h 1080 --out out/master-16x9.mp4
+ node render.mjs --w 1080 --h 1350 --out out/master-4x5.mp4
+fi
+[ "${1:-}" = "masters" ] && exit 0
+X264='-c:v libx264 -preset veryslow -tune animation -profile:v high -pix_fmt yuv420p -movflags +faststart -an'
+VP9='-c:v libvpx-vp9 -b:v 0 -row-mt 1 -deadline good -cpu-used 1 -pix_fmt yuv420p -an'
+# 16:9, the homepage and anywhere else
+ffmpeg -v error -y -i out/master-16x9.mp4 $X264 -crf 27 out/intro-16x9.mp4
+ffmpeg -v error -y -i out/master-16x9.mp4 $VP9 -crf 40 out/intro-16x9.webm
+# 4:5, phones on the homepage (720 wide) and LinkedIn (full 1080x1350, silent for now)
+ffmpeg -v error -y -i out/master-4x5.mp4 -vf scale=720:900:flags=lanczos $X264 -crf 27 out/intro-4x5-720.mp4
+ffmpeg -v error -y -i out/master-4x5.mp4 -vf scale=720:900:flags=lanczos $VP9 -crf 40 out/intro-4x5-720.webm
+ffmpeg -v error -y -i out/master-4x5.mp4 $X264 -crf 18 out/intro-4x5-linkedin.mp4
+ffmpeg -v error -y -i out/master-16x9.mp4 $X264 -crf 18 out/intro-16x9-hq.mp4
+# posters: the finished git log (6.8 s)
+ffmpeg -v error -y -ss 6.8 -i out/master-16x9.mp4 -frames:v 1 -c:v libwebp -quality 82 out/intro-16x9-poster.webp
+ffmpeg -v error -y -ss 6.8 -i out/master-4x5.mp4 -frames:v 1 -vf scale=720:900:flags=lanczos -c:v libwebp -quality 82 out/intro-4x5-poster.webp
+ls -la out
diff --git a/tools/film/film.html b/tools/film/film.html
new file mode 100644
index 00000000..3e6c6e76
--- /dev/null
+++ b/tools/film/film.html
@@ -0,0 +1,9 @@
+
+
+
+
isaaclins.com intro film
+
+
+
+
diff --git a/tools/film/film.js b/tools/film/film.js
new file mode 100644
index 00000000..b7c3f626
--- /dev/null
+++ b/tools/film/film.js
@@ -0,0 +1,603 @@
+// The intro film for isaaclins.com, drawn on one canvas.
+// Time is driven from outside: render(frame) draws exactly that frame, so the
+// same frame always gives the same pixels (no clocks, no Math.random).
+// ?w=1920&h=1080 is the 16:9 film, ?w=1080&h=1350 the 4:5 cut. Layout reads
+// TALL to pick line breaks and sizes; the timeline is the same for both.
+
+(async function () {
+ const Q = new URLSearchParams(location.search);
+ const W = +Q.get('w') || 1920, H = +Q.get('h') || 1080;
+ const FPS = 60;
+ const TALL = H > W;
+ const S = TALL ? W / 1080 : H / 1080;
+
+ const cv = document.getElementById('c');
+ cv.width = W; cv.height = H;
+ const ctx = cv.getContext('2d');
+
+ // ---------- timeline (seconds) ----------
+ const T = {
+ w1: .15, // black: "I build applications."
+ s2: 1.70, // cut to paper: "and ship AI tools."
+ ringIn: 1.95, // the cast prints in around it, in depth, turning
+ textOut: 4.05,
+ morph: 4.30, // the ring folds into a git log (the hero move)
+ header: 4.55,
+ head: 6.00, // HEAD -> main
+ s3: 7.05, // cut to black: "from a prompt"
+ send: 9.05,
+ s4: 9.85, // cut to paper: "to production."
+ push: 12.17, // push into the live dot
+ s5: 12.67, // match cut to black on the dot
+ end: 16.20,
+ };
+ const DUR = T.end;
+ const FRAMES = Math.round(DUR * FPS);
+
+ // ---------- palette (the site's own tokens, assets/css/main.scss) ----------
+ const DARK = { bg: '#09090a', fg: '#ecebe7', fg2: '#a6a5a0', fg3: '#82817c', line: '#2c2c30', blue: '#6f95ff', card: '#1d1f26', rim: 'rgba(255,255,255,.14)' };
+ const PAPER = { bg: '#f5f4ef', fg: '#121212', fg2: '#55544f', fg3: '#6f6e69', line: '#d2d0c8', blue: '#2c55f0', card: '#fbfaf6', rim: '#d9d7cf' };
+
+ // ---------- easing ----------
+ const clamp01 = x => x < 0 ? 0 : x > 1 ? 1 : x;
+ const prog = (t, a, d) => clamp01((t - a) / d);
+ const lerp = (a, b, p) => a + (b - a) * p;
+ function bez(x1, y1, x2, y2) {
+ const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx;
+ const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
+ const sx = u => ((ax * u + bx) * u + cx) * u, sy = u => ((ay * u + by) * u + cy) * u;
+ return x => {
+ if (x <= 0) return 0; if (x >= 1) return 1;
+ let lo = 0, hi = 1;
+ for (let i = 0; i < 26; i++) { const m = (lo + hi) / 2; if (sx(m) < x) lo = m; else hi = m; }
+ return sy((lo + hi) / 2);
+ };
+ }
+ const E = {
+ out: bez(.16, 1, .3, 1), // arrivals (expo-ish out)
+ in: bez(.7, 0, .84, 0), // exits
+ snap: bez(.7, 0, .2, 1), // morphs: slow out, fast across, soft landing
+ site: bez(.2, .8, .2, 1), // --ease on the site
+ inOut: x => x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2,
+ outCubic: x => 1 - Math.pow(1 - x, 3),
+ quart: x => x * x * x * x,
+ inExpo: x => x <= 0 ? 0 : Math.pow(2, 10 * x - 10),
+ outExpo: x => x >= 1 ? 1 : 1 - Math.pow(2, -10 * x),
+ };
+ const back = (x, s = 1.70158) => x >= 1 ? 1 : 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2);
+ // a spring pop: 0 -> ~1.08 -> 1 in about 0.35 s
+ const pop = (t, tin) => t <= tin ? 0 : 1 - Math.exp(-7.5 * (t - tin)) * Math.cos(11 * (t - tin));
+ // time at which an eased sweep reaches fraction f (inverse by bisection)
+ const reach = (ease, f) => { let lo = 0, hi = 1; for (let i = 0; i < 30; i++) { const m = (lo + hi) / 2; if (ease(m) < f) lo = m; else hi = m; } return (lo + hi) / 2; };
+
+ function rng(seed) { return () => { 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; }; }
+
+ // ---------- font ----------
+ const face = new FontFace('JetBrains Mono', 'url(../../static/fonts/jetbrains-mono.woff2)', { weight: '100 800' });
+ await face.load(); document.fonts.add(face);
+
+ // set the font; returns the advance of one character (mono, 0.6 em + tracking)
+ function font(size, weight, ls = -.04) {
+ ctx.font = `${weight} ${size}px "JetBrains Mono"`;
+ ctx.letterSpacing = (size * ls) + 'px';
+ ctx.textBaseline = 'alphabetic';
+ ctx.textAlign = 'left';
+ return size * .6 + size * ls;
+ }
+
+ // ---------- grain ----------
+ // Fine, static, neutral grain: overlay of mid-grey noise (128 = no change),
+ // so the paper keeps the site's #f5f4ef and the black stays black.
+ function texture(seed, amp) {
+ const c = document.createElement('canvas'); c.width = W; c.height = H;
+ const g = c.getContext('2d'), img = g.createImageData(W, H), d = img.data, r = rng(seed);
+ for (let i = 0; i < d.length; i += 4) { const v = 128 + (r() - .5) * 2 * amp; d[i] = d[i + 1] = d[i + 2] = v; d[i + 3] = 255; }
+ g.putImageData(img, 0, 0); return c;
+ }
+ const GRAIN = { paper: texture(7, 34), dark: texture(11, 60) };
+
+ function background(th) {
+ ctx.fillStyle = th.bg; ctx.fillRect(0, 0, W, H);
+ const R = Math.hypot(W, H) / 2;
+ const g = ctx.createRadialGradient(W / 2, H * .48, R * .3, W / 2, H / 2, R);
+ if (th === PAPER) { g.addColorStop(0, 'rgba(60,50,30,0)'); g.addColorStop(1, 'rgba(60,50,30,.05)'); }
+ else { g.addColorStop(0, 'rgba(111,149,255,.03)'); g.addColorStop(1, 'rgba(0,0,0,0)'); }
+ ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
+ }
+ function grain(th) {
+ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.globalCompositeOperation = 'overlay'; ctx.globalAlpha = th === PAPER ? .22 : .5;
+ ctx.drawImage(th === PAPER ? GRAIN.paper : GRAIN.dark, 0, 0);
+ ctx.restore();
+ }
+ // a slow push-in per shot, so nothing ever sits dead still
+ function camera(t, a, b, amt = .03) {
+ const k = 1 + amt * E.site(prog(t, a, b - a));
+ ctx.translate(W / 2, H / 2); ctx.scale(k, k); ctx.translate(-W / 2, -H / 2);
+ }
+
+ // ---------- type ----------
+ function line(segs) {
+ const chars = [];
+ segs.forEach(([txt, col]) => { for (const c of txt) chars.push({ c, col }); });
+ const ws = []; let s = null;
+ chars.forEach((ch, i) => { if (ch.c === ' ') { if (s !== null) { ws.push({ s, e: i }); s = null; } } else if (s === null) s = i; });
+ if (s !== null) ws.push({ s, e: chars.length });
+ return { chars, ws, n: chars.length };
+ }
+ function runs(L, from, to, x, y, cw) {
+ let i = from;
+ while (i < to) {
+ const col = L.chars[i].col; let j = i, str = '';
+ while (j < to && L.chars[j].col === col) { str += L.chars[j].c; j++; }
+ ctx.fillStyle = col; ctx.fillText(str, x + i * cw, y); i = j;
+ }
+ }
+ const textW = (n, size, cw, ls) => n * cw - size * ls; // no trailing tracking
+ // baseline y of line i in a block of n lines centred on cy
+ const lineY = (i, n, size, cy, lh = 1.12) => cy - (n - 1) * size * lh / 2 + i * size * lh + size * .36;
+ // one word in its own slot, shifted by dy (the mask)
+ function slot(L, w, x, y, size, cw, dy) {
+ ctx.save(); ctx.beginPath();
+ ctx.rect(x + w.s * cw - size * .12, y - size * 1.02, (w.e - w.s) * cw + size * .24, size * 1.36); ctx.clip();
+ runs(L, w.s, w.e, x, y + dy, cw); ctx.restore();
+ }
+
+ // Move 1, word mask reveal: each word rises out of its own slot.
+ // With tout, the words drop back out of their slots, one after another.
+ function reveal(lines, size, weight, t, tin, cy, o = {}) {
+ const ls = -.04, cw = font(size, weight, ls);
+ const wmax = Math.max(...lines.map(l => textW(l.n, size, cw, ls)));
+ const x = W / 2 - wmax / 2;
+ const st = o.stagger ?? .085, du = o.dur ?? .62;
+ let k = 0;
+ lines.forEach((L, li) => {
+ const y = lineY(li, lines.length, size, cy);
+ L.ws.forEach(w => {
+ const p = E.out(prog(t, tin + k * st, du));
+ const q = o.tout == null ? 0 : E.in(prog(t, o.tout + k * .04, .36));
+ k++;
+ if (p <= 0 || q >= 1) return;
+ slot(L, w, x, y, size, cw, (1 - p) * size * 1.15 + q * size * 1.15);
+ });
+ });
+ }
+ // Move 2, tracking settle: wide letter-spacing and a touch large, settling in.
+ // Exits by dropping word by word through the mask, like reveal().
+ function settle(lines, size, weight, t, tin, tout, cy) {
+ const p = E.out(prog(t, tin, .7)); if (p <= 0) return;
+ if (tout != null && t >= tout) {
+ const ls = -.04, cw = font(size, weight, ls); let k = 0;
+ lines.forEach((L, i) => {
+ const x = W / 2 - textW(L.n, size, cw, ls) / 2, y = lineY(i, lines.length, size, cy);
+ L.ws.forEach(w => { const q = E.in(prog(t, tout + k++ * .045, .34)); if (q < 1) slot(L, w, x, y, size, cw, q * size * 1.15); });
+ });
+ return;
+ }
+ const ls = lerp(.28, -.04, p), k = lerp(1.07, 1, p);
+ ctx.save(); ctx.globalAlpha = .6 + .4 * Math.min(1, p * 1.6);
+ ctx.translate(W / 2, cy); ctx.scale(k, k); ctx.translate(-W / 2, -cy);
+ const cw = font(size, weight, ls);
+ lines.forEach((L, i) => runs(L, 0, L.n, W / 2 - textW(L.n, size, cw, ls) / 2, lineY(i, lines.length, size, cy), cw));
+ ctx.restore();
+ }
+ function caretBox(x, y, size, col) { ctx.fillStyle = col; ctx.fillRect(x + size * .1, y - size * .76, size * .44, size * .9); }
+ const blinkOn = (t, from) => t < from || ((t - from) % 1.06) < .53;
+ // fade + rise, for small lines (git log rows, header)
+ function rise(t, t0, draw, d = 12 * S) {
+ const p = E.outCubic(prog(t, t0, .35)); if (p <= 0) return;
+ ctx.save(); ctx.globalAlpha *= p; ctx.translate(0, (1 - p) * d); draw(); ctx.restore();
+ }
+
+ // ---------- pixel icons ----------
+ // Resolved row by row in under 0.18 s and popped in. On paper: a soft
+ // shadow that grows with depth; behind the text: smaller, softer, blurred.
+ // On black the outline is lifted to a dark grey rim so dark icons keep their edge.
+ const ICON_Q = 12, oc = document.createElement('canvas'); oc.width = oc.height = 16 * ICON_Q;
+ const ox = oc.getContext('2d');
+ function drawIcon(id, t, cx, cy, size, o = {}) {
+ const { rows, pal } = ICONS.icon(id, t);
+ const th = o.th || PAPER, build = o.build ?? 1;
+ const nrows = build >= 1 ? 16 : Math.ceil(build * 16);
+ if (nrows <= 0 || size <= .5) return;
+ ox.clearRect(0, 0, oc.width, oc.height);
+ for (let y = 0; y < nrows; y++) for (let x = 0; x < 16; x++) {
+ const ch = rows[y][x]; if (ch === '.') continue;
+ ox.fillStyle = th === DARK && ch === 'k' ? '#3d3e47' : pal[ch];
+ ox.fillRect(x * ICON_Q, y * ICON_Q, ICON_Q, ICON_Q);
+ }
+ ctx.save(); ctx.translate(cx, cy); if (o.rot) ctx.rotate(o.rot);
+ ctx.globalAlpha *= (o.alpha ?? 1);
+ const f = [];
+ if (o.blur > .05) f.push(`blur(${o.blur.toFixed(2)}px)`);
+ if (th === DARK) f.push(`drop-shadow(0 0 ${(1.5 * S).toFixed(2)}px rgba(236,235,231,.55))`); // light rim on black
+ if (f.length) ctx.filter = f.join(' ');
+ if (th === PAPER && o.shadow !== false) {
+ const k = size / (180 * S), z = o.z ?? 1; // z: 0 = far back, 1 = front
+ ctx.shadowColor = `rgba(40,32,20,${lerp(.08, .17, z).toFixed(3)})`;
+ ctx.shadowBlur = lerp(10, 32, z) * S * k; ctx.shadowOffsetY = lerp(6, 22, z) * S * k;
+ }
+ ctx.imageSmoothingEnabled = !!o.rot; ctx.imageSmoothingQuality = 'high';
+ ctx.drawImage(oc, -size / 2, -size / 2, size, size);
+ ctx.restore();
+ }
+
+ const CAST = ICONS.CAST;
+
+ // the cast on a tilted, turning ellipse; depth = sin(angle), front = bottom
+ function ring(i, t, o) {
+ const th = -Math.PI / 2 + i * 2 * Math.PI / CAST.length + (o.spin ? o.spin(t - o.t0) : (t - o.t0) * o.speed);
+ const ex = o.rx * Math.cos(th), ey = o.ry * Math.sin(th), c = Math.cos(o.tilt), s = Math.sin(o.tilt);
+ const depth = Math.sin(th);
+ const z = (depth + 1) / 2;
+ return { x: o.cx + ex * c - ey * s, y: o.cy + ex * s + ey * c, depth, z, s: lerp(o.sMin ?? .55, o.sMax ?? 1.15, z), rot: .14 * Math.sin(th * 2 + i * 1.7) };
+ }
+
+ // ---------- layout ----------
+ const L1 = TALL ? [line([['I build', DARK.fg]]), line([['applications.', DARK.fg]])] : [line([['I build applications.', DARK.fg]])];
+ const L2 = TALL ? [line([['and ship', PAPER.fg]]), line([['AI tools.', PAPER.blue]])] : [line([['and ship ', PAPER.fg], ['AI tools.', PAPER.blue]])];
+ const SZ1 = (TALL ? 132 : 106) * S, SZ2 = (TALL ? 116 : 100) * S;
+ const RING = TALL
+ ? { rx: 360 * S, ry: 330 * S, tilt: .14, size: 160 * S, dy: 40 * S }
+ : { rx: 620 * S, ry: 190 * S, tilt: -.21, size: 180 * S, dy: 60 * S };
+ // spins in at 220 deg/s and settles to 80 deg/s
+ const SPIN = dt => dt <= 0 ? 0 : 1.4 * dt + (3.84 - 1.4) / 2 * (1 - Math.exp(-2 * dt));
+
+ // git log (the 4:5 cut drops the hashes so the names can be big)
+ const LOG = TALL
+ ? { rowH: 140 * S, icon: 88 * S, name: 58 * S, hash: 0, head: 34 * S, hdr: 44 * S }
+ : { rowH: 112 * S, icon: 86 * S, name: 60 * S, hash: 32 * S, head: 30 * S, hdr: 34 * S };
+ {
+ const cwN = LOG.name * .57;
+ LOG.hashW = 7 * LOG.hash * .6; LOG.gap = LOG.hash ? 40 * S : 0;
+ LOG.iconX = 34 * S + LOG.icon / 2; LOG.nameX = 34 * S + LOG.icon + 34 * S;
+ const total = LOG.hashW + LOG.gap + LOG.nameX + (TALL ? 16 : 20) * cwN; // 4:5: centre on the typical name, not the longest
+ LOG.railX = W / 2 - total / 2 + LOG.hashW + LOG.gap;
+ LOG.y0 = H / 2 - (CAST.length - 1) * LOG.rowH / 2 + (TALL ? 50 : 38) * S;
+ LOG.hdrY = LOG.y0 - (TALL ? 128 : 100) * S;
+ LOG.hdrX = TALL ? LOG.railX - 10 * S : LOG.railX - LOG.hashW - LOG.gap;
+ }
+ const rowPos = i => ({ x: LOG.railX + LOG.iconX, y: LOG.y0 + i * LOG.rowH });
+ const FOLD = .9, FOLD_ST = .06;
+ const landAt = i => T.morph + i * FOLD_ST + FOLD;
+
+ // the prompt (black) and the live chip (paper)
+ const SZ3 = (TALL ? 96 : 92) * S;
+ const TXT_CY = H * (TALL ? .30 : .34);
+ const PROMPT = TALL ? ['my friends want', 'to play Minecraft'] : ['my friends want to play Minecraft'];
+ const PILL = TALL ? { w: 900 * S, h: 220 * S, r: 56 * S, txt: 50 * S } : { w: 1300 * S, h: 140 * S, r: 70 * S, txt: 50 * S };
+ PILL.cx = W / 2; PILL.cy = H * (TALL ? .57 : .62);
+ const BTN = 56 * S; // send button radius
+ const DOT0 = 18 * S; // the blue dot the pill grows from
+ // seeded human typing: ~30 ms a key, a little longer after a space
+ const keyT = (() => { const r = rng(42), a = [], s = PROMPT.join(' '); let t = T.s3 + .85; for (let i = 0; i < s.length; i++) { a.push(t); t += .019 + r() * .017 + (s[i] === ' ' ? .028 : 0); } return a; })();
+ const keysAt = t => { let n = 0; while (n < keyT.length && keyT[n] <= t) n++; return n; };
+ const CIRC = (TALL ? 180 : 140) * S; // the loader circle between prompt and chip
+
+ const CHIP = (() => {
+ const url = 'minecraft.isaaclins.com', pad = 34 * S, ic = 66 * S;
+ const txt = 46 * S, small = (TALL ? 44 : 36) * S;
+ const cw = txt * .6 - txt * .02, urlW = url.length * cw + txt * .02;
+ const c = { url, pad, ic, txt, small, cw, h: (TALL ? 124 : 120) * S };
+ c.w = pad + ic + 22 * S + urlW + 28 * S + 2 * S + 28 * S + 14 * S + 34 * S + 4 * small * .6 + pad;
+ c.r = c.h / 2; c.x = W / 2 - c.w / 2; c.y = PILL.cy - c.h / 2;
+ c.icX = c.x + pad + ic / 2; c.urlX = c.x + pad + ic + 22 * S; c.urlY = PILL.cy + txt * .36;
+ c.divX = c.urlX + urlW + 28 * S; c.dotX = c.divX + 2 * S + 28 * S + 7 * S; c.dotY = PILL.cy;
+ c.liveX = c.dotX + 34 * S; c.liveY = PILL.cy + small * .36; // 12 px clear of the pulse ring
+ return c;
+ })();
+
+ function rr(x, y, w, h, r) { ctx.beginPath(); ctx.roundRect(x, y, w, h, Math.min(r, h / 2, w / 2)); }
+
+ // ---------- shots ----------
+ function shot1(t) { // black: "I build applications."
+ background(DARK);
+ ctx.save(); camera(t, 0, T.s2, .04);
+ reveal(L1, SZ1, 700, t, T.w1, H / 2, { stagger: .11 });
+ ctx.restore();
+ grain(DARK);
+ }
+
+ function shot2(t) { // paper: "and ship AI tools." -> ring -> git log
+ background(PAPER);
+ ctx.save(); camera(t, T.s2, T.s3, .028);
+ const ro = { ...RING, cx: W / 2, cy: H / 2 + RING.dy, t0: T.ringIn, spin: SPIN, sMin: .4, sMax: 1 };
+ const icons = CAST.map((c, i) => {
+ const tin = T.ringIn + i * .07;
+ if (t < tin) return null;
+ const r = ring(i, t, ro);
+ const pm = E.inOut(prog(t, T.morph + i * FOLD_ST, FOLD));
+ const to = rowPos(i);
+ // an arc that bows away from the text, so the paths do not pile up
+ const ax = Math.sin(Math.PI * pm) * -60 * S, ay = 0;
+ return {
+ c, i, pm, depth: r.depth,
+ x: lerp(r.x, to.x, pm) + ax, y: lerp(r.y, to.y, pm) + ay,
+ size: lerp(RING.size * r.s * pop(t, tin), LOG.icon, pm), rot: lerp(r.rot, 0, pm),
+ build: 1, z: lerp(r.z, 1, pm),
+ alpha: lerp(lerp(.55, 1, r.z), 1, pm), blur: lerp(3 * S * (1 - r.z), 0, pm),
+ };
+ }).filter(Boolean);
+ const draw = ic => {
+ if (ic.pm === 0 && ic.z > .6) [[2, .15], [4, .08], [6, .04]].forEach(([k, a]) => { // a short trail on the front pass
+ const r = ring(ic.i, t - k / FPS, ro);
+ drawIcon(ic.c.id, t, r.x, r.y, ic.size, { rot: r.rot, th: PAPER, alpha: a, shadow: false });
+ });
+ drawIcon(ic.c.id, t, ic.x, ic.y, ic.size, { build: ic.build, rot: ic.rot, th: PAPER, z: ic.z, alpha: ic.alpha, blur: ic.blur });
+ };
+ const behind = ic => ic.pm === 0 && ic.depth < 0;
+ icons.filter(behind).sort((a, b) => a.depth - b.depth).forEach(draw);
+ settle(L2, SZ2, 700, t, T.s2 - 1 / FPS, T.textOut, H / 2);
+
+ // header
+ const hdr = line([['$ ', PAPER.blue], ['git log --graph', PAPER.fg2]]);
+ rise(t, T.header, () => { const cw = font(LOG.hdr, 500, 0); runs(hdr, 0, hdr.n, LOG.hdrX, LOG.hdrY, cw); });
+
+ // the graph: a blue line that grows down with the commits
+ const railTop = LOG.y0 - 34 * S, lastY = LOG.y0 + (CAST.length - 1) * LOG.rowH;
+ const rp = E.site(prog(t, landAt(0) - .25, landAt(CAST.length - 1) - landAt(0) + .4));
+ if (rp > 0) { ctx.fillStyle = PAPER.blue; ctx.fillRect(LOG.railX - 1.5 * S, railTop, 3 * S, (lastY + 34 * S - railTop) * rp); }
+
+ CAST.forEach((c, i) => {
+ const la = landAt(i), y = LOG.y0 + i * LOG.rowH;
+ if (t < la - .1) return;
+ const pn = back(prog(t, la - .1, .3), 2.2);
+ ctx.save(); ctx.translate(LOG.railX, y); ctx.scale(pn, pn);
+ ctx.beginPath(); ctx.arc(0, 0, 10 * S, 0, Math.PI * 2);
+ if (i === 0 && t >= T.head) { ctx.fillStyle = PAPER.blue; ctx.fill(); }
+ else { ctx.fillStyle = PAPER.bg; ctx.fill(); ctx.lineWidth = 3 * S; ctx.strokeStyle = PAPER.blue; ctx.stroke(); }
+ ctx.restore();
+ if (i === 0) [T.head, T.head + .45].forEach(p0 => { // HEAD lands: two pulses on the node
+ const q = prog(t, p0, .6); if (q <= 0 || q >= 1) return;
+ ctx.beginPath(); ctx.arc(LOG.railX, y, lerp(14, 48, E.out(q)) * S, 0, Math.PI * 2);
+ ctx.lineWidth = 2 * S; ctx.strokeStyle = PAPER.blue; ctx.globalAlpha = .5 * (1 - q); ctx.stroke(); ctx.globalAlpha = 1;
+ });
+ const pt = E.out(prog(t, la - .05, .3));
+ ctx.fillStyle = PAPER.blue; ctx.fillRect(LOG.railX + 12 * S, y - 1.5 * S, (LOG.iconX - LOG.icon / 2 - 18 * S) * pt, 3 * S);
+ rise(t, la - .02 + .03, () => {
+ if (LOG.hash) { font(LOG.hash, 500, 0); ctx.textAlign = 'right'; ctx.fillStyle = PAPER.fg2; ctx.fillText(c.hash, LOG.railX - LOG.gap, y + LOG.hash * .36); ctx.textAlign = 'left'; }
+ font(LOG.name, 650, -.03); ctx.fillStyle = PAPER.fg; ctx.fillText(c.name, LOG.railX + LOG.nameX, y + LOG.name * .36);
+ });
+ if (i === 0 && t >= T.head) { // HEAD -> main slides in from the right
+ const ph = prog(t, T.head, .35), e = back(ph, 2);
+ const cwn = font(LOG.name, 650, -.03);
+ const tx = LOG.railX + LOG.nameX + textW(c.name.length, LOG.name, cwn, -.03) + 28 * S + (1 - e) * 60 * S;
+ const cwt = font(LOG.head, 600, 0), label = 'HEAD -> main';
+ const tw = label.length * cwt + 30 * S, thh = LOG.head * 1.7;
+ ctx.save(); ctx.globalAlpha = Math.min(1, ph * 3);
+ rr(tx, y - thh / 2, tw, thh, 7 * S); ctx.lineWidth = 2.5 * S; ctx.strokeStyle = PAPER.blue; ctx.stroke();
+ ctx.fillStyle = PAPER.blue; ctx.fillText(label, tx + 15 * S, y + LOG.head * .36);
+ ctx.restore();
+ }
+ });
+ icons.filter(ic => !behind(ic)).sort((a, b) => (a.pm > 0) - (b.pm > 0) || a.z - b.z).forEach(draw);
+ ctx.restore();
+ grain(PAPER);
+ }
+
+ // the prompt text, typed over one or two lines; returns the caret position
+ function promptText(n, x, y0, cw, lh, col) {
+ // the space between two lines is the line break
+ let left = n, at = null;
+ PROMPT.forEach((s, i) => {
+ const k = Math.max(0, Math.min(s.length, left)), y = y0 + i * lh;
+ if (k > 0) { ctx.fillStyle = col; ctx.fillText(s.slice(0, k), x, y); }
+ if (!at && left <= s.length) at = { cx: x + k * cw, cy: y };
+ left -= s.length + 1;
+ });
+ return at || { cx: x + PROMPT[PROMPT.length - 1].length * cw, cy: y0 + (PROMPT.length - 1) * lh };
+ }
+
+ // the send button: the blue dot lands here, becomes the button, and the pill grows out of it
+ const BTNX = PILL.cx + PILL.w / 2 - 14 * S - BTN, BTNY = PILL.cy + (TALL ? PILL.h / 2 - 14 * S - BTN : 0);
+ const BR = BTN - 8 * S;
+ const collapse = bez(.65, 0, .35, 1);
+ const inOutQuart = x => x < .5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
+ const lerpRect = (a, b, p) => a.map((v, i) => lerp(v, b[i], p));
+ // colour tween between two #rrggbb
+ const mix = (a, b, p) => '#' + [1, 3, 5].map(i => Math.round(lerp(parseInt(a.substr(i, 2), 16), parseInt(b.substr(i, 2), 16), clamp01(p))).toString(16).padStart(2, '0')).join('');
+
+ function shot3(t) { // black: "from a prompt" + the prompt pill
+ background(DARK);
+ ctx.save(); camera(t, T.s3, T.s4, .022);
+ reveal([line([['from a prompt', DARK.fg2]])], SZ3, 500, t, T.s3 + .03, TXT_CY);
+ const dot = back(prog(t, T.s3 + .14, .26), 2.2); // the dot pops in
+ const toBtn = E.out(prog(t, T.s3 + .3, .3)); // and swells into the button
+ const gp = E.out(prog(t, T.s3 + .42, .62)); // the pill grows out of it
+ const cp = inOutQuart(prog(t, T.send + .15, .32)); // send: the pill closes back into the button
+ const land = T.send + .47;
+ const gl = E.snap(prog(t, land + .08, .25)); // then the button glides to the centre and grows
+ const btnRect = [BTNX - BR, BTNY - BR, BR * 2, BR * 2], pillRect = [PILL.cx - PILL.w / 2, PILL.cy - PILL.h / 2, PILL.w, PILL.h];
+ const [x, y, w, h] = lerpRect(lerpRect(btnRect, pillRect, gp), btnRect, cp);
+ const r = lerp(lerp(BR, PILL.r, gp), BR, cp);
+ if (gp > 0 && cp < 1) {
+ rr(x, y, w, h, r); ctx.fillStyle = DARK.card; ctx.fill();
+ ctx.lineWidth = 1.5 * S; ctx.strokeStyle = 'rgba(111,149,255,.34)'; ctx.stroke();
+ const alpha = prog(t, T.s3 + .75, .2);
+ if (alpha > 0) {
+ ctx.save(); ctx.globalAlpha = alpha;
+ rr(x, y, w, h, r); ctx.clip(); // contents only ever clipped, never faded out
+ const x0 = PILL.cx - PILL.w / 2, lh = PILL.txt * 1.3;
+ const y0 = PILL.cy - (PROMPT.length - 1) * lh / 2 + PILL.txt * .36;
+ const cwp = font(PILL.txt, 700, 0);
+ ctx.fillStyle = DARK.blue; ctx.fillText('>', x0 + 46 * S, y0);
+ const cwt = font(PILL.txt, 450, -.02);
+ const tx = x0 + 46 * S + cwp + 24 * S;
+ const c = promptText(keysAt(t), tx, y0, cwt, lh, DARK.fg);
+ if (blinkOn(t, keyT[keyT.length - 1] + .1) && t < T.send) caretBox(c.cx, c.cy, PILL.txt, DARK.blue);
+ ctx.restore();
+ }
+ }
+ // send: one ring around the button, done before the pill closes
+ const fl = prog(t, T.send, .2);
+ if (fl > 0 && fl < 1) {
+ ctx.beginPath(); ctx.arc(BTNX, BTNY, lerp(48, 100, E.outCubic(fl)) * S, 0, Math.PI * 2);
+ ctx.lineWidth = 2.5 * S; ctx.strokeStyle = DARK.blue; ctx.globalAlpha = .5 * (1 - fl); ctx.stroke(); ctx.globalAlpha = 1;
+ }
+ // the button: the dot, always solid blue and on top. Pressed, squashed as the pill lands in it, then it becomes the loader circle.
+ if (dot > 0) {
+ const bp = t < T.send ? 1 : t < T.send + .08 ? lerp(1, .88, prog(t, T.send, .08)) : lerp(.88, 1, back(prog(t, T.send + .08, .16), 2));
+ const sq = Math.sin(Math.PI * prog(t, land, .08));
+ const bx = lerp(BTNX, W / 2, gl), by = lerp(BTNY, PILL.cy, gl), br = lerp(lerp(DOT0, BR, toBtn) * dot, CIRC / 2, gl);
+ ctx.save(); ctx.translate(bx, by); ctx.scale(bp * (1 + .12 * sq), bp * (1 - .1 * sq));
+ ctx.beginPath(); ctx.arc(0, 0, br, 0, Math.PI * 2); ctx.fillStyle = DARK.blue; ctx.fill();
+ const ia = prog(t, T.s3 + .45, .2) * (1 - prog(t, land + .05, .1));
+ if (ia > 0) {
+ ctx.globalAlpha = ia; ctx.strokeStyle = DARK.bg; ctx.lineWidth = 4.5 * S; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
+ ctx.beginPath(); ctx.moveTo(0, 14 * S); ctx.lineTo(0, -14 * S); ctx.moveTo(-12 * S, -2 * S); ctx.lineTo(0, -14 * S); ctx.lineTo(12 * S, -2 * S); ctx.stroke();
+ }
+ ctx.restore();
+ }
+ ctx.restore();
+ grain(DARK);
+ }
+
+ function liveDot(t, t0, x, y, th, ringA = 1) {
+ if (t < t0) return;
+ const pp = prog(t, t0, .2), p = pp < .6 ? lerp(0, 1.2, E.outCubic(pp / .6)) : lerp(1.2, 1, E.outCubic((pp - .6) / .4)); // 0 -> 1.2 -> 1
+ for (let pt = t0 + .05; pt < t; pt += 1.2) { // pulse every 1.2 s, max radius 22 px
+ const q = prog(t, pt, 1.2); if (q >= 1) continue;
+ ctx.beginPath(); ctx.arc(x, y, 7 * S + E.out(q) * 15 * S, 0, Math.PI * 2);
+ ctx.lineWidth = 2 * S; ctx.strokeStyle = th.blue; ctx.globalAlpha = .35 * (1 - q) * ringA; ctx.stroke(); ctx.globalAlpha = 1;
+ }
+ ctx.beginPath(); ctx.arc(x, y, 7 * S * p, 0, Math.PI * 2); ctx.fillStyle = th.blue; ctx.fill();
+ }
+
+ // Move 3, letter cascade: each letter rises out of its own slot, 22 ms apart.
+ function cascade(L, size, weight, t, tin, cy) {
+ const ls = -.04, cw = font(size, weight, ls);
+ const x = W / 2 - textW(L.n, size, cw, ls) / 2, y = lineY(0, 1, size, cy);
+ L.chars.forEach((ch, i) => {
+ if (ch.c === ' ') return;
+ const p = E.out(prog(t, tin + i * .022, .55)); if (p <= 0) return;
+ ctx.save(); ctx.beginPath(); ctx.rect(x + i * cw - size * .1, y - size * 1.02, cw + size * .2, size * 1.36); ctx.clip();
+ ctx.fillStyle = ch.col; ctx.fillText(ch.c, x + i * cw, y + (1 - p) * size * 1.15); ctx.restore();
+ });
+ }
+
+ // The push: one camera move on the whole scene. The scale grows exponentially
+ // (k = 64^e, accelerating into the cut) around the live dot, which slides to
+ // the centre; on the last paper frame the dot is 7 px * 64 ~ 900 px wide.
+ const PUSHK = 64, pushIn = bez(.7, 0, .84, 0);
+ const SZ4 = (TALL ? 116 : 150) * S;
+ function shot4(t) { // paper: "to production." blue circle -> spinner -> check -> live chip
+ background(PAPER);
+ ctx.save();
+ camera(t, T.s4, T.push, .02);
+ let k = 1, e = 0;
+ if (t > T.push) {
+ e = pushIn(prog(t, T.push, T.s5 - T.push - 1 / FPS)); k = Math.pow(PUSHK, e);
+ ctx.translate(lerp(CHIP.dotX, W / 2, e), lerp(CHIP.dotY, H / 2, e)); ctx.scale(k, k); ctx.translate(-CHIP.dotX, -CHIP.dotY);
+ }
+ const textA = 1 - prog(k, 5, 7); // the type is long out of frame by then; never draw glyphs hundreds of px tall
+ if (textA > 0) { ctx.save(); ctx.globalAlpha = textA; cascade(line([['to production.', PAPER.fg]]), SZ4, 800, t, T.s4 + .03, TXT_CY); ctx.restore(); }
+ // spinner until t1, check drawn, colour change t2..t3 (blue -> white, check shrinks away), then the width opens
+ const t1 = T.s4 + .45, t2 = t1 + .26, t3 = t2 + .12;
+ const ex = 1 - Math.pow(1 - prog(t, t3, .38), 5); // easeOutQuint
+ const w = lerp(CIRC, CHIP.w, ex), h = lerp(CIRC, CHIP.h, ex), r = lerp(CIRC / 2, CHIP.r, ex);
+ const bx = W / 2 - w / 2, by = PILL.cy - h / 2;
+ const fillP = E.outCubic(prog(t, t2, .12)); // colour first, width second
+ if (k < 8) { ctx.save(); ctx.shadowColor = `rgba(40,32,20,${(.12 * fillP).toFixed(3)})`; ctx.shadowBlur = 40 * S; ctx.shadowOffsetY = 14 * S; }
+ else ctx.save();
+ rr(bx, by, w, h, r); ctx.fillStyle = mix(PAPER.blue, PAPER.card, fillP); ctx.fill(); ctx.restore();
+ if (fillP > 0) { ctx.save(); ctx.globalAlpha = fillP; rr(bx, by, w, h, r); ctx.lineWidth = 1.5 * S; ctx.strokeStyle = PAPER.rim; ctx.stroke(); ctx.restore(); }
+ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 8 * S;
+ const sr = CIRC * .24;
+ if (t < t1) { // spinner, white on the blue circle
+ const a = (t - T.s4) * 9, len = 1.1 + .6 * Math.sin((t - T.s4) * 7);
+ ctx.beginPath(); ctx.arc(W / 2, PILL.cy, sr, a, a + len * Math.PI); ctx.stroke();
+ } else if (t < t3) { // check, drawn, then scaled away while the circle turns white
+ const p = E.out(prog(t, t1, .22)), kk = sr / 24 * (1 - fillP);
+ ctx.save(); ctx.translate(W / 2, PILL.cy); ctx.scale(Math.max(kk, .001), Math.max(kk, .001));
+ const pts = [[-15, 1], [-4, 12], [17, -11]];
+ const l1 = Math.hypot(11, 11), l2 = Math.hypot(21, 23), d = p * (l1 + l2);
+ ctx.lineWidth = 8 * S / (sr / 24); ctx.beginPath(); ctx.moveTo(...pts[0]);
+ if (d <= l1) ctx.lineTo(lerp(pts[0][0], pts[1][0], d / l1), lerp(pts[0][1], pts[1][1], d / l1));
+ else { ctx.lineTo(...pts[1]); const q = (d - l1) / l2; ctx.lineTo(lerp(pts[1][0], pts[2][0], q), lerp(pts[1][1], pts[2][1], q)); }
+ ctx.stroke(); ctx.restore();
+ }
+ if (t >= t3) { // chip contents at full contrast, revealed only by the chip's own width
+ ctx.save(); rr(bx, by, w, h, r); ctx.clip();
+ if (textA > 0) {
+ ctx.globalAlpha = textA;
+ if (k < 6) drawIcon('minecraft', t, CHIP.icX, PILL.cy, CHIP.ic, { th: PAPER, shadow: false });
+ font(CHIP.txt, 550, -.02); ctx.fillStyle = PAPER.fg; ctx.fillText(CHIP.url, CHIP.urlX, CHIP.urlY);
+ font(CHIP.small, 600, 0); ctx.fillStyle = PAPER.blue; ctx.fillText('live', CHIP.liveX, CHIP.liveY);
+ ctx.globalAlpha = 1;
+ }
+ ctx.fillStyle = PAPER.rim; ctx.fillRect(CHIP.divX, PILL.cy - 26 * S, 2 * S, 52 * S);
+ ctx.restore();
+ liveDot(t, t3 + .38, CHIP.dotX, CHIP.dotY, PAPER, 1 - prog(t, T.push + .35, .08));
+ }
+ ctx.restore();
+ grain(PAPER);
+ }
+
+ // the end line, mono, centred: "////////// isaaclins.com" + caret
+ const ENDS = (TALL ? 62 : 88) * S;
+ const ENDL = line([['//////////', DARK.blue], [' isaaclins.com', DARK.fg]]);
+ const SWEEP = .6;
+ const passAt = Array.from({ length: ENDL.n }, (_, i) => reach(E.inOut, (i + .5) / ENDL.n) * SWEEP);
+ function shot5(t) { // black: the dot pulls the cast in, becomes the caret and draws the address
+ background(DARK);
+ ctx.save(); camera(t, T.s5, T.end, .03);
+ const ls = -.02, cw = font(ENDS, 600, ls);
+ const lw = textW(ENDL.n, ENDS, cw, ls), ex = W / 2 - lw / 2 - ENDS * .27, ey = H / 2 + ENDS * .36;
+ // the same disc as the last paper frame (same size, same place, same blue for 4 frames), shrinking to a 24 px dot
+ let rDot = lerp(7 * S * PUSHK, 12 * S, E.inOut(prog(t, T.s5, .45)));
+ const dotCol = mix(PAPER.blue, DARK.blue, prog(t, T.s5 + 4 / FPS, .2));
+ // the cast spirals back in on a smaller orbit (depth blur like the first one), then falls into the dot one by one
+ const ro = { rx: (TALL ? 330 : 380) * S, ry: (TALL ? 260 : 170) * S, tilt: TALL ? .14 : -.21, speed: 1.1, t0: T.s5 - 1.5, cx: W / 2, cy: H / 2, sMin: .6, sMax: 1.1 };
+ let bump = 0;
+ CAST.forEach((c, i) => {
+ const tin = T.s5 + .1 + i * .04; if (t < tin) return;
+ const ts = T.s5 + .6 + i * .04, ps = E.quart(prog(t, ts, .38));
+ if (ps >= 1) { const q = prog(t, ts + .38, .08); if (q < 1) bump = Math.max(bump, Math.sin(Math.PI * q)); return; } // each arrival feeds the dot
+ const sp = E.outCubic(prog(t, tin, .5)), grow = 1 + .9 * (1 - sp);
+ const r = ring(i, t + (1 - sp) * 1.4, { ...ro, rx: ro.rx * grow * (1 - ps), ry: ro.ry * grow * (1 - ps) });
+ drawIcon(c.id, t, r.x, r.y, 100 * S * r.s * (1 - ps * .8), { rot: r.rot + ps * 3.2, th: DARK, alpha: Math.min(1, sp * 2), blur: ps > 0 ? 0 : 3 * S * (1 - r.z) });
+ });
+ rDot *= 1 + .15 * bump;
+ // dot -> caret where it is, glide to the start, then sweep: the line appears behind it
+ const tq = T.s5 + .6 + 6 * .04 + .38 + .04, tg = tq + .12, tsw = tg + .25;
+ const sq = E.outCubic(prog(t, tq, .12));
+ const cwB = ENDS * .44, chB = ENDS * .9;
+ const bw = lerp(rDot * 2, cwB, sq), bh = lerp(rDot * 2, chB, sq);
+ const startX = ex + ENDS * .1, endX = ex + ENDL.n * cw + ENDS * .1, top = ey - ENDS * .76;
+ const g = inOutQuart(prog(t, tg, .25)), s = E.inOut(prog(t, tsw, SWEEP));
+ const bx = t < tsw ? lerp(W / 2 - bw / 2, startX, g) : lerp(startX, endX, s);
+ const by = lerp(H / 2 - bh / 2, top, g);
+ // the line: slashes grow up from the baseline, the address rises out of its slot
+ font(ENDS, 600, ls);
+ ENDL.chars.forEach((ch, i) => {
+ const pt = tsw + passAt[i]; if (t < pt) return;
+ const x = ex + i * cw;
+ if (i < 10) {
+ const kk = back(prog(t, pt, .28), 2.2);
+ ctx.save(); ctx.translate(x, ey); ctx.scale(1, kk); ctx.fillStyle = ch.col; ctx.fillText(ch.c, 0, 0); ctx.restore();
+ } else {
+ const p = E.out(prog(t, pt, .36));
+ ctx.save(); ctx.beginPath(); ctx.rect(x - 2 * S, ey - ENDS * 1.02, cw + 4 * S, ENDS * 1.32); ctx.clip();
+ ctx.fillStyle = ch.col; ctx.fillText(ch.c, x, ey + (1 - p) * ENDS * 1.1); ctx.restore();
+ }
+ });
+ const done = tsw + SWEEP + .4;
+ if (blinkOn(t, done)) { rr(bx, by, bw, bh, lerp(rDot, 0, sq)); ctx.fillStyle = dotCol; ctx.fill(); }
+ ctx.restore();
+ grain(DARK);
+ }
+
+ function render(f) {
+ const t = f / FPS;
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ if (t < T.s2) shot1(t);
+ else if (t < T.s3) shot2(t);
+ else if (t < T.s4) shot3(t);
+ else if (t < T.s5) shot4(t);
+ else shot5(t);
+ }
+
+ window.FILM = { W, H, FPS, FRAMES, T, render };
+ render(+Q.get('f') || 0);
+ document.body.dataset.ready = '1';
+})().catch(e => { document.body.dataset.error = String(e && e.stack || e); throw e; });
diff --git a/tools/film/icons.js b/tools/film/icons.js
new file mode 100644
index 00000000..784d5b7d
--- /dev/null
+++ b/tools/film/icons.js
@@ -0,0 +1,193 @@
+// The cast: one 16x16 pixel icon per project on the home page, drawn here
+// in code. Each icon is either a hand-placed grid (one char per pixel) or a
+// shape function; shape icons get a 1 px dark outline added automatically.
+// icon(name, t) returns { rows: string[16], pal: {char: color} } for time t,
+// so an icon can move by itself (Stewie blinks, the record spins).
+
+(function () {
+ const K = '#0b0c10'; // outline
+ const N = 16;
+
+ function check(rows, name) {
+ if (rows.length !== N || rows.some(r => r.length !== N)) throw new Error('bad grid: ' + name);
+ return rows;
+ }
+
+ // shape(x, y) -> palette char or '.', sampled at pixel centres; then outline.
+ function fromShape(shape) {
+ const g = [];
+ for (let y = 0; y < N; y++) { let r = ''; for (let x = 0; x < N; x++) r += shape(x + .5, y + .5, x, y) || '.'; g.push(r); }
+ const out = g.map(r => r.split(''));
+ for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
+ if (g[y][x] !== '.') continue;
+ const n = [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => {
+ const v = g[y + dy] && g[y + dy][x + dx];
+ return v && v !== '.';
+ });
+ if (n) out[y][x] = 'k';
+ }
+ return out.map(r => r.join(''));
+ }
+
+ // Seeded hash, so speckles are the same in every frame and every render.
+ const h2 = (x, y) => { let s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return s - Math.floor(s); };
+
+ // Stewie: an agent with its own computer. A monitor with a face.
+ const STEWIE = check([
+ '......kkkk......',
+ '......kbbk......',
+ '......kkkk......',
+ '.......kk.......',
+ '..kkkkkkkkkkkk..',
+ '.kbbbbbbbbbbbbk.',
+ '.kbssssssssssBk.',
+ '.kbssssssssssBk.',
+ '.kbsswwsswwssBk.',
+ '.kbsswwsswwssBk.',
+ '.kbssssssssssBk.',
+ '.kbsssbbbbsssBk.',
+ '.kbssssssssssBk.',
+ '.kBBBBBBBBBBBBk.',
+ '..kkkkkkkkkkkk..',
+ '.....kkkkkk.....',
+ ], 'stewie');
+
+ // Rental Law Navigator: a house, windows lit.
+ const HOUSE = check([
+ '................',
+ '.......kk.......',
+ '......kLLk......',
+ '.....kLLLLk.....',
+ '....kLLLLLLk....',
+ '...kLLLLLLLLk...',
+ '..kLLLLLLLLLLk..',
+ '.kLLLLLLLLLLLLk.',
+ '.kkkkkkkkkkkkkk.',
+ '..kwwwwwwwwwwk..',
+ '..kwccwwwwccwk..',
+ '..kwccwwwwccwk..',
+ '..kwwwkkkkwwwk..',
+ '..kwwwkddkwwwk..',
+ '..kwwwkddkwwwk..',
+ '.kkkkkkkkkkkkkk.',
+ ], 'house');
+
+ // demo-video-skill: a clapperboard with a play button.
+ const CLAP = check([
+ '................',
+ '.kkkkkkkkkkkkkk.',
+ '.kwwkkwwkkwwkkk.',
+ '.kkwwkkwwkkwwkk.',
+ '.kkkkkkkkkkkkkk.',
+ '.kyyyyyyyyyyyyk.',
+ '.kyyyyyyyyyyyyk.',
+ '.kyyyykyyyyyyyk.',
+ '.kyyyykkyyyyyyk.',
+ '.kyyyykkkyyyyyk.',
+ '.kyyyykkkkyyyyk.',
+ '.kyyyykkkyyyyyk.',
+ '.kyyyykkyyyyyyk.',
+ '.kyyyykyyyyyyyk.',
+ '.kYYYYYYYYYYYYk.',
+ '.kkkkkkkkkkkkkk.',
+ ], 'clap');
+
+ // Challenge Server: a grass block, isometric, computed face by face.
+ const GRASS = fromShape((px, py, x, y) => {
+ const top = Math.abs(px - 8) / 7 + Math.abs(py - 4.5) / 3.5 <= 1;
+ if (top) return h2(x, y) > .72 ? 'G' : 'g';
+ if (px >= 1 && px <= 8 && py >= 4.5 + .5 * (px - 1) && py <= 11.5 + .5 * (px - 1)) {
+ const d = py - (4.5 + .5 * (px - 1));
+ if (d < 1.6 || (d < 2.8 && h2(x, 3) > .45)) return 'G';
+ return h2(x, y) > .7 ? 'D' : 'd';
+ }
+ if (px >= 8 && px <= 15 && py >= 8 - .5 * (px - 8) && py <= 15 - .5 * (px - 8)) {
+ const d = py - (8 - .5 * (px - 8));
+ if (d < 1.6 || (d < 2.8 && h2(x, 5) > .45)) return 'H';
+ return h2(x, y) > .7 ? 'E' : 'D';
+ }
+ return '';
+ });
+
+ // FreeSnitch: a shield with a check.
+ const CHECK = new Set(['4,8', '4,9', '5,9', '5,10', '6,10', '6,11', '7,10', '7,9', '8,9', '8,8', '9,8', '9,7', '10,7', '10,6', '11,6', '11,5']);
+ const SHIELD = fromShape((px, py, x, y) => {
+ if (py < 1.2 || py > 15) return '';
+ const hw = py <= 8.5 ? 6.2 : 6.2 * Math.pow(Math.max(0, 1 - (py - 8.5) / 6.6), .75);
+ if (Math.abs(px - 8) > hw) return '';
+ if (CHECK.has(x + ',' + y)) return 'w';
+ return px < 8 ? 'm' : 'M';
+ });
+
+ // pi-vault: a padlock.
+ const KEYHOLE = new Set(['7,9', '8,9', '6,10', '7,10', '8,10', '9,10', '7,11', '8,11', '7,12', '8,12']);
+ const LOCK = fromShape((px, py, x, y) => {
+ if (px > 2.5 && px < 13.5 && py > 7.5 && py < 14.8) {
+ if (KEYHOLE.has(x + ',' + y)) return 'k';
+ return py > 13.2 || px > 12.2 ? 'C' : 'c';
+ }
+ const d = Math.hypot(px - 8, py - 6.6);
+ if (py < 7.6 && d >= 2.6 && d <= 4.7) return px > 8.6 ? 'S' : 's';
+ if (py >= 6.6 && py <= 7.6 && ((px > 3.2 && px < 5.4) || (px > 10.6 && px < 12.8))) return px > 8 ? 'S' : 's';
+ return '';
+ });
+
+ // Spotiglass: a vinyl record. The sheen turns with t, so it spins.
+ function vinyl(t) {
+ const phi = t * 2.6;
+ return fromShape((px, py) => {
+ const d = Math.hypot(px - 8, py - 8);
+ if (d > 7.1) return '';
+ if (d < .8) return 'k';
+ if (d < 2.9) return d < 2.0 ? 'p' : 'P';
+ const a = Math.atan2(py - 8, px - 8);
+ const sheen = Math.cos(2 * (a - phi));
+ if (sheen > .86 && d > 3.4 && d < 6.6) return 'h';
+ return (d > 4.4 && d < 5.1) || d > 6.4 ? 'V' : 'v';
+ });
+ }
+
+ const PAL = {
+ stewie: { k: K, b: '#6f95ff', B: '#3e63d8', s: '#141a33', w: '#ecebe7' },
+ navigator: { k: K, L: '#8fb1ff', w: '#ecebe7', c: '#ffd36b', d: '#4a6fe0' },
+ 'demo-video': { k: K, w: '#ecebe7', y: '#f5b83d', Y: '#c98a17' },
+ minecraft: { k: K, g: '#79c94a', G: '#559c33', H: '#467f2a', d: '#916038', D: '#6f4727', E: '#55361d' },
+ freesnitch: { k: K, m: '#34d399', M: '#1ea372', w: '#f5f4ef' },
+ 'pi-vault': { k: K, c: '#3fd0ff', C: '#1f9ccb', s: '#d4d3cd', S: '#8f8e88' },
+ spotiglass: { k: K, v: '#1c1c22', V: '#2b2b34', h: '#6a6a78', p: '#ff5a72', P: '#d83a55' },
+ };
+
+ // Stewie blinks twice in the film (times in seconds); one blink = 0.12 s.
+ const BLINKS = [3.9, 8.1, 13.2, 17.4];
+
+ function icon(name, t) {
+ let rows;
+ switch (name) {
+ case 'stewie':
+ rows = STEWIE;
+ if (BLINKS.some(b => t >= b && t < b + .12)) rows = rows.map((r, i) => i === 8 ? r.replace(/w/g, 's') : i === 9 ? r.replace(/w/g, 'b') : r);
+ break;
+ case 'navigator': rows = HOUSE; break;
+ case 'demo-video': rows = CLAP; break;
+ case 'minecraft': rows = GRASS; break;
+ case 'freesnitch': rows = SHIELD; break;
+ case 'pi-vault': rows = LOCK; break;
+ case 'spotiglass': rows = vinyl(t); break;
+ default: throw new Error('no icon ' + name);
+ }
+ return { rows, pal: PAL[name] };
+ }
+
+ // The order of the git log on the home page, newest first (data/projects.toml).
+ const CAST = [
+ { id: 'stewie', name: 'Stewie', hash: 'a4d82da' },
+ { id: 'navigator', name: 'Rental Law Navigator', hash: '417d035' },
+ { id: 'demo-video', name: 'demo-video-skill', hash: '1b623a3' },
+ { id: 'minecraft', name: 'Challenge Server', hash: '851cf10' },
+ { id: 'freesnitch', name: 'FreeSnitch', hash: 'cc88217' },
+ { id: 'pi-vault', name: 'pi-vault', hash: '977842a' },
+ { id: 'spotiglass', name: 'Spotiglass', hash: '3e34eb7' },
+ ];
+
+ window.ICONS = { icon, CAST, N };
+})();
diff --git a/tools/film/render.mjs b/tools/film/render.mjs
new file mode 100644
index 00000000..6b51a722
--- /dev/null
+++ b/tools/film/render.mjs
@@ -0,0 +1,77 @@
+// Renders the film frame by frame with Playwright and pipes the frames into ffmpeg.
+// node render.mjs --w 1920 --h 1080 --out out/master-16x9.mp4 [--jobs 4] whole film -> near-lossless master
+// node render.mjs --w 1920 --h 1080 --stills 0,300,600 --outdir out single frames as PNG
+// Needs: node, playwright (chromium), ffmpeg. The time is driven: frame n is
+// always the same picture, however slow the machine is.
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { chromium } from 'playwright';
+
+const args = Object.fromEntries(process.argv.slice(2).reduce((a, v, i, all) => (v.startsWith('--') ? a.concat([[v.slice(2), all[i + 1]]]) : a), []));
+const W = +(args.w || 1920), H = +(args.h || 1080);
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+
+const types = { '.html': 'text/html', '.js': 'text/javascript', '.woff2': 'font/woff2' };
+const server = http.createServer((req, res) => {
+ const p = path.join(root, decodeURIComponent(new URL(req.url, 'http://x').pathname));
+ if (!p.startsWith(root) || !fs.existsSync(p)) { res.writeHead(404); return res.end(); }
+ res.writeHead(200, { 'content-type': types[path.extname(p)] || 'application/octet-stream' });
+ fs.createReadStream(p).pipe(res);
+}).listen(0, '127.0.0.1');
+await new Promise(r => server.once('listening', r));
+const url = `http://127.0.0.1:${server.address().port}/tools/film/film.html?w=${W}&h=${H}`;
+
+const JOBS = +(args.jobs || 4);
+const browser = await chromium.launch();
+async function openPage() {
+ const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
+ page.on('pageerror', e => { console.error(e); process.exit(1); });
+ page.on('console', m => { if (m.type() === 'error') console.error('page:', m.text()); });
+ page.on('requestfailed', r => console.error('request failed:', r.url(), r.failure()?.errorText));
+ await page.goto(url);
+ // plain polling: waitForFunction never resolves in headless shell under WSL
+ for (let i = 0; !(await page.evaluate(() => document.body.dataset.ready || document.body.dataset.error)); i++) {
+ if (i > 300) { console.error('film page never got ready'); process.exit(1); }
+ await new Promise(r => setTimeout(r, 100));
+ }
+ const err = await page.evaluate(() => document.body.dataset.error);
+ if (err) { console.error(err); process.exit(1); }
+ return page;
+}
+const shoot = async (page, f) => { await page.evaluate(f => FILM.render(f), f); return page.screenshot({ type: 'png', clip: { x: 0, y: 0, width: W, height: H } }); };
+
+if (args.stills) {
+ const page = await openPage();
+ fs.mkdirSync(args.outdir || 'out', { recursive: true });
+ for (const f of args.stills.split(',').map(Number)) fs.writeFileSync(path.join(args.outdir || 'out', `f${String(f).padStart(4, '0')}.png`), await shoot(page, f));
+} else {
+ const out = args.out || `out/master-${W}x${H}.mp4`;
+ fs.mkdirSync(path.dirname(out), { recursive: true });
+ const pages = await Promise.all(Array.from({ length: JOBS }, openPage));
+ const FRAMES = await pages[0].evaluate(() => FILM.FRAMES);
+ // near-lossless 4:4:4 master; the web files are encoded from it (encode step in build.sh)
+ const ff = spawn('ffmpeg', ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', '60', '-c:v', 'png', '-i', '-',
+ '-c:v', 'libx264', '-preset', 'medium', '-crf', '4', '-pix_fmt', 'yuv444p', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-colorspace', 'bt709', out], { stdio: ['pipe', 'inherit', 'inherit'] });
+ const t0 = Date.now();
+ // JOBS pages render ahead in parallel; frames go to ffmpeg strictly in order
+ const pending = new Map(); let next = 0, written = 0;
+ const work = async (page, k) => { for (let f = k; f < FRAMES; f += JOBS) { while (f - written > JOBS * 4) await new Promise(r => setTimeout(r, 5)); pending.set(f, await shoot(page, f)); } };
+ const writer = (async () => {
+ while (next < FRAMES) {
+ if (!pending.has(next)) { await new Promise(r => setTimeout(r, 2)); continue; }
+ const buf = pending.get(next); pending.delete(next);
+ if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
+ written = ++next;
+ if (next % 120 === 0) console.log(`frame ${next}/${FRAMES} ${((Date.now() - t0) / 1000).toFixed(0)} s`);
+ }
+ })();
+ await Promise.all([...pages.map(work), writer]);
+ ff.stdin.end();
+ await new Promise(r => ff.on('close', r));
+ console.log(`done: ${out} in ${((Date.now() - t0) / 1000).toFixed(0)} s`);
+}
+await browser.close();
+server.close();