diff --git a/previews/invite-video/.gitignore b/previews/invite-video/.gitignore
new file mode 100644
index 0000000000..64ac77b0d9
--- /dev/null
+++ b/previews/invite-video/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+frames/
+.output/
+preview-*.png
+poster-picker.png
+previous-blue.html
+capture.cjs
diff --git a/previews/invite-video/README.md b/previews/invite-video/README.md
new file mode 100644
index 0000000000..f49a2347d8
--- /dev/null
+++ b/previews/invite-video/README.md
@@ -0,0 +1,39 @@
+# Invite preview media
+
+A 16-second silent Buzz animation and wordmark-only PNG fallback, with a browser tool for reviewing motion and choosing poster frames. The animation uses the onboarding wordmark and fuzzy shader, pale-blue/lime bees, diagonal paired flights and a solo loop-de-loop.
+
+## Preview
+
+From the repository root:
+
+```sh
+python3 -m http.server 4319 --bind 127.0.0.1 --directory previews/invite-video
+```
+
+Open http://127.0.0.1:4319. Scrub or choose a suggested frame, then select **Use this frame**. Poster choices persist only in that browser; **Download poster PNG** exports the choice. The committed poster is the approved wordmark-only frame at 0 seconds. The iMessage-style card is a composition simulation, not evidence of a native Messages unfurl.
+
+## Media
+
+- `buzz-invite-loop.mp4`: 960 × 540, 30 fps, H.264/yuv420p, fast-start, no audio.
+- `buzz-invite-poster.png`: 960 × 540, wordmark only.
+- `buzz-wordmark.png` and `wordmark.frag`: onboarding artwork and 400 ms grain/displacement shader, from the Buzz desktop onboarding implementation; bee silhouette matches the mobile welcome artwork.
+- Background: `#D7D72E`; bees: `#D7E7F5` and `#DBFA52`.
+
+## Verify or regenerate
+
+Activate the repository's Hermit environment first. Install Chrome, Python 3, and ffmpeg. Then:
+
+```sh
+cd previews/invite-video
+npm ci
+npm test
+npm run render
+```
+
+Verification starts an isolated localhost server and checks poster selection, PNG export, persistence, loop continuity, mobile overflow and reduced-motion startup. Rendering also rebuilds both committed media files. Temporary captures stay in `.output/`. `PREVIEW_BROWSER` may select another installed Playwright Chromium channel.
+
+## Integration boundary
+
+This PR prepares media and preview tooling. It does not change live invite HTML or deploy the relay. To enable real link previews, serve the media at public absolute HTTPS URLs and add `og:video` (direct MP4), `og:video:type` (`video/mp4`), dimensions, `og:image` (poster), title and description to the initial invite HTML. Keep generic branding on the public preview and never redeem an invite during a crawler fetch. Ship the relay image and verify an actual shared invite in the target clients.
+
+The receiving app chooses playback behavior. The 16-second duration is an animation choice, not an asserted platform limit.
diff --git a/previews/invite-video/buzz-invite-loop.mp4 b/previews/invite-video/buzz-invite-loop.mp4
new file mode 100644
index 0000000000..4331501c6d
Binary files /dev/null and b/previews/invite-video/buzz-invite-loop.mp4 differ
diff --git a/previews/invite-video/buzz-invite-poster.png b/previews/invite-video/buzz-invite-poster.png
new file mode 100644
index 0000000000..6556bd105a
Binary files /dev/null and b/previews/invite-video/buzz-invite-poster.png differ
diff --git a/previews/invite-video/buzz-wordmark.png b/previews/invite-video/buzz-wordmark.png
new file mode 100644
index 0000000000..a05e0fdb1c
Binary files /dev/null and b/previews/invite-video/buzz-wordmark.png differ
diff --git a/previews/invite-video/index.html b/previews/invite-video/index.html
new file mode 100644
index 0000000000..e411b62d41
--- /dev/null
+++ b/previews/invite-video/index.html
@@ -0,0 +1,110 @@
+
+
+
Buzz · Invite motion preview
+
+
+
+ Buzz
+ Motion study · 07
+
+ A little buzz in your messages.
+
+ The onboarding wordmark, its fuzzy texture, and a bee or two passing
+ through.
+
+
+
+ Loop preview
+
+
+ Pause 0.0 / 16s Show saved poster
+
+ Download MP4
+
+ Choose the fallback poster
+
+ Scrub the animation or try a frame below, then select “Use this
+ frame”. Your choice stays saved in this browser.
+
+
+
+
+
+
+
+
16 seconds Seamless loop
+
1-second breather Between bee passes
+
Silent 960 × 540
+
+
+
+ iMessage-style simulation
+
+
+
iMessage · Today 9:41 AM
+
Come join us on Buzz
+
+
+
+
Join your community on Buzz
+
community.example.com
+
+
+
+
+ Composition preview, not an actual iMessage unfurl. iMessage may show
+ the poster or require a tap; autoplay and looping are not guaranteed.
+
+
+
+
+
+
diff --git a/previews/invite-video/package-lock.json b/previews/invite-video/package-lock.json
new file mode 100644
index 0000000000..2a1152b22e
--- /dev/null
+++ b/previews/invite-video/package-lock.json
@@ -0,0 +1,42 @@
+{
+ "name": "buzz-invite-preview",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "buzz-invite-preview",
+ "devDependencies": {
+ "playwright": "1.65.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.65.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
+ "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.65.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.65.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
+ "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/previews/invite-video/package.json b/previews/invite-video/package.json
new file mode 100644
index 0000000000..bba7751d65
--- /dev/null
+++ b/previews/invite-video/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "buzz-invite-preview",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node verify.mjs",
+ "render": "node verify.mjs --render"
+ },
+ "devDependencies": {
+ "playwright": "1.65.0"
+ }
+}
diff --git a/previews/invite-video/preview.css b/previews/invite-video/preview.css
new file mode 100644
index 0000000000..78d541fcb0
--- /dev/null
+++ b/previews/invite-video/preview.css
@@ -0,0 +1,196 @@
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ background: #f6f5f2;
+ color: #231e1e;
+ font:
+ 15px -apple-system,
+ BlinkMacSystemFont,
+ Arial,
+ sans-serif;
+}
+main {
+ max-width: 1180px;
+ margin: auto;
+ padding: 44px 28px;
+}
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 44px;
+}
+.brand {
+ font-weight: 850;
+ font-size: 26px;
+ letter-spacing: -1px;
+}
+.badge {
+ font-size: 12px;
+ color: #666;
+ border: 1px solid #ddd;
+ border-radius: 30px;
+ padding: 8px 12px;
+}
+h1 {
+ font-size: 42px;
+ letter-spacing: -1.8px;
+ margin: 0 0 12px;
+}
+p {
+ color: #74716c;
+ line-height: 1.6;
+ margin: 0 0 24px;
+}
+.grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 330px;
+ gap: 44px;
+ align-items: start;
+}
+canvas {
+ display: block;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border-radius: 18px;
+ background: #d7d72e;
+}
+.controls {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin: 16px 0;
+ flex-wrap: wrap;
+}
+button,
+a.download {
+ font: inherit;
+ padding: 10px 15px;
+ border: 1px solid #d8d6d0;
+ border-radius: 9px;
+ background: white;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
+}
+button[aria-pressed="true"] {
+ background: #231e1e;
+ color: white;
+}
+input {
+ flex: 1;
+ min-width: 100px;
+ accent-color: #231e1e;
+}
+.caption {
+ font-size: 12px;
+}
+.phone {
+ border: 1px solid #ddd;
+ background: white;
+ border-radius: 35px;
+ padding: 22px 15px 30px;
+ box-shadow: 0 12px 35px #00000008;
+}
+.phone-top {
+ text-align: center;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 18px;
+ font-size: 13px;
+}
+.avatar {
+ margin: 12px auto 6px;
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background: #e9e8ed;
+ display: grid;
+ place-items: center;
+ font-size: 20px;
+}
+.date {
+ text-align: center;
+ font-size: 10px;
+ color: #898989;
+ margin: 23px 0;
+}
+.bubble {
+ margin-left: 32px;
+ background: #087eff;
+ color: white;
+ padding: 12px 15px;
+ border-radius: 18px;
+ font-size: 14px;
+ margin-bottom: 10px;
+}
+.card {
+ margin-left: 14px;
+ background: #eeeef0;
+ border-radius: 17px;
+ overflow: hidden;
+}
+.card canvas {
+ border-radius: 0;
+}
+.card-text {
+ padding: 12px;
+ font-size: 13px;
+}
+.host {
+ margin-top: 5px;
+ font-size: 11px;
+ color: #777;
+}
+.note {
+ font-size: 12px;
+ line-height: 1.6;
+ color: #85817b;
+ margin-top: 18px;
+}
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 1.4px;
+ font-size: 10px;
+ font-weight: 600;
+ color: #85817b;
+ margin-bottom: 14px;
+}
+.details {
+ display: flex;
+ gap: 25px;
+ border-top: 1px solid #dedcd5;
+ margin-top: 26px;
+ padding-top: 20px;
+ font-size: 12px;
+ color: #777;
+}
+.details strong {
+ display: block;
+ font-size: 15px;
+ color: #231e1e;
+ margin-bottom: 5px;
+}
+@media (max-width: 760px) {
+ main {
+ padding: 24px 18px;
+ }
+ header {
+ margin-bottom: 30px;
+ }
+ h1 {
+ font-size: 32px;
+ }
+ .grid {
+ grid-template-columns: 1fr;
+ gap: 28px;
+ }
+ .phone {
+ max-width: 340px;
+ margin: auto;
+ }
+ aside {
+ width: 100%;
+ }
+}
diff --git a/previews/invite-video/preview.js b/previews/invite-video/preview.js
new file mode 100644
index 0000000000..ae5d24e47b
--- /dev/null
+++ b/previews/invite-video/preview.js
@@ -0,0 +1,243 @@
+const hero = document.querySelector("#hero"),
+ ctx = hero.getContext("2d"),
+ small = document.querySelector("#small"),
+ sctx = small.getContext("2d");
+const path =
+ "M128 70.3 A91.7 91.7 0 1 0 128 238.7 Z M338 70.3 A91.7 91.7 0 1 1 338 238.7 Z M162 0 H304 A34 34 0 0 1 338 34 V275 A34 34 0 0 1 304 309 H162 A34 34 0 0 1 128 275 V34 A34 34 0 0 1 162 0 Z M220.3 84.4 A27 27 0 1 1 166.3 84.4 A27 27 0 1 1 220.3 84.4 Z M303 84.4 A27 27 0 1 1 249 84.4 A27 27 0 1 1 303 84.4 Z M171.3 157.2 H298.2 A5 5 0 0 1 303.2 162.2 V190.5 A5 5 0 0 1 298.2 195.5 H171.3 A5 5 0 0 1 166.3 190.5 V162.2 A5 5 0 0 1 171.3 157.2 Z M171.9 235.1 H298.1 A5 5 0 0 1 303.1 240.1 V267.7 A5 5 0 0 1 298.1 272.7 H171.9 A5 5 0 0 1 166.9 267.7 V240.1 A5 5 0 0 1 171.9 235.1 Z";
+const bee = new Path2D(path),
+ mark = new Image();
+mark.src = "buzz-wordmark.png";
+const logo = document.createElement("canvas");
+logo.width = 640;
+logo.height = Math.round((640 * 326) / 777);
+const gl = logo.getContext("webgl", {
+ alpha: true,
+ premultipliedAlpha: true,
+ preserveDrawingBuffer: true,
+});
+let program, phase;
+async function setupLogo() {
+ await mark.decode();
+ if (!gl) return;
+ const fragment = await (await fetch("wordmark.frag")).text();
+ const compile = (type, source) => {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
+ throw Error(gl.getShaderInfoLog(shader));
+ return shader;
+ };
+ program = gl.createProgram();
+ gl.attachShader(
+ program,
+ compile(
+ gl.VERTEX_SHADER,
+ "attribute vec2 position; void main(){gl_Position=vec4(position,0.0,1.0);}",
+ ),
+ );
+ gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment));
+ gl.linkProgram(program);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
+ throw Error(gl.getProgramInfoLog(program));
+ gl.useProgram(program);
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
+ gl.STATIC_DRAW,
+ );
+ const pos = gl.getAttribLocation(program, "position");
+ gl.enableVertexAttribArray(pos);
+ gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
+ gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ const source = document.createElement("canvas");
+ source.width = mark.naturalWidth;
+ source.height = mark.naturalHeight;
+ source.getContext("2d").drawImage(mark, 0, 0);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ gl.viewport(0, 0, logo.width, logo.height);
+ gl.uniform2f(
+ gl.getUniformLocation(program, "uSize"),
+ logo.width,
+ logo.height,
+ );
+ gl.uniform4f(
+ gl.getUniformLocation(program, "uColor"),
+ 35 / 255,
+ 30 / 255,
+ 30 / 255,
+ 1,
+ );
+ phase = gl.getUniformLocation(program, "uPhase");
+}
+
+let posterTime = Number(localStorage.getItem("buzz-invite-poster-time") ?? "0");
+if (!Number.isFinite(posterTime) || posterTime < 0 || posterTime >= 16)
+ posterTime = 0;
+let t = 0,
+ playing = !matchMedia("(prefers-reduced-motion: reduce)").matches,
+ poster = false,
+ last = performance.now();
+function fly(time, start, direction, y, pair = false, endY = y) {
+ const phase = (time - start + 16) % 16;
+ if (phase > 2.6) return;
+ const u = phase / 2.6,
+ x = direction > 0 ? -90 + 1140 * u : 1050 - 1140 * u;
+ const yy =
+ y +
+ (endY - y) * u +
+ 45 * Math.sin(u * Math.PI * 2) +
+ 16 * Math.sin(u * Math.PI * 4);
+ const dx = direction * 1140,
+ dy =
+ endY -
+ y +
+ 90 * Math.PI * Math.cos(u * Math.PI * 2) +
+ 64 * Math.PI * Math.cos(u * Math.PI * 4);
+ ctx.save();
+ ctx.translate(x, yy);
+ ctx.rotate(Math.atan2(dy, dx) + Math.PI / 2);
+ const scale = (pair ? 60 : 73) / 466;
+ ctx.scale(scale * (1 + 0.06 * Math.sin(time * Math.PI * 20)), scale);
+ ctx.translate(-233, -154.5);
+ ctx.fillStyle = pair || start === 8.4 ? "#dbfa52" : "#d7e7f5";
+ ctx.fill(bee, "evenodd");
+ ctx.restore();
+}
+// Arc-length sampling keeps the solo bee's speed steady through the loop.
+const loopPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
+loopPath.setAttribute(
+ "d",
+ "M -90 150 C 100 150 220 100 350 100 C 500 100 510 255 400 250 C 300 245 320 110 450 110 C 650 110 800 180 1050 180",
+);
+const loopLength = loopPath.getTotalLength();
+function loopingBee(time) {
+ if (time > 3.6) return;
+ const distance = (time / 3.6) * loopLength;
+ const p = loopPath.getPointAtLength(distance),
+ a = loopPath.getPointAtLength(Math.max(0, distance - 1)),
+ b = loopPath.getPointAtLength(Math.min(loopLength, distance + 1));
+ ctx.save();
+ ctx.translate(p.x, p.y);
+ ctx.rotate(Math.atan2(b.y - a.y, b.x - a.x) + Math.PI / 2);
+ const scale = 73 / 466;
+ ctx.scale(scale * (1 + 0.06 * Math.sin(time * Math.PI * 20)), scale);
+ ctx.translate(-233, -154.5);
+ ctx.fillStyle = "#d7e7f5";
+ ctx.fill(bee, "evenodd");
+ ctx.restore();
+}
+window.renderAt = (time) => {
+ const q = ((time % 16) + 16) % 16;
+ ctx.fillStyle = "#d7d72e";
+ ctx.fillRect(0, 0, 960, 540);
+ if (gl && program) {
+ gl.uniform1f(phase, (q % 0.4) / 0.4);
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+ ctx.drawImage(logo, 160, (540 - logo.height) / 2);
+ } else {
+ ctx.drawImage(mark, 160, (540 - logo.height) / 2, 640, logo.height);
+ }
+ loopingBee(q);
+ fly(q, 4.6, -1, 460, false, 90);
+ fly(q, 4.8, -1, 505, true, 160);
+ fly(q, 8.4, 1, 70, false, 480);
+ fly(q, 12, -1, 120, false, 360);
+ fly(q, 12.2, -1, 165, true, 435);
+ sctx.drawImage(hero, 0, 0);
+};
+function state() {
+ document.querySelector("#play").textContent = playing ? "Pause" : "Play";
+ document.querySelector("#poster").setAttribute("aria-pressed", poster);
+}
+document.querySelector("#play").onclick = () => {
+ playing = !playing;
+ poster = false;
+ state();
+};
+document.querySelector("#poster").onclick = () => {
+ poster = !poster;
+ if (poster) {
+ playing = false;
+ t = posterTime;
+ renderAt(t);
+ }
+ state();
+};
+document.querySelector("#seek").oninput = (e) => {
+ t = Number(e.target.value);
+ playing = false;
+ poster = false;
+ renderAt(t);
+ state();
+};
+function savePoster(time) {
+ posterTime = Math.round(time * 30) / 30;
+ if (posterTime >= 16) posterTime = 0;
+ renderAt(posterTime);
+ const data = hero.toDataURL("image/png");
+ document.querySelector("#poster-image").src = data;
+ document.querySelector("#download-poster").href = data;
+ document.querySelector("#poster-status").textContent =
+ `Saved poster: ${posterTime.toFixed(2)}s · 960 × 540`;
+ localStorage.setItem("buzz-invite-poster-time", String(posterTime));
+}
+document.querySelector("#save-poster").onclick = () => {
+ playing = false;
+ poster = true;
+ savePoster(t);
+ t = posterTime;
+ state();
+};
+function posterChoices() {
+ for (const [time, label] of [
+ [0, "Wordmark only"],
+ [1, "Solo bee"],
+ [5.9, "Pair crossing"],
+ [13.1, "Diagonal pair"],
+ ]) {
+ renderAt(time);
+ const button = document.createElement("button");
+ button.style.padding = "5px";
+ button.innerHTML =
+ '' +
+ label +
+ " ";
+ button.onclick = () => {
+ t = time;
+ playing = false;
+ poster = false;
+ renderAt(t);
+ state();
+ };
+ document.querySelector("#choices").append(button);
+ }
+ savePoster(posterTime);
+}
+setupLogo()
+ .then(() => {
+ posterChoices();
+ window.ready = true;
+ renderAt(0);
+ state();
+ requestAnimationFrame(function frame(now) {
+ if (playing && !window.captureMode) t = (t + (now - last) / 1000) % 16;
+ last = now;
+ if (!window.captureMode) renderAt(t);
+ document.querySelector("#seek").value = t;
+ document.querySelector("#time").textContent = `${t.toFixed(1)} / 16s`;
+ requestAnimationFrame(frame);
+ });
+ })
+ .catch((error) => {
+ console.error(error);
+ document.querySelector(".caption")?.remove();
+ });
diff --git a/previews/invite-video/verify.mjs b/previews/invite-video/verify.mjs
new file mode 100644
index 0000000000..74b636ace8
--- /dev/null
+++ b/previews/invite-video/verify.mjs
@@ -0,0 +1,135 @@
+import assert from "node:assert/strict";
+import { spawn, spawnSync } from "node:child_process";
+import { mkdir, rm, writeFile } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { chromium } from "playwright";
+
+const root = fileURLToPath(new URL(".", import.meta.url));
+const render = process.argv.includes("--render");
+const server = spawn(
+ "python3",
+ ["-u", "-m", "http.server", "0", "--bind", "127.0.0.1", "--directory", root],
+ { stdio: ["ignore", "pipe", "pipe"] },
+);
+let browser;
+try {
+ const port = await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error("Preview server did not start")),
+ 10000,
+ );
+ server.on("error", reject);
+ server.on("exit", (code) => reject(new Error(`Server exited: ${code}`)));
+ server.stdout.on("data", (data) => {
+ const match = data.toString().match(/port (\d+)/);
+ if (match) {
+ clearTimeout(timer);
+ resolve(Number(match[1]));
+ }
+ });
+ });
+ browser = await chromium.launch({
+ channel: process.env.PREVIEW_BROWSER || "chrome",
+ headless: true,
+ });
+ const page = await browser.newPage({
+ viewport: { width: 1180, height: 1000 },
+ });
+ const errors = [];
+ page.on("pageerror", (error) => errors.push(error.message));
+ await page.goto(`http://127.0.0.1:${port}`);
+ await page.waitForFunction(() => window.ready);
+ await page.getByRole("button", { name: "Pair crossing" }).click();
+ await page.locator("#save-poster").click();
+ await page.waitForFunction(
+ () => Number(document.querySelector("#seek").value) === 5.9,
+ );
+ const poster = await page.locator("#poster-image").getAttribute("src");
+ assert.ok(poster.startsWith("data:image/png"));
+ await page.reload();
+ await page.waitForFunction(() => window.ready);
+ assert.match(await page.locator("#poster-status").innerText(), /5.90s/);
+ await page.locator("#poster").click();
+ await page.waitForFunction(
+ () => Number(document.querySelector("#seek").value) === 5.9,
+ );
+ assert.equal(await page.locator("#poster-image").getAttribute("src"), poster);
+ await page
+ .getByRole("button", { name: "Wordmark only", exact: true })
+ .click();
+ await page.locator("#save-poster").click();
+ await page.evaluate(() => {
+ window.captureMode = true;
+ window.renderAt(0);
+ });
+ const frame = (time) =>
+ page.evaluate((time) => {
+ window.renderAt(time);
+ return document.querySelector("#hero").toDataURL("image/png");
+ }, time);
+ assert.equal(await frame(0), await frame(16), "Loop seam");
+ assert.notEqual(await frame(1), await frame(5.9), "Flight frames differ");
+ await mkdir(`${root}.output`, { recursive: true });
+ await frame(1);
+ await page.screenshot({ path: `${root}.output/desktop.png`, fullPage: true });
+ await page.setViewportSize({ width: 390, height: 844 });
+ assert.equal(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth > innerWidth,
+ ),
+ false,
+ );
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.reload();
+ await page.waitForFunction(() => window.ready);
+ assert.equal(await page.locator("#play").innerText(), "Play");
+ assert.deepEqual(errors, []);
+ if (render) {
+ await page.evaluate(() => {
+ window.captureMode = true;
+ });
+ const png = (data) => Buffer.from(data.split(",")[1], "base64");
+ await writeFile(`${root}buzz-invite-poster.png`, png(await frame(0)));
+ // Clear frames from earlier renders so ffmpeg encodes exactly 480.
+ await rm(`${root}.output/frames`, { recursive: true, force: true });
+ await mkdir(`${root}.output/frames`, { recursive: true });
+ for (let i = 0; i < 480; i++) {
+ await writeFile(
+ `${root}.output/frames/${String(i).padStart(4, "0")}.png`,
+ png(await frame(i / 30)),
+ );
+ }
+ const result = spawnSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-framerate",
+ "30",
+ "-i",
+ `${root}.output/frames/%04d.png`,
+ "-c:v",
+ "libx264",
+ "-crf",
+ "20",
+ "-pix_fmt",
+ "yuv420p",
+ "-movflags",
+ "+faststart",
+ "-an",
+ `${root}buzz-invite-loop.mp4`,
+ ],
+ { stdio: "inherit" },
+ );
+ assert.equal(result.status, 0, "Video encoding");
+ }
+ console.log(
+ "PASS: poster selection/export/persistence, loop seam, mobile layout, reduced motion, JavaScript errors" +
+ (render ? ", media export" : ""),
+ );
+} finally {
+ await browser?.close();
+ server.kill();
+}
diff --git a/previews/invite-video/wordmark.frag b/previews/invite-video/wordmark.frag
new file mode 100644
index 0000000000..83868e1356
--- /dev/null
+++ b/previews/invite-video/wordmark.frag
@@ -0,0 +1,72 @@
+// Port of mobile/shaders/onboarding_wordmark.frag. Same noise and 400ms phase.
+precision highp float;
+
+uniform vec2 uSize;
+uniform float uPhase;
+uniform vec4 uColor;
+uniform sampler2D uWordmark;
+
+
+float hash(vec2 p, float seed) {
+ return fract(sin(dot(p, vec2(127.1, 311.7)) + seed * 17.0) * 43758.5453);
+}
+
+float noise(vec2 p, float seed) {
+ vec2 cell = floor(p);
+ vec2 f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(cell, seed), hash(cell + vec2(1, 0), seed), f.x),
+ mix(hash(cell + vec2(0, 1), seed), hash(cell + vec2(1, 1), seed), f.x), f.y);
+}
+
+float fractalNoise(vec2 p, float seed) {
+ float value = 0.0;
+ float weight = 0.5333333;
+ for (int i = 0; i < 4; i++) {
+ value += weight * noise(p, seed);
+ p *= 2.0;
+ weight *= 0.5;
+ }
+ return value;
+}
+
+float seedAt(float step) {
+ if (step < 1.0) return 7.0;
+ if (step < 2.0) return 19.0;
+ if (step < 3.0) return 11.0;
+ if (step < 4.0) return 25.0;
+ return 7.0;
+}
+
+// A small Gaussian kernel broadens the fuzzy edge at phone size without
+// adding a separate blur pass or softening the surrounding onboarding UI.
+float softAlpha(vec2 uv) {
+ vec2 d = vec2(9.0) / vec2(777.0, 326.0);
+ float alpha = texture2D(uWordmark, uv).a * 0.25;
+ alpha += (texture2D(uWordmark, uv + vec2(d.x, 0.0)).a
+ + texture2D(uWordmark, uv - vec2(d.x, 0.0)).a
+ + texture2D(uWordmark, uv + vec2(0.0, d.y)).a
+ + texture2D(uWordmark, uv - vec2(0.0, d.y)).a) * 0.125;
+ alpha += (texture2D(uWordmark, uv + d).a
+ + texture2D(uWordmark, uv - d).a
+ + texture2D(uWordmark, uv + vec2(d.x, -d.y)).a
+ + texture2D(uWordmark, uv + vec2(-d.x, d.y)).a) * 0.0625;
+ return alpha;
+}
+
+void main() {
+ vec2 uv = vec2(gl_FragCoord.x, uSize.y - gl_FragCoord.y) / uSize;
+ vec2 p = uv * vec2(777.0, 326.0) * 0.68;
+ float phase = uPhase * 4.0;
+ float a = seedAt(floor(phase));
+ float b = seedAt(floor(phase) + 1.0);
+ float blend = smoothstep(0.0, 1.0, fract(phase));
+ vec2 textureNoise = mix(
+ vec2(fractalNoise(p, a), fractalNoise(p + 43.7, a)),
+ vec2(fractalNoise(p, b), fractalNoise(p + 43.7, b)), blend);
+ vec2 displaced = uv + (textureNoise - 0.5) * 14.4 / vec2(777.0, 326.0);
+ float alpha = softAlpha(clamp(displaced, 0.0, 1.0));
+ float grain = clamp((textureNoise.x + textureNoise.y) * 0.99, 0.0, 1.0);
+ alpha = (alpha + alpha * grain * (1.0 - alpha)) * uColor.a;
+ gl_FragColor = vec4(uColor.rgb * alpha, alpha);
+}