Spaces:
Running
Running
| /* 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); | |
| })(); | |