M14: photo mode (lane F)
📷 / P: pause if playing, scene3d.setHelpersVisible(false) + one scene3d
update so the per-video rigs honor the flag, then render ONE frame at 3840px
wide (aspect preserved, pixelRatio 1) by temporarily resizing the live
renderer, snapshot via canvas.toBlob in the same task, download
festival4d-photo.png, and restore pixelRatio/size/camera.aspect/helpers/play
state (try/finally). Splat: DropInViewer is a scene child and renders with
the same call — no branch; live splat verification deferred to coordinator.
Live-verified: PNG 3840x3093 (aspect 1.2416 preserved), zero helpers in the
captured image, full restore incl. play state (plan/status/lane-F.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
13b7424c83
commit
1b97086145
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user