Spaces:
Running
Running
File size: 19,687 Bytes
d32737a 9b672a8 d32737a cbd6f7b ea31ddb 0ec7a93 ea31ddb 0ec7a93 ea31ddb 0ec7a93 ea31ddb 5de81d5 cbd6f7b 9b672a8 cbd6f7b 0ec7a93 9b672a8 d4a5ec1 9b672a8 ea31ddb 9b672a8 ea31ddb 9b672a8 5de81d5 9b672a8 d4a5ec1 0ec7a93 cbd6f7b 9b672a8 cbd6f7b 9b672a8 ea31ddb 9b672a8 5de81d5 cbd6f7b 9b672a8 cbd6f7b 5de81d5 0ec7a93 d4a5ec1 0ec7a93 cbd6f7b 0ec7a93 cbd6f7b 9b672a8 cbd6f7b 5de81d5 9b672a8 cbd6f7b 9b672a8 cbd6f7b 9b672a8 2ce6518 e5a1b70 d4a5ec1 e5a1b70 d32737a 2ce6518 d32737a 9b672a8 2ce6518 9b672a8 d32737a 9b672a8 2ce6518 d32737a 9b672a8 d32737a e5a1b70 d4a5ec1 e5a1b70 d4a5ec1 e5a1b70 2ce6518 ea31ddb e5a1b70 d4a5ec1 2ce6518 d32737a 9b672a8 2ce6518 9b672a8 d32737a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 | /* 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);
})();
|