Efradeca commited on
Commit
fea3388
·
verified ·
1 Parent(s): 85ede0d

chore: deploy private lightloom build

Browse files
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