Files
buzz/previews/invite-video/preview.js
klopez4212 838976d6e4 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>
2026-11-09 10:35:12 -08:00

244 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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