diff --git a/web/js/gaussian_preview.js b/web/js/gaussian_preview.js
index 514f446..95f14ea 100644
--- a/web/js/gaussian_preview.js
+++ b/web/js/gaussian_preview.js
@@ -15,6 +15,63 @@ const EXTENSION_FOLDER = (() => {
console.log("[GaussianPack] Loading extension...");
+// Build a small WASD / arrows / Shift+Space+C keypad that lights up as keys
+// are pressed (driven by KEY_STATE messages the viewer iframe forwards). Handy
+// for screen-recording navigation. Returns { el, set(code,on), reset() }.
+function makeKeyPad() {
+ const codes = {};
+ const wrap = document.createElement("div");
+ Object.assign(wrap.style, {
+ display: "flex", gap: "10px", alignItems: "center",
+ padding: "4px 10px", flexShrink: "0", background: "#1a1a1a",
+ borderTop: "1px solid #444", borderLeft: "1px solid #333",
+ });
+ const keyEl = (label, code, w = 18) => {
+ const k = document.createElement("div");
+ k.textContent = label;
+ Object.assign(k.style, {
+ minWidth: w + "px", height: "18px", lineHeight: "16px", textAlign: "center",
+ fontSize: "10px", fontFamily: "monospace", color: "#aaa",
+ background: "#2a2a2a", border: "1px solid #444", borderRadius: "3px",
+ padding: "0 3px", boxSizing: "border-box", transition: "all 0.04s",
+ });
+ codes[code] = k;
+ return k;
+ };
+ const grid = (rows) => {
+ const g = document.createElement("div");
+ Object.assign(g.style, { display: "grid", gridTemplateColumns: "repeat(3, 18px)", gap: "2px" });
+ rows.forEach(r => r.forEach(cell => g.appendChild(cell || document.createElement("div"))));
+ return g;
+ };
+ const wasd = grid([
+ [null, keyEl("W", "KeyW"), null],
+ [keyEl("A", "KeyA"), keyEl("S", "KeyS"), keyEl("D", "KeyD")],
+ ]);
+ const arrows = grid([
+ [null, keyEl("↑", "ArrowUp"), null],
+ [keyEl("←", "ArrowLeft"), keyEl("↓", "ArrowDown"), keyEl("→", "ArrowRight")],
+ ]);
+ const extra = document.createElement("div");
+ Object.assign(extra.style, { display: "flex", gap: "2px", alignItems: "center" });
+ const shift = keyEl("⇧", "ShiftLeft", 24);
+ codes["ShiftRight"] = shift; // both shifts light the same key
+ extra.appendChild(shift);
+ extra.appendChild(keyEl("Spc", "Space", 26));
+ extra.appendChild(keyEl("C", "KeyC"));
+ wrap.append(wasd, arrows, extra);
+
+ const set = (code, on) => {
+ const el = codes[code];
+ if (!el) return;
+ el.style.background = on ? "#00e0ff" : "#2a2a2a";
+ el.style.color = on ? "#002b30" : "#aaa";
+ el.style.borderColor = on ? "#00e0ff" : "#444";
+ };
+ const reset = () => Object.keys(codes).forEach(c => set(c, false));
+ return { el: wrap, set, reset };
+}
+
app.registerExtension({
name: "gaussianpack.previewgaussians",
@@ -302,10 +359,19 @@ app.registerExtension({
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = 'Gaussian splat info will appear here after execution';
- // Add iframe + info panel + loading-bar overlay to container.
+ // Footer = info panel (left, grows) + key-press indicator (right).
+ const keyPad = makeKeyPad();
+ infoPanel.style.flex = "1 1 auto";
+ const footer = document.createElement("div");
+ Object.assign(footer.style, { display: "flex", alignItems: "stretch", flexShrink: "0" });
+ footer.appendChild(infoPanel);
+ footer.appendChild(keyPad.el);
+ this.gaussianKeyPad = keyPad;
+
+ // Add iframe + footer + loading-bar overlay to container.
// loadBar last so its overlay sits above the iframe.
container.appendChild(iframe);
- container.appendChild(infoPanel);
+ container.appendChild(footer);
container.appendChild(loadBar);
// --- Persistent camera-state plumbing ---------------------
@@ -397,6 +463,12 @@ app.registerExtension({
// node's state.
if (event.source !== iframe.contentWindow) return;
+ // Key-press indicator: light up the footer keypad.
+ if (event.data?.type === "KEY_STATE") {
+ this.gaussianKeyPad?.set(event.data.code, !!event.data.down);
+ return;
+ }
+
// Camera-state pushes from iframe -> persist on
// node.properties["Camera Config"] (Load3D pattern).
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
@@ -752,8 +824,17 @@ app.registerExtension({
infoPanel.style.cssText = "background:#1a1a1a;border-top:1px solid #444;padding:6px 12px;font:10px monospace;color:#ccc;line-height:1.3;flex-shrink:0;overflow:hidden;";
infoPanel.innerHTML = 'Dual view info will appear after execution';
+ // Footer = info panel (left) + key-press indicator (right).
+ const keyPad = makeKeyPad();
+ infoPanel.style.flex = "1 1 auto";
+ const footer = document.createElement("div");
+ Object.assign(footer.style, { display: "flex", alignItems: "stretch", flexShrink: "0" });
+ footer.appendChild(infoPanel);
+ footer.appendChild(keyPad.el);
+ this.gaussianKeyPad = keyPad;
+
container.appendChild(iframe);
- container.appendChild(infoPanel);
+ container.appendChild(footer);
container.appendChild(lb1.bar);
container.appendChild(lb2.bar);
@@ -775,6 +856,10 @@ app.registerExtension({
this.properties = this.properties || {};
window.addEventListener('message', (event) => {
if (event.source !== iframe.contentWindow) return;
+ if (event.data?.type === "KEY_STATE") {
+ this.gaussianKeyPad?.set(event.data.code, !!event.data.down);
+ return;
+ }
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
node.properties["Camera Config"] = {
cameraType: "perspective",
diff --git a/web/viewer_gaussian.html b/web/viewer_gaussian.html
index aa1e9e9..c2e513f 100644
--- a/web/viewer_gaussian.html
+++ b/web/viewer_gaussian.html
@@ -257,6 +257,18 @@
const VIEWER_MODE = _urlParams.get("mode") === "spectate" ? "spectate" : "trackball";
const SPECTATE_INITIAL_SPEED = parseFloat(_urlParams.get("speed") || "2.0");
+ // Forward navigation key state to the parent node so it can light up its
+ // on-screen keypad indicator (useful for screen recordings).
+ (() => {
+ const _down = new Set();
+ const fwd = (code, down) => {
+ try { window.parent.postMessage({ type: "KEY_STATE", code, down }, "*"); } catch (_) {}
+ };
+ window.addEventListener("keydown", (e) => { if (!_down.has(e.code)) { _down.add(e.code); fwd(e.code, true); } });
+ window.addEventListener("keyup", (e) => { if (_down.delete(e.code)) fwd(e.code, false); });
+ window.addEventListener("blur", () => { _down.forEach(c => fwd(c, false)); _down.clear(); });
+ })();
+
// ============================================================
// SpectateControls — TrackballControls-compatible API for fly-cam.
//
@@ -390,6 +402,9 @@
this._scratchFwd.set(0, 0, -1).applyQuaternion(this.camera.quaternion);
this._scratchRight.set(1, 0, 0).applyQuaternion(this.camera.quaternion);
+ // Up/down is relative to the camera's CURRENT orientation, not a
+ // fixed world axis, so Space/C follow where you're looking now.
+ this._scratchUp.set(0, 1, 0).applyQuaternion(this.camera.quaternion);
this._scratchMove.set(0, 0, 0);
if (this._keys.has("KeyW")) this._scratchMove.add(this._scratchFwd);
@@ -948,18 +963,21 @@
const fwd = new pc.Vec3();
const right = new pc.Vec3();
+ const up = new pc.Vec3();
cam.getWorldTransform().getZ(fwd);
cam.getWorldTransform().getX(right);
+ cam.getWorldTransform().getY(up); // camera's CURRENT up
fwd.mulScalar(-1);
- // WASD: move camera
+ // WASD: move camera. Space/C use the camera's current up axis, so
+ // up/down is relative to where you're looking now, not world Y.
let mx = 0, my = 0, mz = 0;
if (this._keys.has("KeyW")) { mx += fwd.x; my += fwd.y; mz += fwd.z; }
if (this._keys.has("KeyS")) { mx -= fwd.x; my -= fwd.y; mz -= fwd.z; }
if (this._keys.has("KeyD")) { mx += right.x; my += right.y; mz += right.z; }
if (this._keys.has("KeyA")) { mx -= right.x; my -= right.y; mz -= right.z; }
- if (this._keys.has("Space")) { my += 1; }
- if (this._keys.has("KeyC")) { my -= 1; }
+ if (this._keys.has("Space")) { mx += up.x; my += up.y; mz += up.z; }
+ if (this._keys.has("KeyC")) { mx -= up.x; my -= up.y; mz -= up.z; }
const lenSq = mx*mx + my*my + mz*mz;
if (lenSq > 0) {
diff --git a/web/viewer_gaussian_dual.html b/web/viewer_gaussian_dual.html
index 54a2c6a..3999092 100644
--- a/web/viewer_gaussian_dual.html
+++ b/web/viewer_gaussian_dual.html
@@ -111,6 +111,18 @@ const LAYOUT = params.get("layout") || "side_by_side";
const MODE = params.get("mode") === "spectate" ? "spectate" : "orbit";
const SPECTATE_INITIAL_SPEED = parseFloat(params.get("speed") || "2.0");
+// Forward navigation key state to the parent node so it can light up its
+// on-screen keypad indicator (useful for screen recordings).
+(() => {
+ const _down = new Set();
+ const fwd = (code, down) => {
+ try { window.parent.postMessage({ type: "KEY_STATE", code, down }, "*"); } catch (_) {}
+ };
+ window.addEventListener("keydown", (e) => { if (!_down.has(e.code)) { _down.add(e.code); fwd(e.code, true); } });
+ window.addEventListener("keyup", (e) => { if (_down.delete(e.code)) fwd(e.code, false); });
+ window.addEventListener("blur", () => { _down.forEach(c => fwd(c, false)); _down.clear(); });
+})();
+
const container = document.getElementById('viewerContainer');
const vp1 = document.getElementById('vp1');
const vp2 = document.getElementById('vp2');
@@ -420,6 +432,9 @@ class SpectateControls {
this._fwd.set(0, 0, -1).applyQuaternion(this.camera.quaternion);
this._right.set(1, 0, 0).applyQuaternion(this.camera.quaternion);
+ // Up/down relative to the camera's CURRENT orientation, not a fixed
+ // world axis, so Space/C follow where you're looking now.
+ this._up.set(0, 1, 0).applyQuaternion(this.camera.quaternion);
this._move.set(0, 0, 0);
if (this._keys.has("KeyW")) this._move.add(this._fwd);
if (this._keys.has("KeyS")) this._move.sub(this._fwd);
diff --git a/workflows/spectate.json b/workflows/spectate.json
index d23d5b3..509c865 100644
--- a/workflows/spectate.json
+++ b/workflows/spectate.json
@@ -1 +1 @@
-{"id":"6662828f-82cb-4faa-8977-cd10a1b9d52b","revision":0,"last_node_id":2,"last_link_id":1,"nodes":[{"id":1,"type":"LoadPLY","pos":[-143.84952498754797,190.94188881558176],"size":[270,58],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"ply_file","name":"ply_file","type":"COMBO","widget":{"name":"ply_file"},"link":null}],"outputs":[{"localized_name":"ply_path","name":"ply_path","type":"STRING","links":[1]}],"properties":{"cnr_id":"ComfyUI-GaussianPack","ver":"aba5d58066450088421d87a680e57ae599d92a1b","Node name for S&R":"LoadPLY"},"widgets_values":["compressed_room.ply"]},{"id":2,"type":"PreviewGaussians","pos":[164.8117698509801,184.28127124793232],"size":[512,714],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"ply_path","name":"ply_path","type":"STRING","link":1},{"localized_name":"camera_mode","name":"camera_mode","type":"COMBO","widget":{"name":"camera_mode"},"link":null},{"localized_name":"fov_degrees","name":"fov_degrees","type":"FLOAT","widget":{"name":"fov_degrees"},"link":null},{"localized_name":"renderer","name":"renderer","type":"COMBO","widget":{"name":"renderer"},"link":null},{"localized_name":"transport_format","name":"transport_format","type":"COMBO","widget":{"name":"transport_format"},"link":null}],"outputs":[],"properties":{"cnr_id":"ComfyUI-GaussianPack","ver":"aba5d58066450088421d87a680e57ae599d92a1b","Node name for S&R":"PreviewGaussians","Widget Values":{"camera_mode":"spectate","fov_degrees":70,"renderer":"playcanvas","transport_format":"ply"},"Camera Config":{"cameraType":"perspective","fov":50,"state":"{\"v\":1,\"pos\":[0.5147809982299805,-0.3441455066204071,-0.2638316750526428],\"target\":[0,0,1],\"fov\":50,\"scale\":20,\"opacity\":1}"}},"widgets_values":["spectate",70,"playcanvas","ply",""]}],"links":[[1,1,0,2,0,"STRING"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.7970122433967322,"offset":[526.1427860582855,186.55364205821778]}},"version":0.4}
\ No newline at end of file
+{"id":"6662828f-82cb-4faa-8977-cd10a1b9d52b","revision":0,"last_node_id":2,"last_link_id":1,"nodes":[{"id":1,"type":"LoadPLY","pos":[-143.84952498754797,190.94188881558176],"size":[270,58],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"ply_file","name":"ply_file","type":"COMBO","widget":{"name":"ply_file"},"link":null}],"outputs":[{"localized_name":"ply_path","name":"ply_path","type":"STRING","links":[1]}],"properties":{"cnr_id":"ComfyUI-GaussianPack","ver":"aba5d58066450088421d87a680e57ae599d92a1b","Node name for S&R":"LoadPLY"},"widgets_values":["compressed_room.ply"]},{"id":2,"type":"PreviewGaussians","pos":[164.8117698509801,184.28127124793232],"size":[807.3472077244259,714],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"ply_path","name":"ply_path","type":"STRING","link":1},{"localized_name":"camera_mode","name":"camera_mode","type":"COMBO","widget":{"name":"camera_mode"},"link":null},{"localized_name":"fov_degrees","name":"fov_degrees","type":"FLOAT","widget":{"name":"fov_degrees"},"link":null},{"localized_name":"renderer","name":"renderer","type":"COMBO","widget":{"name":"renderer"},"link":null},{"localized_name":"transport_format","name":"transport_format","type":"COMBO","widget":{"name":"transport_format"},"link":null}],"outputs":[],"properties":{"cnr_id":"ComfyUI-GaussianPack","ver":"aba5d58066450088421d87a680e57ae599d92a1b","Node name for S&R":"PreviewGaussians","Widget Values":{"camera_mode":"spectate","fov_degrees":70,"renderer":"playcanvas","transport_format":"ply"},"Camera Config":{"cameraType":"perspective","fov":50,"state":"{\"v\":1,\"pos\":[-2.3301117420196533,0.8196620941162109,1.912135362625122],\"target\":[0,0,1],\"fov\":50,\"scale\":20,\"opacity\":1}"}},"widgets_values":["spectate",70,"playcanvas","ply",""]}],"links":[[1,1,0,2,0,"STRING"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.9404450261780105,"offset":[314.09923160447045,0.2480432962569592]}},"version":0.4}
\ No newline at end of file