// Photo mode (spec M14, lane F). // // 📷 / "P": pause the transport if playing, hide every helper (grid/axes/frusta/gizmos/anchor // labels — scene3d.setHelpersVisible(false), one call), render ONE frame at 3840 px wide // (preserving the pane's aspect) by temporarily resizing scene3d.renderer with pixelRatio 1, // snapshot it via canvas.toBlob (the copy is taken synchronously at call time, so it is safe // to restore the renderer immediately after — encoding continues async), download // festival4d-photo.png, then restore helpers / size / pixelRatio / camera aspect / play state. // // Splat note (pitfall #5): the DropInViewer is a plain child of scene3d.scene and renders with // the same renderer.render(scene, camera) call, so no splat-specific branch exists here; live // verification on a trained splat is deferred to the coordinator. // // No write-ui class: photo mode is read-only and stays available in capsule bundles. import { state } from "./state.js"; import { transport } from "./transport.js"; import { scene3d } from "./scene3d.js"; const PHOTO_WIDTH = 3840; export const photoMode = { ready: false, _busy: false, init() { const btn = document.getElementById("btn-photo"); if (!btn) return; // pre-wired DOM missing — degrade to hidden btn.addEventListener("click", () => this.shoot()); window.addEventListener("keydown", (e) => { if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return; if (e.code === "KeyP" && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); this.shoot(); } }); btn.style.display = ""; // unhide: the module is live this.ready = true; }, /** Take the hi-res still. Synchronous through the render + snapshot so no animation frame * (main.js loop) can interleave between resize, render, and capture. */ shoot() { if (this._busy) return; const renderer = scene3d.renderer; const camera = scene3d.camera; if (!renderer || !camera || !scene3d.scene) return; // scene not booted yet this._busy = true; // --- save everything we are about to touch --- const wasPlaying = state.playing; const prevHelpers = scene3d.helpersVisible; const prevRatio = renderer.getPixelRatio(); const prevSize = { w: 0, h: 0 }; { // getSize wants a Vector2; a duck-typed target keeps this module three-free. const v = { x: 0, y: 0, set(x, y) { this.x = x; this.y = y; return this; } }; renderer.getSize(v); prevSize.w = v.x; prevSize.h = v.y; } const prevAspect = camera.aspect; try { if (wasPlaying) transport.pause(); scene3d.setHelpersVisible(false); // setHelpersVisible hides grid/axes/gizmos/label-sprites immediately, but the per-video // rig groups (frusta/markers/path lines) get their visibility recomputed inside // scene3d.update(). Run one update at the CURRENT size so the rigs honor the flag // before the hi-res render below. scene3d.update(); const w = Math.max(1, prevSize.w); const h = Math.max(1, prevSize.h); const outW = PHOTO_WIDTH; const outH = Math.max(1, Math.round((PHOTO_WIDTH * h) / w)); renderer.setPixelRatio(1); renderer.setSize(outW, outH, false); camera.aspect = outW / outH; camera.updateProjectionMatrix(); renderer.render(scene3d.scene, camera); // Snapshot NOW (same task as the render — the WebGL buffer is still valid). renderer.domElement.toBlob((blob) => { try { if (!blob) { console.warn("[photoMode] toBlob returned null (buffer too large for this GPU?)"); return; } const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "festival4d-photo.png"; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 10_000); } finally { this._busy = false; } }, "image/png"); } catch (err) { console.warn("[photoMode] capture failed", err); this._busy = false; } finally { // --- restore ALL of it (snapshot already copied; safe immediately) --- try { renderer.setPixelRatio(prevRatio); renderer.setSize(prevSize.w, prevSize.h, false); camera.aspect = prevAspect; camera.updateProjectionMatrix(); scene3d.setHelpersVisible(prevHelpers); if (wasPlaying) transport.play(); } catch (err) { console.warn("[photoMode] restore failed", err); } } }, };