Add invite preview video and fallback poster (#9282)

Adds a 16-second Buzz invite-preview video and wordmark-only poster,
matching onboarding colors and fuzzy texture. Includes a local
preview/poster picker and reproducible media export. This prepares
assets; live invite Open Graph metadata and relay deployment are
follow-up work.

Validated: preview browser checks and independent review pass. Full
`just ci` stopped during Rust compilation because the disk filled.
Native iMessage playback is not yet tested.

Video:


https://github.com/user-attachments/assets/8977f990-fb79-4217-bec1-fa8d2b93ece0

Fallback poster:

![Wordmark-only fallback
poster](https://github.com/user-attachments/assets/b1c5754a-2bb7-4540-b797-102bc57c5e15)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
klopez4212
2026-11-09 18:35:12 +00:00
committed by GitHub
parent 3071279cd8
commit 838976d6e4
12 changed files with 856 additions and 0 deletions

7
previews/invite-video/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
frames/
.output/
preview-*.png
poster-picker.png
previous-blue.html
capture.cjs

View File

@@ -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.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

View File

@@ -0,0 +1,110 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8" /><meta
name="viewport"
content="width=device-width,initial-scale=1"
/><title>Buzz · Invite motion preview</title>
<link rel="stylesheet" href="preview.css" />
<main>
<header>
<div class="brand">Buzz</div>
<span class="badge">Motion study · 07</span>
</header>
<h1>A little buzz in your messages.</h1>
<p>
The onboarding wordmark, its fuzzy texture, and a bee or two passing
through.
</p>
<div class="grid">
<section>
<div class="eyebrow">Loop preview</div>
<canvas
id="hero"
width="960"
height="540"
role="img"
aria-label="Fuzzy Buzz wordmark with blue and lime bees on chartreuse"
></canvas>
<div class="controls">
<button type="button" id="play">Pause</button
><input
id="seek"
type="range"
min="0"
max="16"
step="0.01"
value="0"
aria-label="Animation time"
/><span id="time">0.0 / 16s</span
><button type="button" id="poster" aria-pressed="false">Show saved poster</button>
</div>
<a class="download" href="buzz-invite-loop.mp4" download
>Download MP4</a
>
<section style="margin-top: 28px">
<div class="eyebrow">Choose the fallback poster</div>
<p class="caption">
Scrub the animation or try a frame below, then select “Use this
frame”. Your choice stays saved in this browser.
</p>
<div
id="choices"
style="
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
"
></div>
<div class="controls">
<button type="button" id="save-poster">Use this frame</button
><a
id="download-poster"
class="download"
download="buzz-invite-poster.png"
>Download poster PNG</a
>
</div>
<p class="caption" id="poster-status" aria-live="polite"></p>
<img
id="poster-image"
alt="Selected fallback poster"
style="width: 100%; border-radius: 14px"
/>
</section>
<div class="details">
<div><strong>16 seconds</strong>Seamless loop</div>
<div><strong>1-second breather</strong>Between bee passes</div>
<div><strong>Silent</strong>960 × 540</div>
</div>
</section>
<aside>
<div class="eyebrow">iMessage-style simulation</div>
<div class="phone">
<div class="phone-top">
<div class="avatar">🐝</div>
Your friend
</div>
<div class="date">iMessage · Today 9:41 AM</div>
<div class="bubble">Come join us on Buzz</div>
<div class="card">
<canvas
id="small"
width="960"
height="540"
aria-label="Simulated animated link preview"
></canvas>
<div class="card-text">
<strong>Join your community on Buzz</strong>
<div class="host">community.example.com</div>
</div>
</div>
</div>
<p class="note">
Composition preview, not an actual iMessage unfurl. iMessage may show
the poster or require a tap; autoplay and looping are not guaranteed.
</p>
</aside>
</div>
</main>
<script src="preview.js"></script>
</html>

42
previews/invite-video/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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%;
}
}

View File

@@ -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 =
'<img alt="" style="width:100%;border-radius:5px" src="' +
hero.toDataURL("image/png") +
'"><span style="font-size:11px">' +
label +
"</span>";
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();
});

View File

@@ -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();
}

View File

@@ -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);
}