diff --git a/frontend/src/photoMode.js b/frontend/src/photoMode.js index 36ff2e1..1688c91 100644 --- a/frontend/src/photoMode.js +++ b/frontend/src/photoMode.js @@ -1,18 +1,117 @@ -// Photo mode (spec M14, lane F). STUB — lane F fills THIS FILE ONLY. +// Photo mode (spec M14, lane F). // -// Pre-wired by foundation2: the hidden 📷 button (#btn-photo in index.html), this module's -// import + init() call in main.js, and the hook `scene3d.setHelpersVisible(false)` which -// hides grid/axes/frusta/path-lines/keyframe-gizmos/anchor-labels in one call (restore with -// true). +// 📷 / "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. // -// When implementing: unhide the button, set `ready = true`, bind key "P" too; on trigger -// pause the transport, setHelpersVisible(false), render ONE frame at 3840-wide (preserve -// aspect) to an offscreen WebGLRenderTarget via scene3d.renderer/scene/camera, download the -// PNG, then restore everything (helpers, renderer size, play state). Works in free-roam and -// follow-cam; with a splat present it renders the splat (no splat -> the point cloud — -// pitfall #5). +// 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, - init() {}, // ponytail: stub — lane F replaces the body + _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); + } + } + }, };