chore: deploy private lightloom build
Browse files- frontend/css/immersive.css +17 -0
- frontend/index.html +5 -0
- frontend/js/api.js +31 -0
- frontend/js/controller.js +45 -0
frontend/css/immersive.css
CHANGED
|
@@ -160,6 +160,23 @@ html, body { height: 100%; margin: 0; background: var(--atmo); color: var(--ink)
|
|
| 160 |
.about__close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: var(--ink-soft); font-size: 18px; cursor: pointer; }
|
| 161 |
|
| 162 |
body.reduced-motion .mic__ring, body.reduced-motion .orch.is-active i { animation: none; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
@media (max-width: 640px) {
|
| 164 |
.orchestra { display: none; }
|
| 165 |
.mic { width: 104px; height: 104px; }
|
|
|
|
| 160 |
.about__close { position: absolute; top: 14px; right: 16px; background: none; border: 0; color: var(--ink-soft); font-size: 18px; cursor: pointer; }
|
| 161 |
|
| 162 |
body.reduced-motion .mic__ring, body.reduced-motion .orch.is-active i { animation: none; }
|
| 163 |
+
/* Director's Cut keepsake overlay — the MiniCPM-V-named fly-through film of your finished world. */
|
| 164 |
+
.keepsake { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
|
| 165 |
+
background: rgba(4, 5, 9, 0.82); backdrop-filter: blur(6px); padding: 4vmin; }
|
| 166 |
+
.keepsake[hidden] { display: none; }
|
| 167 |
+
.keepsake__panel { position: relative; max-width: min(94vw, 1100px); width: 100%;
|
| 168 |
+
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
| 169 |
+
background: rgba(12, 14, 20, 0.92); border: 1px solid rgba(255, 255, 255, 0.08);
|
| 170 |
+
border-radius: 16px; padding: 22px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6); }
|
| 171 |
+
.keepsake__close { position: absolute; top: 12px; right: 14px; background: none; border: 0;
|
| 172 |
+
color: var(--ink-soft, #cbd0db); font-size: 18px; cursor: pointer; }
|
| 173 |
+
.keepsake__title { font-family: var(--serif, "Fraunces", Georgia, serif); font-style: italic;
|
| 174 |
+
font-size: clamp(20px, 3.4vw, 34px); color: var(--gold, #e8c98a); text-align: center; }
|
| 175 |
+
.keepsake__video { width: 100%; max-height: 64vh; border-radius: 10px; background: #000; display: block; }
|
| 176 |
+
.keepsake__dl { color: var(--ink, #f3f4f7); text-decoration: none; font-size: 15px;
|
| 177 |
+
border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 999px; padding: 9px 20px; transition: background 0.15s ease; }
|
| 178 |
+
.keepsake__dl:hover { background: rgba(255, 255, 255, 0.08); }
|
| 179 |
+
|
| 180 |
@media (max-width: 640px) {
|
| 181 |
.orchestra { display: none; }
|
| 182 |
.mic { width: 104px; height: 104px; }
|
frontend/index.html
CHANGED
|
@@ -92,6 +92,7 @@
|
|
| 92 |
<!-- Bottom controls (appear once a world is live). -->
|
| 93 |
<div id="controls" class="controls" hidden>
|
| 94 |
<button id="mic-mini" class="ctl" type="button" title="Speak more" aria-label="Speak more">🎙</button>
|
|
|
|
| 95 |
<button id="stop-btn" class="ctl" type="button" title="New world" aria-label="New world">⟲</button>
|
| 96 |
<button id="rm-btn" class="ctl" type="button" title="Reduce motion" aria-label="Reduce motion">♿</button>
|
| 97 |
<button id="about-btn" class="ctl" type="button" title="About" aria-label="About">ⓘ</button>
|
|
@@ -120,6 +121,10 @@
|
|
| 120 |
</div>
|
| 121 |
</section>
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
<!-- ES modules in dependency order. The corridor controller wires everything. -->
|
| 124 |
<script type="module" src="/frontend/js/api.js"></script>
|
| 125 |
<script type="module" src="/frontend/js/recorder.js"></script>
|
|
|
|
| 92 |
<!-- Bottom controls (appear once a world is live). -->
|
| 93 |
<div id="controls" class="controls" hidden>
|
| 94 |
<button id="mic-mini" class="ctl" type="button" title="Speak more" aria-label="Speak more">🎙</button>
|
| 95 |
+
<button id="save-btn" class="ctl" type="button" title="Save your world" aria-label="Save your world">🎬</button>
|
| 96 |
<button id="stop-btn" class="ctl" type="button" title="New world" aria-label="New world">⟲</button>
|
| 97 |
<button id="rm-btn" class="ctl" type="button" title="Reduce motion" aria-label="Reduce motion">♿</button>
|
| 98 |
<button id="about-btn" class="ctl" type="button" title="About" aria-label="About">ⓘ</button>
|
|
|
|
| 121 |
</div>
|
| 122 |
</section>
|
| 123 |
|
| 124 |
+
<!-- Director's Cut keepsake: the MiniCPM-V-named fly-through film of your finished world (injected by
|
| 125 |
+
controller._showKeepsake on the 🎬 button). Independent post-process; never blocks the live world. -->
|
| 126 |
+
<section id="keepsake" class="keepsake" hidden role="dialog" aria-modal="true" aria-label="Your saved world"></section>
|
| 127 |
+
|
| 128 |
<!-- ES modules in dependency order. The corridor controller wires everything. -->
|
| 129 |
<script type="module" src="/frontend/js/api.js"></script>
|
| 130 |
<script type="module" src="/frontend/js/recorder.js"></script>
|
frontend/js/api.js
CHANGED
|
@@ -153,6 +153,37 @@ const api = {
|
|
| 153 |
return { session, done: true };
|
| 154 |
},
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
/**
|
| 157 |
* Transcribe a base64 WAV (the narrator's mic audio) via Cohere Transcribe.
|
| 158 |
* Resolves with {text, error}. Used by LL.recorder; the transcript then drives
|
|
|
|
| 153 |
return { session, done: true };
|
| 154 |
},
|
| 155 |
|
| 156 |
+
/**
|
| 157 |
+
* DIRECTOR'S CUT keepsake. Over the FINISHED session panorama (already on disk under `session`), run the
|
| 158 |
+
* post-process: the MiniCPM-V-4.6 Art Director names it from the pixels, then a depth-parallax fly-through
|
| 159 |
+
* is rendered to world.mp4. Streams {stitched,titled,rendering,encoding,ready} (ready carries .video + the
|
| 160 |
+
* .title). Short-lived per call; does NOT touch the live _job handle. Independent of the live painter.
|
| 161 |
+
* @returns {Promise<{session:string, done:boolean}>}
|
| 162 |
+
*/
|
| 163 |
+
async streamPostprocess(session, lang = "en", onEvent) {
|
| 164 |
+
const emit = typeof onEvent === "function" ? onEvent : () => {};
|
| 165 |
+
let client;
|
| 166 |
+
try {
|
| 167 |
+
client = await this.connect();
|
| 168 |
+
} catch (err) {
|
| 169 |
+
emit({ stage: "postprocess_error", error: `connect_failed: ${shortErr(err)}` });
|
| 170 |
+
return { session, done: false };
|
| 171 |
+
}
|
| 172 |
+
try {
|
| 173 |
+
const job = client.submit("/postprocess", { session: session || "", lang: lang || "en" });
|
| 174 |
+
for await (const msg of job) {
|
| 175 |
+
if (!msg || msg.type !== "data") continue;
|
| 176 |
+
const ev = asEvent(msg.data);
|
| 177 |
+
if (!ev || typeof ev !== "object") continue;
|
| 178 |
+
if (ev.session) session = ev.session;
|
| 179 |
+
try { emit(ev); } catch (e) { console.error("[LL.api] postprocess onEvent threw", e); }
|
| 180 |
+
}
|
| 181 |
+
} catch (err) {
|
| 182 |
+
emit({ stage: "postprocess_error", error: `stream_failed: ${shortErr(err)}` });
|
| 183 |
+
}
|
| 184 |
+
return { session, done: true };
|
| 185 |
+
},
|
| 186 |
+
|
| 187 |
/**
|
| 188 |
* Transcribe a base64 WAV (the narrator's mic audio) via Cohere Transcribe.
|
| 189 |
* Resolves with {text, error}. Used by LL.recorder; the transcript then drives
|
frontend/js/controller.js
CHANGED
|
@@ -149,6 +149,7 @@ const controller = {
|
|
| 149 |
});
|
| 150 |
on("showcase-btn", "click", () => this.playShowcase());
|
| 151 |
on("stop-btn", "click", () => this.reset());
|
|
|
|
| 152 |
on("rm-btn", "click", () => this.setReducedMotion(!this.reducedMotion));
|
| 153 |
on("about-btn", "click", () => this._about(true));
|
| 154 |
on("about-close", "click", () => this._about(false));
|
|
@@ -279,6 +280,50 @@ const controller = {
|
|
| 279 |
}
|
| 280 |
},
|
| 281 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
_onEvent(ev) {
|
| 283 |
if (!ev || typeof ev !== "object") return;
|
| 284 |
// Drop ghost WORLD events from a session that is no longer active. The live generator keeps
|
|
|
|
| 149 |
});
|
| 150 |
on("showcase-btn", "click", () => this.playShowcase());
|
| 151 |
on("stop-btn", "click", () => this.reset());
|
| 152 |
+
on("save-btn", "click", () => this._saveWorld());
|
| 153 |
on("rm-btn", "click", () => this.setReducedMotion(!this.reducedMotion));
|
| 154 |
on("about-btn", "click", () => this._about(true));
|
| 155 |
on("about-close", "click", () => this._about(false));
|
|
|
|
| 280 |
}
|
| 281 |
},
|
| 282 |
|
| 283 |
+
// ---- DIRECTOR'S CUT keepsake: save the finished world as a named fly-through MP4 (post-process; the live
|
| 284 |
+
// world keeps running). Captures _liveSession WITHOUT resetting it, so you can save AND keep speaking. ----
|
| 285 |
+
_saveWorld() {
|
| 286 |
+
const session = this._liveSession;
|
| 287 |
+
const es = this.lang === "es";
|
| 288 |
+
if (!session) { this._status(es ? "Habla primero para crear un mundo" : "Speak first to create a world", 2800); return; }
|
| 289 |
+
this._status(es ? "Creando tu recuerdo…" : "Crafting your keepsake…", 0);
|
| 290 |
+
LL.api.streamPostprocess(session, this.lang, (ev) => {
|
| 291 |
+
switch (ev && ev.stage) {
|
| 292 |
+
case "stitched": this._status(es ? "Uniendo tu mundo…" : "Stitching your world…", 0); break;
|
| 293 |
+
case "titled": if (ev.title) this._setTitle(ev.title); this._status(es ? "Nombrando y filmando…" : "Naming & filming…", 0); break;
|
| 294 |
+
case "rendering": this._status((es ? "Renderizando… " : "Rendering… ") + Math.round((100 * (ev.frame || 0)) / (ev.total || 1)) + "%", 0); break;
|
| 295 |
+
case "encoding": this._status(es ? "Codificando la película…" : "Encoding the film…", 0); break;
|
| 296 |
+
case "ready": this._showKeepsake(ev); break;
|
| 297 |
+
case "postprocess_error": case "quota_exceeded": case "error":
|
| 298 |
+
this._status(es ? "No se pudo guardar (intenta de nuevo)" : "Couldn't save (try again)", 3400); break;
|
| 299 |
+
default: break; // seer_skipped etc. -> the MP4 still renders with the existing title
|
| 300 |
+
}
|
| 301 |
+
});
|
| 302 |
+
},
|
| 303 |
+
|
| 304 |
+
/** Show the keepsake overlay with the named fly-through <video> + a download link. Built with DOM nodes
|
| 305 |
+
* (not innerHTML) so the model-authored title can never inject markup. */
|
| 306 |
+
_showKeepsake(ev) {
|
| 307 |
+
this._status("");
|
| 308 |
+
const card = document.getElementById("keepsake");
|
| 309 |
+
if (!card) return;
|
| 310 |
+
card.textContent = "";
|
| 311 |
+
const panel = document.createElement("div"); panel.className = "keepsake__panel";
|
| 312 |
+
const close = document.createElement("button");
|
| 313 |
+
close.className = "keepsake__close"; close.type = "button"; close.setAttribute("aria-label", "Close"); close.textContent = "✕";
|
| 314 |
+
close.addEventListener("click", () => { const v = card.querySelector("video"); if (v) { try { v.pause(); } catch (_) {} } card.hidden = true; });
|
| 315 |
+
const ttl = document.createElement("div"); ttl.className = "keepsake__title"; ttl.textContent = ev.title || "";
|
| 316 |
+
const video = document.createElement("video");
|
| 317 |
+
video.className = "keepsake__video"; video.src = ev.video || "";
|
| 318 |
+
video.controls = true; video.autoplay = true; video.loop = true; video.muted = true; video.playsInline = true;
|
| 319 |
+
const dl = document.createElement("a");
|
| 320 |
+
dl.className = "keepsake__dl"; dl.href = ev.video || ""; dl.setAttribute("download", "lightloom-world.mp4");
|
| 321 |
+
dl.textContent = this.lang === "es" ? "↓ Descargar tu mundo" : "↓ Download your world";
|
| 322 |
+
panel.append(close, ttl, video, dl);
|
| 323 |
+
card.append(panel);
|
| 324 |
+
card.hidden = false;
|
| 325 |
+
},
|
| 326 |
+
|
| 327 |
_onEvent(ev) {
|
| 328 |
if (!ev || typeof ev !== "object") return;
|
| 329 |
// Drop ghost WORLD events from a session that is no longer active. The live generator keeps
|