Spaces:
Running on Zero
Running on Zero
| // frontend/js/controller.js — Lightloom · the CORRIDOR controller (LL.controller) | |
| // | |
| // The world IS the interface. Voice (or typed text) -> LL.api.streamWorld -> each | |
| // vivid scene streams into LL.corridor as it is painted, and the camera flies | |
| // forward through the world growing ahead of you. A discreet HUD lights up the | |
| // tiny model working right now (Voice / Director / Painter / Depth). | |
| // | |
| // Vanilla ES module. Reuses LL.api, LL.recorder, LL.corridor. | |
| import "./api.js"; | |
| import "./recorder.js"; | |
| import "./stage-scroll.js"; | |
| // (subtitles.js is intentionally not used: captions render in #transcript, which | |
| // carries aria-live="polite" for screen readers.) | |
| const LL = (window.LL = window.LL || {}); | |
| const COPY = { | |
| warming: { en: "Warming the projector — the first scene takes a few seconds…", es: "Encendiendo el proyector — la primera escena tarda unos segundos…" }, | |
| listening: { en: "Listening… speak your story, tap the mic to finish.", es: "Te escucho… cuenta tu historia, toca el micro para terminar." }, | |
| speaking: { en: "Listening — your world forms as you speak…", es: "Te escucho — tu mundo se forma mientras hablas…" }, | |
| transcribing: { en: "Hearing your words…", es: "Escuchando tus palabras…" }, | |
| micDenied: { en: "No mic — type your story instead.", es: "Sin micrófono — escribe tu historia." }, | |
| noSpeech: { en: "I didn't catch that. Try again, or type it.", es: "No te escuché. Inténtalo de nuevo, o escríbelo." }, | |
| empty: { en: "Say or type a story first.", es: "Di o escribe una historia primero." }, | |
| quota: { en: "The world ran out of GPU for today — watch the showcase.", es: "Se acabó la GPU de hoy — mira el showcase." }, | |
| building: { en: "Painting your world…", es: "Pintando tu mundo…" }, | |
| // transient feedback so a dropped phrase is never indistinguishable from a working one | |
| // (was: silent/asr_error/filtered fell through to a no-op and the sticky "building" line lied forever) | |
| notHeard: { en: "I didn't catch that — say it again?", es: "No te entendí — ¿otra vez?" }, | |
| unclear: { en: "Couldn't hear that clearly — try again.", es: "No se escuchó claro — inténtalo de nuevo." }, | |
| filtered: { en: "Let's keep it friendly — try rephrasing.", es: "Mantengámoslo amable — reformula." }, | |
| sceneSlip: { en: "That scene slipped — keep going.", es: "Esa escena se escapó — sigue hablando." }, | |
| // cycled during the cold-start wait so the ~35s never reads as a frozen hang (see _warmCycle) | |
| warmSteps: [ | |
| { en: "Warming the projector…", es: "Encendiendo el proyector…" }, | |
| { en: "Waking the voice…", es: "Despertando la voz…" }, | |
| { en: "The director is reading your words…", es: "El director lee tus palabras…" }, | |
| { en: "The painter is mixing light…", es: "El pintor mezcla la luz…" }, | |
| ], | |
| }; | |
| const t = (p, l) => (p && p[l]) || (p && p.en) || ""; | |
| const controller = { | |
| lang: "en", | |
| reducedMotion: false, | |
| running: false, | |
| _scenes: new Map(), // index -> { image, depth, caption } | |
| _count: 0, | |
| _statusTimer: null, | |
| init() { | |
| this._restore(); | |
| const canvas = document.getElementById("world-canvas"); | |
| if (canvas && LL.scroll) { | |
| try { LL.scroll.init(canvas); } catch (e) { console.error("scroll init", e); } | |
| // the caption tracks whichever section is under the centre of the scroll — BUT it must not | |
| // clobber a just-spoken transcript for a brief pin window, or your words flash and instantly | |
| // vanish under the OLDER on-screen section's caption (the "text feels slow/late" complaint). | |
| LL.scroll.onFocus = (meta) => { if (Date.now() < (this._tPin || 0)) return; this._transcript(meta && meta.caption); }; | |
| } | |
| this._initRecorder(); | |
| this._wire(); | |
| this._applyLang(this.lang); | |
| this._startAmbient(); // a pre-rendered scroll flows behind the intro + during warm-up | |
| this._loadLedger(); // keep the About param count honest + current from /health | |
| this._prewarm(); // load the GPU models DURING the intro so the first phrase is ~instant | |
| return this; | |
| }, | |
| /** Pre-warm the GPU worker (ASR + Director + painter + depth) while the user is still | |
| * reading the intro, so their first spoken phrase paints in ~3 s instead of ~40 s. The | |
| * showcase covers the load visually. Fire-and-forget; one warm per page. */ | |
| _prewarm() { | |
| if (this._prewarmed) return; | |
| this._prewarmed = true; | |
| if (!this._liveSession) this._liveSession = randomHex(); | |
| setTimeout(() => { | |
| try { LL.api.streamScrollLive(this._liveSession, "__warm__", this.lang, () => {}); } catch (_) {} | |
| }, 1200); | |
| }, | |
| /** Pull the runtime parameter total straight from the ledger (/health) so the About | |
| * panel always shows the TRUE count — never a hand-typed number that can drift. */ | |
| async _loadLedger() { | |
| try { | |
| const r = await fetch("/health", { cache: "no-store" }); | |
| if (!r.ok) return; | |
| const h = await r.json(); | |
| const b = (h.params_total || 0) / 1e9; | |
| const el = document.getElementById("about-ledger"); | |
| if (b > 0 && el) el.textContent = `${b.toFixed(2)}B / 32B parameters · all local · Off the Grid`; | |
| } catch (_) {} | |
| }, | |
| /** Keep a pre-rendered scroll flowing as a living BACKDROP that GUARANTEES the screen | |
| * is never black — a gap-filler, not a fixed loop. Every tick it checks how much | |
| * painted world is still ahead of the camera (LL.scroll.pendingAhead); only when that | |
| * buffer runs low does it extend the strip with a showcase section. So it fills the | |
| * cold-start (behind the intro + the ~30s warm-up), and tops up mid-session if the | |
| * live generation rate ever dips below the scroll — yet stays SILENT while the user's | |
| * real sections flow ahead, so they take over seamlessly with no wipe and no void. | |
| * GPU-free (bundled assets). */ | |
| // Pick a RANDOM pre-rendered cover variant (a different art style each load) from variants.json, | |
| // falling back to the single bundled manifest — so the world looks different from the very first | |
| // frame, not only once the live strips arrive. | |
| async _pickAmbientManifest() { | |
| const load = async (url) => { | |
| try { const r = await fetch(url, { cache: "no-store" }); if (r.ok) return (await r.json()).filter((m) => m && m.image); } catch (_) {} | |
| return null; | |
| }; | |
| try { | |
| const r = await fetch("/frontend/assets/scroll/variants.json", { cache: "no-store" }); | |
| if (r.ok) { | |
| const vs = await r.json(); | |
| if (Array.isArray(vs) && vs.length) { | |
| const v = vs[Math.floor(Math.random() * vs.length)]; | |
| const m = await load(`/frontend/assets/scroll/${v}/manifest.json`); | |
| if (m && m.length) return m; | |
| } | |
| } | |
| } catch (_) {} | |
| return (await load("/frontend/assets/scroll/manifest.json")) || []; | |
| }, | |
| async _startAmbient() { | |
| if (this._ambientOn) return; | |
| if (!this._ambientManifest) this._ambientManifest = await this._pickAmbientManifest(); | |
| const manifest = this._ambientManifest; | |
| if (!manifest.length || !LL.scroll) return; | |
| this._ambientOn = true; | |
| this._ambientStop = false; | |
| let i = 0; | |
| const FILL_AHEAD_PX = 1200; // keep at least ~one screen of painted world ahead of the view | |
| const tick = () => { | |
| // Stop once the user's real world begins: from then on the live sections own the | |
| // strip (showcase frames must NOT splice into the narrated world), and the scroll | |
| // clamps at the painted frontier so a generation lull pauses — never a black void. | |
| if (this._ambientStop || this._realStarted) { this._ambientOn = false; this._ambientTimer = null; return; } | |
| let ahead = Infinity; | |
| try { ahead = LL.scroll.pendingAhead; } catch (_) {} | |
| if (ahead < FILL_AHEAD_PX) { | |
| const m = manifest[i++ % manifest.length]; | |
| LL.scroll.addSection({ imageUrl: absUrl(m.image), depthUrl: m.depth ? absUrl(m.depth) : null, meta: { caption: "" } }); | |
| } | |
| this._ambientTimer = setTimeout(tick, 700); | |
| }; | |
| tick(); | |
| }, | |
| _wire() { | |
| on("mic-btn", "click", () => this._mic()); | |
| on("mic-mini", "click", () => this._mic()); | |
| on("type-toggle", "click", () => this._toggleComposer()); | |
| on("begin-btn", "click", () => { | |
| const ta = document.getElementById("story-text"); | |
| this.startWorld(ta ? ta.value : ""); | |
| }); | |
| on("showcase-btn", "click", () => this.playShowcase()); | |
| on("stop-btn", "click", () => this.reset()); | |
| on("save-btn", "click", () => this._saveWorld()); | |
| on("rm-btn", "click", () => this.setReducedMotion(!this.reducedMotion)); | |
| on("about-btn", "click", () => this._about(true)); | |
| on("about-close", "click", () => this._about(false)); | |
| const mq = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)"); | |
| if (mq && mq.matches) this.setReducedMotion(true, true); | |
| }, | |
| _initRecorder() { | |
| if (!LL.recorder || typeof LL.recorder.init !== "function") return; | |
| LL.recorder.init({ | |
| getLang: () => this.lang, | |
| onState: (s) => { | |
| this._orch("asr", s === "recording"); | |
| const mic = document.getElementById("mic-btn"); | |
| const mini = document.getElementById("mic-mini"); | |
| if (mic) mic.classList.toggle("is-recording", s === "recording"); | |
| if (mini) mini.classList.toggle("is-recording", s === "recording"); | |
| if (s === "recording") this._beginLive(); // the world starts building AS you speak | |
| else this._status(""); | |
| }, | |
| // LIVE narration: each VAD-cut phrase is painted into the SAME scroll as it's spoken. | |
| onSegment: (wavB64) => this._onLiveSegment(wavB64), | |
| onTranscript: (text, error) => { | |
| const clean = (text || "").trim(); | |
| if (clean) this.startWorld(clean); | |
| else { | |
| const denied = error === "mic_denied" || error === "unsupported"; | |
| this._status(t(denied ? COPY.micDenied : COPY.noSpeech, this.lang), 3600); | |
| if (denied) this._toggleComposer(true); | |
| } | |
| }, | |
| }); | |
| }, | |
| _mic() { | |
| if (LL.recorder && LL.recorder.supported && typeof LL.recorder.toggle === "function") { | |
| LL.recorder.toggle(); | |
| } else { | |
| this._toggleComposer(true); | |
| this._status(t(COPY.micDenied, this.lang), 3000); | |
| } | |
| }, | |
| _toggleComposer(force) { | |
| const c = document.getElementById("composer"); | |
| if (!c) return; | |
| const show = force === true ? true : c.hidden; | |
| c.hidden = !show; | |
| if (show) { const ta = document.getElementById("story-text"); if (ta) ta.focus(); } | |
| }, | |
| // ---- the experience: stream a world ---- | |
| async startWorld(text) { | |
| if (this.running) return; | |
| const story = (text || "").trim(); | |
| if (!story) { this._status(t(COPY.empty, this.lang), 3000, "warn"); this._toggleComposer(true); return; } | |
| if (LL.recorder && LL.recorder.cancel) LL.recorder.cancel(); | |
| this.running = true; | |
| this._scenes.clear(); | |
| this._count = 0; | |
| this._realStarted = false; // the ambient keeps flowing until the first real section | |
| if (!this._liveSession) this._liveSession = randomHex(); | |
| this._liveOn = true; // typed stories now use the SAME live continuous pipeline as voice | |
| this._enterWorld(); | |
| this._badge("✦ showcase · warming up your world…"); | |
| this._warmCycle(true); | |
| // Split the story into phrases and paint each through /scroll_live, CONTINUING the last — identical | |
| // Director/painter/continuity to the spoken path (no separate, divergent /scroll codepath). | |
| const phrases = story.split(/(?<=[.!?…])\s+/).map((s) => s.trim()).filter(Boolean); | |
| try { | |
| for (const p of (phrases.length ? phrases : [story])) { | |
| if (!this._liveOn) break; | |
| await LL.api.streamScrollLive(this._liveSession, "", this.lang, (ev) => this._onEvent(ev), p); | |
| } | |
| } catch (err) { | |
| console.error("[controller] typed live failed", err); | |
| } finally { | |
| this.running = false; | |
| this._warmCycle(false); | |
| this._orchAllOff(); | |
| this._status(""); | |
| // leave _liveOn so the user can keep going (speak more / type more) in the SAME world | |
| } | |
| }, | |
| // ---- LIVE narration: build the world AS the user speaks ---- | |
| /** Enter the world the instant the mic opens and warm the models, so the first | |
| * spoken phrase paints with minimal delay. The ambient gap-filler keeps the screen | |
| * full until the user's strips arrive. One session per mic session (continuity). */ | |
| _beginLive() { | |
| if (this._liveOn) { this._status(t(COPY.speaking, this.lang), 0, "work"); return; } | |
| this._liveOn = true; | |
| this.running = true; | |
| if (!this._liveSession) this._liveSession = randomHex(); | |
| this._phraseQ = this._phraseQ || []; | |
| this._enterWorld(); | |
| // honest cover: the showcase flows during warm-up but is clearly LABELLED, and it is | |
| // removed the instant the user's own first strip is painted (see _tryAdd). | |
| this._badge("✦ showcase · warming up your world…"); | |
| this._status(t(COPY.speaking, this.lang), 0, "work"); | |
| // models were already pre-warmed during the intro (_prewarm); if that was skipped, | |
| // fire one warm now as a fallback so the first phrase still loads them. | |
| if (!this._prewarmed) { try { LL.api.streamScrollLive(this._liveSession, "", this.lang, () => {}); } catch (_) {} } | |
| }, | |
| /** A freshly spoken phrase (base64 WAV) — queue it; the drainer paints phrases in | |
| * spoken order, each one CONTINUING the same panorama. */ | |
| _onLiveSegment(wavB64) { | |
| if (!this._liveOn || !wavB64) return; | |
| if (!this._liveSession) this._liveSession = randomHex(); | |
| (this._phraseQ = this._phraseQ || []).push(wavB64); | |
| this._drainLive(); | |
| }, | |
| async _drainLive() { | |
| if (this._draining) return; | |
| this._draining = true; | |
| try { | |
| // Loop on the QUEUE, not on _liveOn: if the session ends mid-await the queued phrases must still be | |
| // serviced/cleared, otherwise they strand and the drain never resumes. Drop (don't paint) once the | |
| // session is gone. | |
| while (this._phraseQ && this._phraseQ.length) { | |
| // While a keepsake is rendering, the live painter and postprocess share the single GPU slot, so | |
| // submitting a phrase now would queue it behind the ~20s render and trip the 25s phrase watchdog | |
| // (the words would be silently dropped). BUFFER instead: leave phrases in _phraseQ and drain them | |
| // the instant the save finishes (_saveWorld's finish() re-calls _drainLive) -> nothing is lost. | |
| if (this._saving) break; | |
| // BASELINE drain: paint EVERY spoken phrase as its own strip, in spoken order (the coalescing | |
| // experiment merged phrases into one strip and visibly "skipped" what the user said -> reverted). | |
| const wav = this._phraseQ.shift(); | |
| if (!this._liveOn) continue; // session ended -> drain the rest without painting | |
| // instant "is it listening?" feedback: the moment a spoken phrase enters the painter, say so — | |
| // the server transcript/section events arrive ~1-2s later, so without this there is dead air. | |
| this._status(t(COPY.transcribing, this.lang), 0, "work"); | |
| try { | |
| await LL.api.streamScrollLive(this._liveSession, wav, this.lang, (ev) => this._onEvent(ev)); | |
| } catch (err) { console.error("[controller] live phrase failed", err); } | |
| } | |
| } finally { | |
| this._draining = false; | |
| if (!this._phraseQ || !this._phraseQ.length) this._orchAllOff(); | |
| } | |
| }, | |
| // ---- DIRECTOR'S CUT keepsake: save the finished world as a named fly-through MP4 (post-process; the live | |
| // world keeps running). Captures _liveSession WITHOUT resetting it, so you can save AND keep speaking. ---- | |
| _saveWorld() { | |
| if (this._saving) return; // a keepsake is already rendering -> ignore repeat clicks (no double quota burn) | |
| const session = this._liveSession; | |
| const es = this.lang === "es"; | |
| if (!session) { this._status(es ? "Habla primero para crear un mundo" : "Speak first to create a world", 2800); return; } | |
| this._saving = true; // _drainLive buffers spoken phrases while this is set (they paint after the render) | |
| const token = (this._saveToken = (this._saveToken || 0) + 1); // invalidated by reset() / a newer save | |
| const btn = document.getElementById("save-btn"); | |
| if (btn) btn.disabled = true; | |
| // The single robust clear point: streamPostprocess ALWAYS resolves exactly once (even on a silent | |
| // socket close, thanks to its watchdog), so .finally() reliably re-enables Save and drains the phrases | |
| // buffered during the render. Token-gated so a stale save that resolves late can't clobber a new world. | |
| const finish = () => { | |
| if (this._saveToken !== token) return; | |
| this._saving = false; | |
| if (btn) btn.disabled = false; | |
| this._drainLive(); | |
| }; | |
| this._status(es ? "Creando tu recuerdo…" : "Crafting your keepsake…", 0); | |
| LL.api.streamPostprocess(session, this.lang, (ev) => { | |
| if (this._saveToken !== token) return; // drop events from a save the user superseded (no pop over a new world) | |
| switch (ev && ev.stage) { | |
| case "stitched": this._status(es ? "Uniendo tu mundo…" : "Stitching your world…", 0); break; | |
| // do NOT write the seer title onto the live #world-title — it shows only inside the keepsake overlay, | |
| // so an abandoned/failed save never leaves a stale title on the live world chrome. | |
| case "titled": this._status(es ? "Nombrando y filmando…" : "Naming & filming…", 0); break; | |
| case "rendering": this._status((es ? "Renderizando… " : "Rendering… ") + Math.round((100 * (ev.frame || 0)) / (ev.total || 1)) + "%", 0); break; | |
| case "encoding": this._status(es ? "Codificando la película…" : "Encoding the film…", 0); break; | |
| case "ready": this._showKeepsake(ev); break; | |
| case "render_timeout": | |
| this._status(es ? "El mundo es muy largo para filmar — graba uno más corto" : "That world is too long to film — try a shorter one", 4600); break; | |
| case "quota_exceeded": | |
| this._status(es ? "Se acabó la GPU de hoy — inténtalo más tarde" : "Out of GPU for today — try later", 4200); break; | |
| case "postprocess_error": case "error": | |
| this._status(es ? "No se pudo guardar (intenta de nuevo)" : "Couldn't save (try again)", 3400); break; | |
| default: break; // seer_skipped etc. -> the MP4 still renders with the fallback title | |
| } | |
| }).finally(finish); | |
| }, | |
| /** Show the keepsake overlay with the named fly-through <video> + a download link. Built with DOM nodes | |
| * (not innerHTML) so the model-authored title can never inject markup. */ | |
| _showKeepsake(ev) { | |
| this._status(""); | |
| const card = document.getElementById("keepsake"); | |
| if (!card) return; | |
| const es = this.lang === "es"; | |
| card.textContent = ""; | |
| const panel = document.createElement("div"); panel.className = "keepsake__panel"; | |
| // ONE close path (✕ click and Escape both route here): pause the video, drop the key listener, hide | |
| // the dialog, and return focus to the Save button — satisfies the aria-modal=true keyboard contract. | |
| const onKey = (e) => { if (e.key === "Escape") closeKeepsake(); }; | |
| const closeKeepsake = () => { | |
| const v = card.querySelector("video"); if (v) { try { v.pause(); } catch (_) {} } | |
| document.removeEventListener("keydown", onKey); | |
| card.hidden = true; | |
| const sb = document.getElementById("save-btn"); if (sb) { try { sb.focus(); } catch (_) {} } | |
| }; | |
| const close = document.createElement("button"); | |
| close.className = "keepsake__close"; close.type = "button"; close.setAttribute("aria-label", "Close"); close.textContent = "✕"; | |
| close.addEventListener("click", closeKeepsake); | |
| const ttl = document.createElement("div"); ttl.className = "keepsake__title"; ttl.textContent = ev.title || ""; | |
| const nodes = [close, ttl]; | |
| // The Art Director's one-line description (computed + streamed) — show it; it was being dropped. | |
| if (ev.caption) { const cap = document.createElement("div"); cap.className = "keepsake__caption"; cap.textContent = ev.caption; nodes.push(cap); } | |
| // MiniCPM-V's "field notes" — the things it actually saw in your finished world (its pixel analysis). | |
| if (Array.isArray(ev.field_notes) && ev.field_notes.length) { | |
| const ul = document.createElement("ul"); ul.className = "keepsake__notes"; | |
| ev.field_notes.slice(0, 5).forEach((n) => { const li = document.createElement("li"); li.textContent = String(n); ul.appendChild(li); }); | |
| nodes.push(ul); | |
| } | |
| // The film lives in a stage with a buffering spinner over it; the spinner clears on the first | |
| // decoded frame (loadeddata) so a slow MP4 stream never looks like a frozen black box. | |
| const stage = document.createElement("div"); stage.className = "keepsake__stage"; | |
| const video = document.createElement("video"); | |
| video.className = "keepsake__video"; video.src = ev.video || ""; | |
| video.controls = true; video.autoplay = true; video.loop = true; video.muted = true; video.playsInline = true; | |
| video.preload = "auto"; | |
| const spin = document.createElement("div"); spin.className = "keepsake__spin"; | |
| spin.textContent = es ? "Cargando la película…" : "Loading the film…"; | |
| const clearSpin = () => stage.classList.add("is-ready"); | |
| video.addEventListener("loadeddata", clearSpin, { once: true }); | |
| video.addEventListener("playing", clearSpin, { once: true }); | |
| // A swept/404/0-byte MP4 would otherwise be a silent black box — tell the user instead. | |
| video.addEventListener("error", () => { clearSpin(); this._status(es ? "El video no cargó — vuelve a intentar" : "The film could not load — try again", 4000); }); | |
| stage.append(video, spin); | |
| // Action row: download, copy social caption, full panorama, explore-in-3D — kept on one line. | |
| const actions = document.createElement("div"); actions.className = "keepsake__actions"; | |
| const dl = document.createElement("a"); | |
| dl.className = "keepsake__dl"; dl.href = ev.video || ""; dl.setAttribute("download", "lightloom-world.mp4"); | |
| dl.textContent = es ? "↓ Descargar tu mundo" : "↓ Download your world"; | |
| actions.append(dl); | |
| // Copy social caption: a one-tap share string composed from the world's name + the Art Director's line. | |
| const shareText = [ | |
| ev.title ? `"${ev.title}"` : "", | |
| ev.caption || "", | |
| es ? "— hecho con la voz en Lightloom, todo con modelos locales." : "— spoken into being with Lightloom, entirely on local models.", | |
| ].filter(Boolean).join(" "); | |
| const copy = document.createElement("button"); | |
| copy.type = "button"; copy.className = "keepsake__dl keepsake__copy"; | |
| const copyLabel = es ? "⧉ Copiar descripción" : "⧉ Copy social caption"; | |
| copy.textContent = copyLabel; | |
| copy.addEventListener("click", async () => { | |
| try { | |
| if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(shareText); | |
| else { const ta = document.createElement("textarea"); ta.value = shareText; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove(); } | |
| copy.classList.add("is-copied"); copy.textContent = es ? "✓ Copiado" : "✓ Copied"; | |
| setTimeout(() => { copy.classList.remove("is-copied"); copy.textContent = copyLabel; }, 1800); | |
| } catch (_) { this._status(es ? "No se pudo copiar" : "Couldn't copy", 2400); } | |
| }); | |
| actions.append(copy); | |
| nodes.push(stage, actions); | |
| // Bonus: the full stitched panorama (now saved at any width) as a second keepsake download. | |
| if (ev.panorama) { | |
| const pdl = document.createElement("a"); | |
| pdl.className = "keepsake__dl keepsake__dl--pano"; pdl.href = ev.panorama; | |
| pdl.setAttribute("download", "lightloom-panorama.jpg"); | |
| pdl.textContent = es ? "↓ El panorama completo" : "↓ The full panorama"; | |
| actions.append(pdl); | |
| } | |
| // SEPARATE, post-process, client-GPU: step INTO the finished world in real navigable 3D (explore3d.js). | |
| if (ev.explore_color && ev.explore_depth) { | |
| const ex = document.createElement("button"); | |
| ex.className = "keepsake__dl keepsake__explore"; ex.type = "button"; | |
| ex.textContent = es ? "✦ Explorar en 3D" : "✦ Explore in 3D"; | |
| ex.addEventListener("click", () => { | |
| try { LL.explore3d && LL.explore3d.open({ color: ev.explore_color, depth: ev.explore_depth, title: ev.title, focal: ev.focal_points }); } | |
| catch (_) { this._status(es ? "No se pudo abrir el 3D" : "Couldn't open 3D", 3000); } | |
| }); | |
| actions.append(ex); | |
| } | |
| // Ask Your World — MiniCPM-V answers questions about YOUR finished world from its pixels (the OpenBMB lever). | |
| const askSession = ev.session || this._liveSession; | |
| if (askSession && (ev.explore_color || ev.panorama)) { | |
| const ask = document.createElement("div"); ask.className = "keepsake__ask"; | |
| const row = document.createElement("div"); row.className = "keepsake__askrow"; | |
| const inp = document.createElement("input"); inp.type = "text"; inp.className = "keepsake__askinput"; inp.maxLength = 200; | |
| inp.placeholder = es ? "Pregúntale a tu mundo…" : "Ask your world…"; | |
| const btn = document.createElement("button"); btn.type = "button"; btn.className = "keepsake__askbtn"; | |
| btn.textContent = es ? "Preguntar" : "Ask"; | |
| const out = document.createElement("div"); out.className = "keepsake__askout"; out.setAttribute("aria-live", "polite"); | |
| const run = async () => { | |
| const q = inp.value.trim(); if (!q || btn.disabled) return; | |
| btn.disabled = true; out.textContent = es ? "Mirando tu mundo…" : "Looking at your world…"; | |
| try { const r = await LL.api.ask(askSession, q, this.lang); out.textContent = (r && r.answer) ? r.answer : (es ? "No pude responder eso." : "Couldn't answer that."); } | |
| catch (_) { out.textContent = es ? "No pude responder eso." : "Couldn't answer that."; } | |
| btn.disabled = false; | |
| }; | |
| btn.addEventListener("click", run); | |
| inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); run(); } }); | |
| row.append(inp, btn); ask.append(row, out); nodes.push(ask); | |
| } | |
| panel.append(...nodes); | |
| card.append(panel); | |
| card.hidden = false; | |
| document.addEventListener("keydown", onKey); | |
| try { close.focus(); } catch (_) {} | |
| }, | |
| _onEvent(ev) { | |
| if (!ev || typeof ev !== "object") return; | |
| // Drop ghost WORLD events from a session that is no longer active. The live generator keeps | |
| // yielding after Stop/New-World (reset() clears _liveOn/running and _liveSession), and a newer | |
| // session supersedes an older one — without this, the dead stream paints over the fresh idle | |
| // cover and the scene counter climbs over the intro. Status/quota events still pass through. | |
| if (ev.stage === "section" || ev.stage === "depth" || ev.stage === "transcript" || ev.stage === "section_error" || ev.stage === "title" || ev.stage === "error" || ev.stage === "warmed") { | |
| if (!this._liveOn && !this.running) return; // session ended -> back to idle | |
| if (ev.session && this._liveSession && ev.session !== this._liveSession) return; // superseded by a newer session | |
| } | |
| if (ev.stage !== "warming" && this._warmTimer) this._warmCycle(false); // real content flowing -> stop the warm-up cycle | |
| switch (ev.stage) { | |
| case "transcript": // the words you just spoke, becoming the world | |
| this._orch("director", true); | |
| // FAST FEEDBACK (user preference): show the just-transcribed words IMMEDIATELY (the backend yields | |
| // this the instant Parakeet finishes, before the slower painting) so speaking feels FLUID — the text | |
| // leads its (still-painting) image. onFocus keeps updating #transcript scene-matched as sections | |
| // scroll under the view centre, so the words converge with their on-screen scene. | |
| if (ev.text) { this._transcript(ev.text); this._tPin = Date.now() + 2400; } | |
| this._status(t(COPY.building, this.lang), 0, "work"); | |
| break; | |
| case "silent": // the user spoke but ASR returned nothing — TELL them, and clear the sticky line | |
| this._orch("asr", false); | |
| this._status(t(COPY.notHeard, this.lang), 2600, "warn"); | |
| break; | |
| case "asr_error": | |
| this._orch("asr", false); | |
| this._status(t(COPY.unclear, this.lang), 2600, "warn"); | |
| break; | |
| case "filtered": // moderation removed it — guide the user instead of silently dropping | |
| this._status(t(COPY.filtered, this.lang), 2800, "warn"); | |
| break; | |
| case "phrase_done": | |
| break; | |
| case "warming": | |
| this._warmCycle(true); | |
| break; | |
| case "directing": | |
| this._orch("director", true); | |
| this._status(t(COPY.building, this.lang), 0, "work"); | |
| break; | |
| case "painting": | |
| this._orch("painter", true); | |
| break; | |
| case "section": | |
| this._orch("painter", true); | |
| this._stash(ev.index, { image: absUrl(ev.image), caption: ev.caption }); | |
| this._tryAdd(ev.index); | |
| this._count = Math.max(this._count, (ev.index || 0) + 1); | |
| this._setCount(); | |
| break; | |
| case "depth": | |
| this._orch("depth", true); | |
| this._stash(ev.index, { depth: absUrl(ev.depth) }); | |
| this._tryAdd(ev.index); | |
| break; | |
| case "section_error": | |
| this._orch("painter", false); | |
| this._status(t(COPY.sceneSlip, this.lang), 2600, "warn"); | |
| this._tryAdd(ev.index, true); // add with whatever we have | |
| break; | |
| case "title": // the Director (MiniCPM) named your world | |
| this._setTitle(ev.text); | |
| break; | |
| case "done": | |
| this._orchAllOff(); | |
| this._status(""); | |
| break; | |
| case "quota_exceeded": | |
| this._status(t(COPY.quota, this.lang), 6000, "err"); | |
| break; | |
| case "error": | |
| this._status((ev.error || "error").slice(0, 80), 5000); | |
| break; | |
| default: | |
| if (ev.stage) console.debug("[controller] unhandled stage", ev.stage); | |
| } | |
| }, | |
| _stash(i, patch) { | |
| if (typeof i !== "number") return; | |
| this._scenes.set(i, Object.assign(this._scenes.get(i) || {}, patch)); | |
| }, | |
| _tryAdd(i, force) { | |
| const s = this._scenes.get(i); | |
| if (!s || s._added || !s.image) return; | |
| if (!s.depth && !force) return; // brief wait for depth (it arrives ~instantly) | |
| s._added = true; | |
| // The user's real sections simply EXTEND the same continuous strip — no wipe. On the | |
| // FIRST real strip we drop the "showcase · warming up" badge and stop the ambient | |
| // filler (gated in _startAmbient), so the user's own world takes over cleanly. | |
| if (!this._realStarted) { this._realStarted = true; this._badge(""); } | |
| if (LL.scroll && typeof LL.scroll.addSection === "function") { | |
| LL.scroll.addSection({ imageUrl: s.image, depthUrl: s.depth || null, meta: { caption: s.caption } }); | |
| } | |
| }, | |
| // ---- showcase (pre-rendered corridor journey, GPU-free) ---- | |
| async playShowcase() { | |
| if (this.running) return; | |
| const manifest = await this._pickAmbientManifest(); // a random pre-rendered style variant | |
| if (!manifest.length) { this.startWorld(document.getElementById("story-text")?.value || ""); return; } | |
| this.running = true; | |
| if (LL.scroll && LL.scroll.reset) LL.scroll.reset(); | |
| this._enterWorld(); | |
| const _es = this.lang === "es"; | |
| this._badge(_es ? "✦ SHOWCASE · toca el micro para crear el tuyo · ✕ salir" : "✦ SHOWCASE · tap the mic to make your own · ✕ exit"); | |
| // make the showcase EXITABLE + discoverable: click the badge OR press Escape -> back to a fresh start | |
| const _bdg = document.getElementById("ll-badge"); | |
| if (_bdg) { _bdg.style.pointerEvents = "auto"; _bdg.style.cursor = "pointer"; _bdg.onclick = () => this.reset(); } | |
| this._showcaseKey = (e) => { if (e.key === "Escape") this.reset(); }; | |
| document.addEventListener("keydown", this._showcaseKey); | |
| this._showcaseStop = false; | |
| try { | |
| while (!this._showcaseStop) { | |
| for (const m of manifest) { | |
| if (this._showcaseStop) break; | |
| if (LL.scroll) LL.scroll.addSection({ imageUrl: absUrl(m.image), depthUrl: m.depth ? absUrl(m.depth) : null, meta: { caption: m.caption } }); | |
| this._count++; this._setCount(); | |
| await sleep(this.reducedMotion ? 2600 : 4200); // pace appends to the scroll speed | |
| } | |
| if (this.reducedMotion) break; | |
| } | |
| } finally { | |
| this.running = false; | |
| if (this._showcaseKey) { document.removeEventListener("keydown", this._showcaseKey); this._showcaseKey = null; } | |
| } | |
| }, | |
| // ---- screen / chrome ---- | |
| _enterWorld() { | |
| const intro = document.getElementById("intro"); | |
| if (intro) intro.classList.add("is-gone"); | |
| const ctl = document.getElementById("controls"); | |
| if (ctl) ctl.hidden = false; | |
| document.body.classList.add("in-world"); | |
| }, | |
| reset() { | |
| this._showcaseStop = true; | |
| // end any live narration and drop its session (a new world = a fresh panorama) | |
| this._liveOn = false; | |
| this._liveSession = null; | |
| this._phraseQ = []; | |
| this._draining = false; // clear the drain guard so a wedged/aborted drain can't survive into the next session | |
| this._warmCycle(false); // stop any running warm-up cycle | |
| // Invalidate any in-flight keepsake save: bumping the token makes its finish()/event handlers no-op, and | |
| // clearing _saving + re-enabling the button avoids a Save button stuck disabled after a mid-save New-World. | |
| this._saveToken = (this._saveToken || 0) + 1; | |
| this._saving = false; | |
| const saveBtn = document.getElementById("save-btn"); | |
| if (saveBtn) saveBtn.disabled = false; | |
| if (LL.api && LL.api.cancel) LL.api.cancel(); | |
| if (LL.recorder && LL.recorder.cancel) LL.recorder.cancel(); | |
| this.running = false; | |
| this._badge(""); | |
| this._orchAllOff(); | |
| this._setCount(true); | |
| this._transcript(""); | |
| this._setTitle(""); | |
| // stop the running gap-filler, fully wipe the strip, then restart fresh behind the intro | |
| this._ambientStop = true; | |
| if (this._ambientTimer) { clearTimeout(this._ambientTimer); this._ambientTimer = null; } | |
| this._ambientOn = false; | |
| if (LL.scroll && LL.scroll.reset) LL.scroll.reset(); | |
| const intro = document.getElementById("intro"); | |
| if (intro) intro.classList.remove("is-gone"); | |
| const ctl = document.getElementById("controls"); | |
| if (ctl) ctl.hidden = true; | |
| document.body.classList.remove("in-world"); | |
| this._realStarted = false; | |
| this._startAmbient(); // the showcase flows behind the intro again | |
| }, | |
| _orch(name, on) { | |
| const el = document.getElementById("orch-" + name); | |
| if (el) { | |
| el.classList.toggle("is-active", !!on); | |
| // baton handoff: replay the one-shot flare on the chip that JUST lit (conductor passing the baton) | |
| if (on && this._lit !== name) { | |
| el.classList.remove("is-handoff"); void el.offsetWidth; el.classList.add("is-handoff"); | |
| } | |
| } | |
| // only one painter/depth/director lit at a time for a clean read | |
| if (on) for (const k of ["asr", "director", "painter", "depth"]) { | |
| if (k !== name) { const o = document.getElementById("orch-" + k); if (o) o.classList.remove("is-active"); } | |
| } | |
| if (on) { this._lit = name; this._nowPlaying(name); } | |
| else if (this._lit === name) { this._lit = null; this._nowPlaying(null); } | |
| }, | |
| _orchAllOff() { | |
| this._lit = null; | |
| for (const k of ["asr", "director", "painter", "depth"]) { | |
| const el = document.getElementById("orch-" + k); | |
| if (el) el.classList.remove("is-active", "is-handoff"); | |
| } | |
| this._nowPlaying(null); | |
| }, | |
| // The live model's one-line job — so a judge reads WHICH tiny model is working right now. | |
| _nowPlaying(name) { | |
| const el = document.getElementById("now-playing"); | |
| if (!el) return; | |
| const JOBS = { | |
| asr: { en: "<b>Voice</b> — Parakeet is hearing you" }, | |
| director: { en: "<b>Director</b> — MiniCPM is shaping the scene" }, | |
| painter: { en: "<b>Painter</b> — FLUX.2 klein is painting it" }, | |
| depth: { en: "<b>Depth</b> — Depth-Anything is giving it depth" }, | |
| }; | |
| const job = name && JOBS[name]; | |
| if (!job) { el.classList.remove("is-on"); el.textContent = ""; return; } | |
| el.innerHTML = job.en; // fixed, code-authored strings only (no user/model text) — safe innerHTML | |
| el.classList.add("is-on"); | |
| }, | |
| _setCount(clear) { | |
| const el = document.getElementById("scene-count"); | |
| if (!el) return; | |
| el.textContent = clear || !this._count ? "" : (this.lang === "es" ? "escena " : "scene ") + this._count; | |
| }, | |
| _transcript(text) { | |
| const el = document.getElementById("transcript"); | |
| if (!el) return; | |
| const s = (text == null ? "" : String(text)).trim(); | |
| if (!s) { el.classList.remove("is-on"); return; } | |
| el.textContent = s; | |
| el.classList.remove("is-on"); void el.offsetWidth; el.classList.add("is-on"); | |
| }, | |
| _setTitle(text) { | |
| const el = document.getElementById("world-title"); | |
| if (!el) return; | |
| const s = (text == null ? "" : String(text)).trim(); | |
| if (!s) { el.classList.remove("is-on"); el.textContent = ""; return; } | |
| el.textContent = s; | |
| el.classList.remove("is-on"); void el.offsetWidth; el.classList.add("is-on"); | |
| }, | |
| _status(text, ms = 3000, kind = "") { | |
| const el = document.getElementById("status"); | |
| if (!el) return; | |
| if (this._statusTimer) { clearTimeout(this._statusTimer); this._statusTimer = null; } | |
| const s = (text || "").trim(); | |
| el.textContent = s; | |
| el.classList.remove("status--work", "status--warn", "status--err"); | |
| if (s && kind) el.classList.add("status--" + kind); // work=gold dot · warn=amber · err=red | |
| el.classList.toggle("is-on", !!s); | |
| if (s && ms > 0) this._statusTimer = setTimeout(() => el.classList.remove("is-on"), ms); | |
| }, | |
| // Cycle a few "the projector is warming" lines during the ~35s cold start so the wait reads as guided | |
| // progress (working state), not a frozen hang. Cleared the instant real content flows + on reset(). | |
| _warmCycle(on) { | |
| if (this._warmTimer) { clearInterval(this._warmTimer); this._warmTimer = null; } | |
| if (!on) return; | |
| const steps = COPY.warmSteps; | |
| let i = 0; | |
| const show = () => this._status(t(steps[i % steps.length], this.lang), 0, "work"); | |
| show(); | |
| this._warmTimer = setInterval(() => { i += 1; show(); }, 6000); | |
| }, | |
| _badge(text) { | |
| let el = document.getElementById("ll-badge"); | |
| if (!text) { if (el) el.hidden = true; return; } | |
| if (!el) { | |
| el = document.createElement("div"); el.id = "ll-badge"; el.className = "showcase-badge"; | |
| document.body.appendChild(el); | |
| } | |
| el.textContent = text; el.hidden = false; | |
| }, | |
| _about(show) { | |
| const el = document.getElementById("about"); | |
| if (!el) return; | |
| el.hidden = !show; | |
| if (show) { | |
| this._aboutReturn = document.activeElement; | |
| const c = document.getElementById("about-close"); if (c) { try { c.focus(); } catch (_) {} } | |
| this._aboutKey = (e) => { if (e.key === "Escape") this._about(false); }; | |
| document.addEventListener("keydown", this._aboutKey); | |
| } else { | |
| if (this._aboutKey) { document.removeEventListener("keydown", this._aboutKey); this._aboutKey = null; } | |
| const r = this._aboutReturn; if (r && r.focus) { try { r.focus(); } catch (_) {} } // restore focus to the opener | |
| } | |
| }, | |
| setReducedMotion(on, fromOS) { | |
| this.reducedMotion = !!on; | |
| document.body.classList.toggle("reduced-motion", this.reducedMotion); | |
| const b = document.getElementById("rm-btn"); | |
| if (b) b.classList.toggle("is-active", this.reducedMotion); | |
| if (LL.scroll && LL.scroll.setReducedMotion) LL.scroll.setReducedMotion(this.reducedMotion); | |
| if (!fromOS) try { localStorage.setItem("ll.rm", this.reducedMotion ? "1" : "0"); } catch (_) {} | |
| }, | |
| _applyLang(lang) { | |
| document.documentElement.setAttribute("lang", lang); | |
| document.querySelectorAll("[data-en],[data-es]").forEach((n) => { | |
| if (n.hasAttribute("data-en") && !n.hasAttribute("data-es")) n.hidden = lang !== "en"; | |
| else if (n.hasAttribute("data-es") && !n.hasAttribute("data-en")) n.hidden = lang !== "es"; | |
| }); | |
| this._setCount(); | |
| }, | |
| _restore() { | |
| // English-only: the app is always English (judged in English). No language is restored. | |
| this.lang = "en"; | |
| try { if (localStorage.getItem("ll.rm") === "1") this.reducedMotion = true; } catch (_) {} | |
| }, | |
| }; | |
| function on(id, evt, fn) { const el = document.getElementById(id); if (el) el.addEventListener(evt, fn); } | |
| function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } | |
| // an 8-hex-char session id matching the backend's [a-f0-9]{6,32} guard | |
| function randomHex() { | |
| try { | |
| const a = new Uint8Array(4); | |
| (window.crypto || crypto).getRandomValues(a); | |
| return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join(""); | |
| } catch (_) { | |
| return Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, "0"); | |
| } | |
| } | |
| function absUrl(u) { if (!u) return u; try { return new URL(u, window.location.origin).href; } catch (_) { return u; } } | |
| LL.controller = controller; | |
| if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => controller.init(), { once: true }); | |
| else controller.init(); | |
| export default controller; | |