/* 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