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