jacooob521's picture
3D 3b1b-style ML clips + water-drop flow + seamless backgrounds
5de81d5 verified
Raw
History Blame Contribute Delete
19.7 kB
/* manimo playground — loads every per-paper manifest from data/ and renders the gallery of testers.
* Each paper is themed with the SAME palette as its video; each animated concept gets its interactive
* tester (from MANIMO.registry, by primitive) so you can run the very computation the video explains.
*
* One click → the whole paper plays: a "Play all" bar walks every concept in storyboard order, scrolls
* to it, lights it up, runs its animation to completion, then advances — a web mirror of the Manim video
* (same params, same math, same order), so the page and the video tell the identical story. */
(function () {
const k = MANIMO.kit;
const DEFAULT_THEME = { bg: "#0E1116", ink: "#E8EDF3", muted: "#8B95A1", accent: "#58A6FF", accent2: "#BC8CFF" };
async function getJSON(url) { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) throw new Error(url + " " + r.status); return r.json(); }
function applyTheme(node, theme) {
const t = Object.assign({}, DEFAULT_THEME, theme || {});
node.style.setProperty("--bg", t.bg); node.style.setProperty("--ink", t.ink);
node.style.setProperty("--muted", t.muted); node.style.setProperty("--accent", t.accent);
node.style.setProperty("--accent2", t.accent2); node.style.setProperty("--card", k.mix(t.bg, t.ink, 0.06));
return t;
}
function widgetFor(concept) {
return MANIMO.registry[concept.widget] || MANIMO.registry[concept.primitive] || MANIMO.registry["_generic"];
}
// ── Fullscreen + "theater" big-screen mode ─────────────────────────────────────────────────────
// Play-all enters a theater layout (nav/topbar fold away, the playing card fills the screen so the
// animation is large and legible) and best-effort requests real OS fullscreen. Theater is a CSS class
// so it still applies when the Fullscreen API is blocked (e.g. the "#play" autostart has no user
// gesture); fullscreen, when granted, just makes it edge-to-edge.
function fsElement() { return document.fullscreenElement || document.webkitFullscreenElement || null; }
function requestFS() {
const el = document.documentElement;
const fn = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen;
if (!fn) return;
try { const r = fn.call(el); if (r && r.catch) r.catch(() => {}); } catch (e) { /* blocked → theater still applies */ }
}
function exitFS() {
if (!fsElement()) return;
const fn = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
if (!fn) return;
try { const r = fn.call(document); if (r && r.catch) r.catch(() => {}); } catch (e) { /* noop */ }
}
// ── Narration: give the playground a VOICE with the browser's built-in speech synthesis (no files,
// no deps — the buildless way to add audio). Each concept's plain-language explainer is spoken as it
// plays. Triggered by the Play-all click (a real user gesture) so browsers allow it; the autostart
// "#play" link may be muted by autoplay policy, exactly like the fullscreen request. ──
const SPEECH = (typeof window !== "undefined" && window.speechSynthesis) || null;
let narrOn = !!SPEECH;
function pickVoice() {
if (!SPEECH) return null;
const vs = SPEECH.getVoices() || [];
return vs.find((v) => /^en[-_]?US/i.test(v.lang)) || vs.find((v) => /^en/i.test(v.lang)) || vs[0] || null;
}
function speak(text, onEnd) {
if (!SPEECH || !narrOn || !text) { if (onEnd) onEnd(); return; }
try {
SPEECH.cancel(); // never let two lines overlap
const u = new SpeechSynthesisUtterance(String(text).replace(/\s+/g, " ").trim().slice(0, 320));
const v = pickVoice(); if (v) { u.voice = v; u.lang = v.lang; }
u.rate = 0.98; u.pitch = 1.0;
if (onEnd) { u.onend = onEnd; u.onerror = onEnd; } // tell the transport when the spoken line finishes
SPEECH.speak(u);
} catch (e) { if (onEnd) onEnd(); } // speech blocked → don't stall the sequence
}
function hush() { if (SPEECH) { try { SPEECH.cancel(); } catch (e) { /* noop */ } } }
if (SPEECH) { try { SPEECH.getVoices(); SPEECH.addEventListener("voiceschanged", () => {}); } catch (e) { /* noop */ } }
// The water-drop flow rail: a glowing teardrop descends a vertical stream, one station per slide. On each
// slide the drop FALLS to that slide's station (a gravity-eased transition), LANDS with a ripple while
// the slide's video plays, then falls on to the next — a literal "water flowing down the page, pausing
// to explain each slide, playing its video, then falling to the next". The fill above the drop = progress.
function buildFlow(n) {
const TOP = 4, BOT = 96, FALL_MS = 720; // FALL_MS must match the CSS .flow-drop transition
const yOf = (i) => (n <= 1 ? 50 : TOP + (BOT - TOP) * (i / (n - 1)));
const stream = k.el("div", { class: "flow-stream" });
const fillv = k.el("div", { class: "flow-fill" });
const ripple = k.el("div", { class: "flow-ripple" });
const drop = k.el("div", { class: "flow-drop" }, [k.el("div", { class: "bulb" })]);
const rail = k.el("div", { class: "flowrail", "aria-hidden": "true" }, [stream, fillv]);
const stations = [];
for (let i = 0; i < n; i++) {
const st = k.el("i", { class: "flow-station" });
st.style.top = yOf(i) + "%";
stations.push(st); rail.appendChild(st);
}
rail.appendChild(ripple); rail.appendChild(drop);
let landT = null, postT = null;
function clear() { if (landT) clearTimeout(landT); if (postT) clearTimeout(postT); landT = postT = null; }
return {
el: rail,
to(i) { // drop falls to slide i, then lands + ripples
const y = yOf(i);
clear();
drop.classList.add("falling");
drop.style.top = y + "%";
fillv.style.height = Math.max(0, y - TOP) + "%";
ripple.style.top = y + "%";
stations.forEach((s, j) => { s.classList.toggle("done", j < i); s.classList.toggle("active", j === i); });
landT = setTimeout(() => {
drop.classList.remove("falling"); drop.classList.add("land");
ripple.classList.remove("go"); void ripple.offsetWidth; ripple.classList.add("go"); // restart ripple
postT = setTimeout(() => drop.classList.remove("land"), 520);
}, FALL_MS);
},
reset() {
clear();
drop.classList.remove("falling", "land"); drop.style.top = TOP + "%";
fillv.style.height = "0%";
stations.forEach((s) => s.classList.remove("done", "active"));
},
atTop: TOP,
};
}
// The "Play all" transport: drives every concept's player in order, like scrubbing through the video —
// but paced for a human: each concept settles into view, plays, then HOLDS so you can take it all in.
function buildTransport(players) {
const SETTLE_MS = 650; // let the scroll + spotlight land before the animation starts
const HOLD_MS = 2000; // comprehension pause after each concept finishes, before advancing
let running = false, cancelled = false, idx = -1, timer = null, speechTimer = null;
const fill = k.el("i", { class: "fill" });
const rail = k.el("div", { class: "rail" }, [fill]);
const caption = k.el("span", { class: "cap" }, ["Press play to watch + hear the whole paper, step by step — full screen, narrated."]);
const count = k.el("span", { class: "count" }, ["0 / " + players.length]);
const playBtn = k.el("button", { class: "btn play", onclick: start }, ["▶ Play all"]);
const stopBtn = k.el("button", { class: "btn ghost", onclick: stop }, ["⏹ Stop"]);
stopBtn.disabled = true;
const muteBtn = k.el("button", { class: "btn ghost mute", onclick: toggleNarr, title: "Narration on/off" }, [narrOn ? "🔊" : "🔇"]);
if (!SPEECH) { muteBtn.disabled = true; muteBtn.title = "Narration not supported in this browser"; }
const bar = k.el("div", { class: "transport" }, [
k.el("div", { class: "tbtns" }, [playBtn, stopBtn, muteBtn]),
k.el("div", { class: "tmeta" }, [k.el("div", { class: "caprow" }, [caption, count]), rail]),
]);
const flow = buildFlow(players.length); // the descending water-drop, one station per slide
bar.appendChild(flow.el); // position:fixed → floats over the theater stage
function toggleNarr() {
narrOn = !narrOn;
muteBtn.textContent = narrOn ? "🔊" : "🔇";
hush(); // stop any browser TTS
document.querySelectorAll("video.manim-hero").forEach((v) => { v.muted = !narrOn; }); // mute/unmute clip audio
}
function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } if (speechTimer) { clearTimeout(speechTimer); speechTimer = null; } }
function later(fn, ms) { clearTimer(); timer = setTimeout(fn, ms); }
function progress(done) { fill.style.width = (players.length ? (100 * done / players.length) : 0) + "%"; }
function spotlight(i) {
players.forEach((p, j) => p.card.classList.toggle("playing", i === j));
if (i >= 0 && players[i]) players[i].card.scrollIntoView({ behavior: "smooth", block: "center" });
}
function enterTheater() { document.body.classList.add("theater"); requestFS(); }
function exitTheater() { document.body.classList.remove("theater"); exitFS(); }
function teardown(msg, ok) {
running = false; cancelled = false; idx = -1; clearTimer(); hush();
playBtn.disabled = false; stopBtn.disabled = true;
players.forEach((p) => p.card.classList.remove("playing"));
flow.reset();
exitTheater();
caption.textContent = msg; caption.classList.toggle("ok", !!ok);
}
function step() {
if (!running || cancelled) return;
idx++;
if (idx >= players.length) { progress(players.length); teardown("Finished — that's the whole paper. ▶ replay or edit any input.", true); return; }
const p = players[idx];
caption.classList.remove("ok");
caption.textContent = "Now playing — " + p.name;
count.textContent = (idx + 1) + " / " + players.length;
progress(idx);
spotlight(idx); // light it up + scroll, THEN settle before playing
flow.to(idx); // the drop falls to this slide + lands with a ripple
// A slide is finished only when BOTH its animation has played out AND its spoken explanation has
// been said in full — whichever takes longer. That's the fix for the "cut short" feeling: narration
// (~8–12s/concept) used to be chopped off when the shorter visual + hold advanced the slide.
let visualDone = false, speechDone = false, advanced = false, narrated = false;
function advance() {
if (advanced || !running || cancelled || !visualDone || !speechDone) return;
advanced = true;
caption.textContent = "Take it in — " + p.name;
later(step, narrated ? 800 : HOLD_MS); // narration already gave dwell time; else a full hold
}
function onSpeechEnd() {
if (speechDone) return;
speechDone = true;
if (speechTimer) { clearTimeout(speechTimer); speechTimer = null; }
advance();
}
// Clip slides carry their own baked-in Kokoro narration on the video's audio track; only fall back to
// the browser's Web Speech voice for concepts with NO clip (e.g. pipeline runs without rendered clips).
if (narrOn && SPEECH && !p.hasClip && (p.say || p.name)) {
narrated = true;
speak(p.say || p.name, onSpeechEnd);
const words = String(p.say || p.name).split(/\s+/).length;
speechTimer = setTimeout(onSpeechEnd, Math.min(24000, words * 430 + 4000)); // safety if no 'end' event
} else {
speechDone = true;
}
later(() => {
if (!running || cancelled) return;
try {
p.player.reset();
p.player.play(() => { if (!running || cancelled) return; visualDone = true; progress(idx + 1); advance(); });
} catch (e) { visualDone = true; progress(idx + 1); advance(); }
}, SETTLE_MS);
}
function start() {
if (running) return;
running = true; cancelled = false; idx = -1;
playBtn.disabled = true; stopBtn.disabled = false;
caption.classList.remove("ok");
enterTheater(); // go big-screen the instant Play all is pressed
flow.reset(); // drop at the top of the stream, ready to fall
step();
}
function stop() {
if (!running) return;
cancelled = true; clearTimer();
if (idx >= 0 && players[idx]) { try { players[idx].player.stop(); } catch (e) { /* noop */ } }
teardown("Stopped. ▶ Play all to watch it again.", false);
}
// Exiting fullscreen (ESC) while playing ends the run cleanly and drops the theater layout.
const onFsChange = () => { if (!fsElement() && running) stop(); };
document.addEventListener("fullscreenchange", onFsChange);
document.addEventListener("webkitfullscreenchange", onFsChange);
bar.start = start; // expose so a "#play" link can auto-run the whole paper
return bar;
}
// A connector strip drawn between two interconnected cards: the upstream value flows into this one.
function connector(key, fromTitle) {
return k.el("div", { class: "flowlink" }, [
k.el("span", { class: "flow-dot" }, []),
k.el("span", { class: "flow-var" }, [key]),
k.el("span", { class: "flow-txt" }, ["flows in from " + fromTitle]),
]);
}
// Drive a Manim-clip <video> as a transport "player": play it to the end, then signal done. Robust to a
// missing/blocked clip (resolves anyway) so a broken clip can never stall the Play-all sequence.
function videoPlayer(video) {
let cap = null;
const clearCap = () => { if (cap) { clearTimeout(cap); cap = null; } };
return {
play(done) {
const finish = () => { clearCap(); video.onended = null; video.onerror = null; if (done) done(); };
try {
try { video.currentTime = 0; } catch (e) { /* not seekable yet */ }
video.onended = finish;
video.onerror = finish;
// play WITH sound (the narration is baked into the clip); if the browser blocks unmuted autoplay,
// retry muted so the visual still runs (narration just won't be heard on that strict browser).
const attempt = (muted) => {
video.muted = muted;
const pr = video.play();
if (pr && pr.catch) pr.catch(() => (muted ? finish() : attempt(true)));
};
attempt(!narrOn);
cap = setTimeout(finish, 60000); // hard safety cap so a stuck clip never hangs
} catch (e) { finish(); }
},
stop() { clearCap(); try { video.pause(); } catch (e) { /* noop */ } },
reset() { clearCap(); try { video.pause(); video.currentTime = 0; } catch (e) { /* noop */ } },
};
}
function renderPaper(main, manifest) {
k.clear(main);
MANIMO.bus.clear(); // fresh data bus per paper; widgets re-wire as they mount in order
const theme = applyTheme(main, manifest.theme);
main.appendChild(k.el("div", { class: "paper-head" }, [
k.el("h1", null, [manifest.title || "Paper"]),
k.el("p", { class: "sub" }, [manifest.subtitle || ""]),
]));
const concepts = manifest.concepts || [];
if (!concepts.length) { main.appendChild(k.el("p", { class: "empty" }, ["No animated concepts in this paper."])); return; }
const players = [], nodes = [], produced = {};
concepts.forEach((c, i) => {
const w = widgetFor(c);
const name = c.title || c.label || w.title;
const io = c.io || {};
// mounting order = manifest order, so an upstream producer has already published before this mounts.
if (io.consumes && produced[io.consumes]) nodes.push(connector(io.consumes, produced[io.consumes]));
const card = k.el("section", { class: "card" });
card.appendChild(k.el("div", { class: "card-h" }, [
k.el("span", { class: "card-n" }, [String(i + 1).padStart(2, "0")]),
k.el("span", { class: "card-t" }, [name]),
k.el("span", { class: "tag" }, [w.title]),
]));
const body = k.el("div", { class: "card-b" });
card.appendChild(body);
// The hero is the real Manim clip (same params as the video); the interactive widget sits below as
// "try it yourself". Play-all plays the Manim clip; exit theater to tinker with the live widget.
let hero = null;
if (c.clip) {
const video = k.el("video", {
class: "manim-hero", src: c.clip, preload: "metadata", playsinline: "",
controls: "", "aria-label": name + " — narrated Manim animation",
});
video.muted = !narrOn; // the Kokoro narration is baked into the clip's audio
body.appendChild(k.el("div", { class: "hero-wrap" }, [video]));
body.appendChild(k.el("div", { class: "tryit" }, ["try it yourself"]));
hero = videoPlayer(video);
}
const stage = k.el("div", { class: "widget-body" });
body.appendChild(stage);
let widget = null;
try { widget = w.mount(stage, c, theme); } catch (e) { stage.appendChild(k.el("p", { class: "err" }, ["widget error: " + e.message])); }
if (io.produces) produced[io.produces] = name;
const say = c.explainer ? name + ". " + c.explainer : name; // what the narrator says for this concept
const player = hero || (widget && typeof widget.play === "function" ? widget : null);
if (player) players.push({ player, card, name, say, hasClip: !!hero });
nodes.push(card);
});
let transport = null;
if (players.length) { transport = buildTransport(players); main.appendChild(transport); }
nodes.forEach((n) => main.appendChild(n));
// shareable autoplay: opening the page at "#play" runs the whole paper on load.
if (transport && (location.hash === "#play" || location.hash === "#autoplay")) setTimeout(transport.start, 400);
}
async function boot() {
const nav = document.getElementById("nav"), main = document.getElementById("main");
let index;
try { index = await getJSON("data/index.json"); }
catch (e) { main.appendChild(k.el("p", { class: "empty" }, ["No data/index.json found. Add per-paper manifests to webapp/data/ (the pipeline writes them)."])); return; }
const papers = index.papers || [];
if (!papers.length) { main.appendChild(k.el("p", { class: "empty" }, ["No papers yet."])); return; }
let active = null;
for (const entry of papers) {
const btn = k.el("button", { class: "nav-item", onclick: async () => {
if (active) active.classList.remove("on"); active = btn; btn.classList.add("on");
try { renderPaper(main, await getJSON("data/" + entry.file)); }
catch (e) { k.clear(main); main.appendChild(k.el("p", { class: "err" }, ["failed to load " + entry.file + ": " + e.message])); }
} }, [k.el("span", { class: "nav-t" }, [entry.title || entry.file]), k.el("span", { class: "nav-s" }, [(entry.concepts || "") + (entry.concepts ? " testers" : "")])]);
nav.appendChild(btn);
}
nav.firstChild.click();
}
document.addEventListener("DOMContentLoaded", boot);
})();