lightloom / frontend /js /controller.js
Efradeca's picture
chore: deploy private lightloom build
ef9b322 verified
Raw
History Blame Contribute Delete
41.5 kB
// 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;