Files
2026-07-15 18:47:41 +02:00

701 lines
26 KiB
JavaScript

import { app } from "../../scripts/app.js";
/*
* Reframe360 — interactive preview.
*
* The fragment shader below is a port of projection.py. Same camera model,
* same lens maths, same ray-sphere trace. Keep them in step: if the preview
* and the render disagree, the node is worse than useless.
*
* No three.js. A textured sphere with a stock PerspectiveCamera cannot do
* fisheye projections or perspective_warp, so it would have to lie about
* exactly the parameters this node exists to control.
*/
const VERT = `#version 300 es
in vec2 p;
out vec2 uv;
void main() {
uv = p * 0.5 + 0.5;
gl_Position = vec4(p, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 uv;
out vec4 frag;
uniform sampler2D pano;
uniform vec2 uSensor; // effective sensor, mm
uniform float uFocal; // mm
uniform int uLens; // 0 rect, 1 stereo, 2 equisolid, 3 equidistant
uniform vec3 uRot; // yaw, pitch, roll (radians)
uniform float uWarp; // offset along the camera's OWN Z, fraction of radius
const float PI = 3.141592653589793;
void main() {
// --- sensor position in mm (mirrors build_direction_map) ---
vec2 c = vec2(uv.x - 0.5, uv.y - 0.5) * uSensor;
float r = length(c);
float phi = atan(c.y, c.x);
// --- lens projection: sensor radius -> angle off the optical axis ---
float theta;
bool ok = true;
if (uLens == 0) {
theta = atan(r, uFocal);
} else if (uLens == 1) {
theta = 2.0 * atan(r, 2.0 * uFocal);
} else if (uLens == 2) {
float s = r / (2.0 * uFocal);
ok = s <= 1.0;
theta = 2.0 * asin(min(s, 1.0));
} else {
theta = r / uFocal;
ok = theta <= PI;
theta = min(theta, PI);
}
if (!ok) { frag = vec4(0.0, 0.0, 0.0, 1.0); return; }
float st = sin(theta);
vec3 d = vec3(st * cos(phi), st * sin(phi), cos(theta));
// --- orientation: R = Ry(yaw) * Rx(pitch) * Rz(roll) ---
// mat3() fills COLUMNS, so these are the transposes of the Python rows.
float cy = cos(uRot.x), sy = sin(uRot.x);
float cp = cos(uRot.y), sp = sin(uRot.y);
float cr = cos(uRot.z), sr = sin(uRot.z);
mat3 Ry = mat3( cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
mat3 Rx = mat3(1.0, 0.0, 0.0, 0.0, cp, -sp, 0.0, sp, cp);
mat3 Rz = mat3( cr, sr, 0.0, -sr, cr, 0.0, 0.0, 0.0, 1.0);
mat3 R = Ry * Rx * Rz;
d = R * d;
// The camera slides along ITS OWN Z axis. Rotate the offset by the same
// matrix as the rays, or it stays pinned to world Z and slides sideways
// whenever you yaw away from front.
vec3 O = R * vec3(0.0, 0.0, uWarp);
// --- trace to the unit sphere ---
float b = dot(O, d);
float k = dot(O, O) - 1.0; // always negative: camera is inside
float t = -b + sqrt(b * b - k); // always exactly one positive root
vec3 P = d * t + O;
// --- hit point -> equirectangular UV ---
float lon = atan(P.x, P.z);
float lat = asin(clamp(P.y, -1.0, 1.0));
vec2 e = vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI);
frag = vec4(texture(pano, e).rgb, 1.0);
}`;
const LENS_INDEX = { rectilinear: 0, stereographic: 1, equisolid: 2, equidistant: 3 };
const SENSORS = {
"Full Frame (36 x 24)": [36.0, 24.0],
"Super35 (24.89 x 18.66)": [24.89, 18.66],
"ARRI ALEXA 35 (27.99 x 19.22)": [27.99, 19.22],
"ARRI ALEXA LF (36.70 x 25.54)": [36.7, 25.54],
"RED KOMODO 6K (27.03 x 14.26)": [27.03, 14.26],
"APS-C (23.6 x 15.6)": [23.6, 15.6],
"Micro Four Thirds (17.3 x 13)": [17.3, 13.0],
"Super16 (12.52 x 7.41)": [12.52, 7.41],
"1 inch (13.2 x 8.8)": [13.2, 8.8],
"iPhone main (9.8 x 7.3)": [9.8, 7.3],
Custom: [36.0, 24.0],
};
// Which widgets live under which group header, in node order.
const GROUPS = [
{
key: "lens",
label: "LENS",
members: [
"sensor", "sensor_fit", "sensor_width_custom", "sensor_height_custom",
"focal_length", "lens_projection",
],
},
{
key: "camera",
label: "CAMERA",
members: ["yaw", "pitch", "roll", "perspective_warp"],
},
{
key: "output",
label: "OUTPUT",
members: ["aspect_preset", "output_width", "output_height", "interpolation"],
},
{
key: "advanced",
label: "ADVANCED",
members: ["preview_max_width"],
collapsed: true,
},
];
// Mirror of resolve_sensor() in projection.py.
function resolveSensor(sw, sh, fit, ow, oh) {
const a = ow / oh;
if (fit === "auto") fit = ow >= oh ? "horizontal" : "vertical";
return fit === "horizontal" ? [sw, sw / a] : [sh * a, sh];
}
function compile(gl, src, type) {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
class PanoView {
constructor(canvas) {
this.canvas = canvas;
const gl = canvas.getContext("webgl2", { antialias: false, alpha: false });
if (!gl) throw new Error("WebGL2 unavailable");
this.gl = gl;
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl, VERT, gl.VERTEX_SHADER));
gl.attachShader(prog, compile(gl, FRAG, gl.FRAGMENT_SHADER));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
this.prog = prog;
gl.useProgram(prog);
gl.bindVertexArray(gl.createVertexArray());
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "p");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
this.tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.tex);
// REPEAT on S closes the 360 seam for free.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
this.u = {
sensor: gl.getUniformLocation(prog, "uSensor"),
focal: gl.getUniformLocation(prog, "uFocal"),
lens: gl.getUniformLocation(prog, "uLens"),
rot: gl.getUniformLocation(prog, "uRot"),
warp: gl.getUniformLocation(prog, "uWarp"),
};
this.ready = false;
}
setPano(src) {
return new Promise((res, rej) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
this.ready = true;
res();
};
img.onerror = rej;
img.src = src;
});
}
draw(p) {
if (!this.ready) return;
const gl = this.gl;
const [ew, eh] = resolveSensor(p.sw, p.sh, p.fit, p.ow, p.oh);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.useProgram(this.prog);
gl.uniform2f(this.u.sensor, ew, eh);
gl.uniform1f(this.u.focal, p.focal);
gl.uniform1i(this.u.lens, LENS_INDEX[p.lens] ?? 0);
gl.uniform3f(this.u.rot, (p.yaw * Math.PI) / 180, (p.pitch * Math.PI) / 180, (p.roll * Math.PI) / 180);
gl.uniform1f(this.u.warp, p.warp);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
// --- widget show/hide -------------------------------------------------------
// ComfyUI has moved this mechanism around between frontend versions. Setting
// only computeSize collapses the LAYOUT slot but leaves the widget DRAWING,
// which strands it outside the node. Hit every switch that any version reads.
const HIDDEN = "reframe360-hidden";
function setVisible(w, show) {
if (!w) return;
if (w._origType === undefined) {
w._origType = w.type;
w._origCompute = w.computeSize;
}
w.hidden = !show; // litegraph (newer)
w.type = show ? w._origType : HIDDEN; // litegraph (older)
w.computeSize = show ? w._origCompute : () => [0, -4];
if (w.options) w.options.hidden = !show; // Vue nodes frontend
if (w.element) w.element.style.display = show ? "" : "none"; // DOM widgets
}
// Angle from the optical axis for a point `mm` off centre on one sensor axis.
// Mirrors the lens maths in projection.py, applied per axis.
function axisAngle(mm, focal, lens) {
const a = Math.abs(mm);
let t;
if (lens === "stereographic") t = 2 * Math.atan(a / (2 * focal));
else if (lens === "equisolid") t = 2 * Math.asin(Math.min(a / (2 * focal), 1));
else if (lens === "equidistant") t = Math.min(a / focal, Math.PI);
else t = Math.atan(a / focal);
return (Math.sign(mm) * t * 180) / Math.PI;
}
// ---------------------------------------------------------------------------
// Preview.
//
// Drawn straight onto the LiteGraph canvas via onDrawForeground, NOT as a DOM
// widget. ComfyUI's DOM widget sizing varies between frontend versions and
// would not honour the output aspect, which stretched the canvas and made the
// image appear to warp under rotation. Drawing it ourselves means the box is
// exactly the aspect we say it is, on every version.
//
// The node's height is derived from its width and the output aspect, so the
// preview always fills the full width with no letterboxing and nothing to
// squash.
// ---------------------------------------------------------------------------
const PAD = 8;
const MAX_RENDER_W = 1280;
function inRect(pos, r) {
return r && pos[0] >= r.x && pos[0] <= r.x + r.w && pos[1] >= r.y && pos[1] <= r.y + r.h;
}
app.registerExtension({
name: "Reframe360.InteractivePreview",
async setup() {
// Final guard: repair every Reframe360 node just before the prompt is
// built, so a scrambled graph can never reach the server.
const origQueue = app.queuePrompt;
app.queuePrompt = async function () {
for (const n of app.graph?._nodes || []) {
if (n.comfyClass === "Reframe360") n._sanitize?.();
}
return origQueue.apply(this, arguments);
};
// LiteGraph doesn't route wheel events to nodes, so intercept at the
// canvas and hit-test ourselves. Capture phase, ahead of graph zoom.
const el = app.canvas?.canvas;
if (!el) return;
el.addEventListener("wheel", (e) => {
const gp = app.canvas.convertEventToCanvasOffset(e);
const node = app.graph.getNodeOnPos(gp[0], gp[1]);
if (!node || node.comfyClass !== "Reframe360" || node.flags?.collapsed) return;
const local = [gp[0] - node.pos[0], gp[1] - node.pos[1]];
if (!inRect(local, node._previewRect)) return;
const w = node.widgets?.find((x) => x.name === "focal_length");
if (!w) return;
// Geometric, so every notch is a constant ratio.
const v = w.value * (e.deltaY > 0 ? 1 / 1.08 : 1.08);
const lo = w.options?.min ?? 1, hi = w.options?.max ?? 300;
w.value = Math.round(Math.min(hi, Math.max(lo, v)) * 10) / 10;
w.callback?.(w.value, app.canvas, node);
node.setDirtyCanvas(true, true);
e.preventDefault();
e.stopPropagation();
}, { capture: true, passive: false });
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "Reframe360") return;
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onCreated?.apply(this, arguments);
const node = this;
const W = (n) => node.widgets?.find((w) => w.name === n);
node._W = W;
// Snapshot the defaults now, straight from INPUT_TYPES and before any
// configure() can touch them. This is what we fall back to when a saved
// graph turns out to be unusable.
node._defaults = {};
for (const w of node.widgets || []) node._defaults[w.name] = w.value;
// Widget values are serialized as a flat array indexed by POSITION.
// Our group headers live in the same array, so anything positional
// shifts every value by the number of headers above it. Repair by
// checking each value against its own declared options.
node._sanitize = () => {
for (const w of node.widgets || []) {
if (!w.name || w.name.startsWith("__")) continue;
const def = node._defaults[w.name];
if (def === undefined) continue;
let v = w.value;
if (Array.isArray(w.options?.values)) {
if (!w.options.values.includes(v)) v = def;
} else if (typeof def === "number") {
// Out of range means corrupted, not user intent -- the UI clamps
// live edits already. Fall back to the default rather than to the
// limit, or a scrambled 0 would silently become a 16px render.
const lo = w.options?.min, hi = w.options?.max;
if (typeof v !== "number" || !isFinite(v)) v = def;
else if (lo !== undefined && v < lo) v = def;
else if (hi !== undefined && v > hi) v = def;
}
if (v !== w.value) {
console.warn(`[Reframe360] repaired ${w.name}: ${JSON.stringify(w.value)} -> ${JSON.stringify(v)}`);
w.value = v;
}
}
};
// Offscreen. Never enters the DOM, so nothing can lay it out but us.
const gl = document.createElement("canvas");
gl.width = 640;
gl.height = 360;
try {
node._panoView = new PanoView(gl);
node._glCanvas = gl;
} catch (e) {
node._panoView = null;
console.error("[Reframe360]", e);
}
node._params = () => {
const preset = W("sensor")?.value ?? "Full Frame (36 x 24)";
const [psw, psh] = SENSORS[preset] ?? [36, 24];
const custom = preset === "Custom";
return {
sw: custom ? W("sensor_width_custom")?.value ?? 36 : psw,
sh: custom ? W("sensor_height_custom")?.value ?? 24 : psh,
fit: W("sensor_fit")?.value ?? "auto",
focal: W("focal_length")?.value ?? 18,
lens: W("lens_projection")?.value ?? "rectilinear",
yaw: W("yaw")?.value ?? 0,
pitch: W("pitch")?.value ?? 0,
roll: W("roll")?.value ?? 0,
warp: W("perspective_warp")?.value ?? 0,
ow: W("output_width")?.value ?? 1920,
oh: W("output_height")?.value ?? 1080,
};
};
// ---------------- grouping ----------------
node._groupState = {};
GROUPS.forEach((g) => (node._groupState[g.key] = !g.collapsed));
node._applyVisibility = () => {
const isCustomSensor = W("sensor")?.value === "Custom";
const drivenAspect = (W("aspect_preset")?.value ?? "16:9") !== "Custom";
GROUPS.forEach((g) => {
const open = node._groupState[g.key];
g.members.forEach((name) => {
let show = open;
// Contextual: these only matter in one situation, so don't make
// people look at them the rest of the time.
if (name === "sensor_width_custom" || name === "sensor_height_custom") {
show = open && isCustomSensor;
}
if (name === "output_height" && drivenAspect) show = false;
setVisible(W(name), show);
});
const header = W("__" + g.key);
if (header) header.name = `${open ? "\u25BC" : "\u25B6"} ${g.label}`;
});
node._fitHeight();
};
GROUPS.forEach((g) => {
const btn = node.addWidget("button", `\u25BC ${g.label}`, null, () => {
node._groupState[g.key] = !node._groupState[g.key];
node._applyVisibility();
node.setDirtyCanvas(true, true);
});
btn.name = "__" + g.key;
// Both flags: litegraph reads one, graphToPrompt reads the other.
btn.serialize = false;
btn.options = { serialize: false };
node.widgets.pop();
const at = node.widgets.findIndex((w) => w.name === g.members[0]);
node.widgets.splice(at < 0 ? node.widgets.length : at, 0, btn);
});
// ---------------- layout ----------------
// Height is a function of width and output aspect. Nothing to stretch:
// the box IS the aspect, so the preview can only ever be pixel-correct.
node._fitHeight = () => {
const p = node._params();
const aspect = p.ow / p.oh;
const baseH = node.computeSize()[1]; // title + slots + widgets
const pw = Math.max(32, node.size[0] - PAD * 2);
const ph = pw / aspect;
node._previewRect = { x: PAD, y: baseH, w: pw, h: ph };
node.size[1] = baseH + ph + PAD;
};
const onWidget = (name, extra) => {
const w = W(name);
if (!w) return;
const prev = w.callback;
w.callback = function () {
const r = prev?.apply(this, arguments);
extra?.();
node.setDirtyCanvas(true, true);
return r;
};
};
[
"sensor_fit", "focal_length", "lens_projection", "yaw", "pitch", "roll",
"perspective_warp", "sensor_width_custom", "sensor_height_custom",
"interpolation",
].forEach((n) => onWidget(n));
onWidget("sensor", () => node._applyVisibility());
// Aspect preset drives height, and height drives the preview box shape.
const syncAspect = () => {
const A = {
"16:9": 16 / 9, "2.39:1 (scope)": 2.39, "1.85:1 (flat)": 1.85,
"4:3": 4 / 3, "1:1": 1, "9:16 (vertical)": 9 / 16, "4:5 (social)": 4 / 5,
};
const a = A[W("aspect_preset")?.value];
if (a) {
const hw = W("output_height");
if (hw) hw.value = Math.max(2, Math.round(W("output_width").value / a / 2) * 2);
}
node._applyVisibility();
};
onWidget("aspect_preset", syncAspect);
onWidget("output_width", syncAspect);
onWidget("output_height", () => node._fitHeight());
node.size[0] = Math.max(node.size[0], 380);
setTimeout(() => {
node._sanitize();
node._applyVisibility();
node._tryLoadPano?.();
node.setDirtyCanvas(true, true);
}, 0);
};
// Height follows width. Dragging the node taller just snaps back, because
// any other height would mean letterboxing or stretching.
const onResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function (size) {
onResize?.apply(this, arguments);
this._fitHeight?.();
};
nodeType.prototype.onDrawForeground = function (ctx) {
if (this.flags.collapsed || !this._previewRect) return;
const r = this._previewRect;
const p = this._params();
const view = this._panoView;
ctx.save();
ctx.beginPath();
ctx.roundRect?.(r.x, r.y, r.w, r.h, 4);
if (!ctx.roundRect) ctx.rect(r.x, r.y, r.w, r.h);
ctx.fillStyle = "#0d0d0d";
ctx.fill();
ctx.clip();
if (view?.ready) {
// Render at the box's exact aspect. Backing store tracks the box, so
// the drawImage below is always 1:1 -- never anisotropic.
const scale = Math.min(1, MAX_RENDER_W / r.w);
const gw = Math.max(2, Math.round(r.w * scale));
const gh = Math.max(2, Math.round(r.h * scale));
const c = this._glCanvas;
if (c.width !== gw || c.height !== gh) {
c.width = gw;
c.height = gh;
}
view.draw(p);
ctx.drawImage(c, r.x, r.y, r.w, r.h);
let hfov;
const [ew] = resolveSensor(p.sw, p.sh, p.fit, p.ow, p.oh);
if (p.lens === "rectilinear") hfov = (2 * Math.atan(ew / 2 / p.focal) * 180) / Math.PI;
else if (p.lens === "stereographic") hfov = (4 * Math.atan(ew / 4 / p.focal) * 180) / Math.PI;
else if (p.lens === "equisolid") hfov = (4 * Math.asin(Math.min(ew / 4 / p.focal, 1)) * 180) / Math.PI;
else hfov = Math.min(((ew / p.focal) * 180) / Math.PI, 360);
const txt =
`${p.focal.toFixed(1)}mm ${hfov.toFixed(1)}\u00B0 ` +
`y${p.yaw.toFixed(1)} p${p.pitch.toFixed(1)} r${p.roll.toFixed(1)}` +
(Math.abs(p.warp) > 0.001 ? ` warp ${p.warp.toFixed(3)}` : "");
ctx.font = "10px ui-monospace, monospace";
const tw = ctx.measureText(txt).width;
ctx.fillStyle = "rgba(0,0,0,.6)";
ctx.fillRect(r.x + 4, r.y + r.h - 18, tw + 10, 14);
ctx.fillStyle = "#ccc";
ctx.fillText(txt, r.x + 9, r.y + r.h - 7);
} else {
ctx.fillStyle = "#666";
ctx.font = "11px sans-serif";
ctx.textAlign = "center";
ctx.fillText("Connect a 360 image and run once.", r.x + r.w / 2, r.y + r.h / 2);
ctx.textAlign = "left";
}
ctx.restore();
};
// ---------------- drag ----------------
// Cursor position -> ray angles, so the pixel you grab stays under the
// cursor. A linear dx*fov mapping is only right dead centre and overshoots
// badly toward the edges -- that was the "sliding hand".
nodeType.prototype._cursorAngles = function (pos) {
const r = this._previewRect;
const p = this._params();
const [ew, eh] = resolveSensor(p.sw, p.sh, p.fit, p.ow, p.oh);
const nx = (pos[0] - r.x) / r.w - 0.5;
const ny = 0.5 - (pos[1] - r.y) / r.h;
return { ax: axisAngle(nx * ew, p.focal, p.lens), ay: axisAngle(ny * eh, p.focal, p.lens) };
};
nodeType.prototype.onMouseDown = function (e, pos) {
if (!inRect(pos, this._previewRect) || !this._panoView?.ready) return false;
const p = this._params();
const a = this._cursorAngles(pos);
this._drag = {
ax: a.ax, ay: a.ay, pos: [pos[0], pos[1]],
yaw: p.yaw, pitch: p.pitch, roll: p.roll,
mode: e.shiftKey || e.which === 3 ? "roll" : "orbit",
};
// Keep receiving move events after the cursor leaves the node rect,
// otherwise a fast drag dies halfway.
this.captureInput?.(true);
return true; // consume, so the node doesn't get dragged around
};
nodeType.prototype.onMouseMove = function (e, pos) {
if (!this._drag) return false;
const W = this._W;
const set = (n, v) => {
const w = W(n);
if (!w) return;
const lo = w.options?.min ?? -Infinity, hi = w.options?.max ?? Infinity;
w.value = Math.min(hi, Math.max(lo, v));
};
const r = this._previewRect;
if (this._drag.mode === "roll") {
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
const a0 = Math.atan2(this._drag.pos[1] - cy, this._drag.pos[0] - cx);
const a1 = Math.atan2(pos[1] - cy, pos[0] - cx);
set("roll", this._drag.roll + ((a1 - a0) * 180) / Math.PI);
} else {
// Difference the true ray angles. Both axes subtract, so the image
// tracks your hand: drag down and you look up.
const a = this._cursorAngles(pos);
set("yaw", this._drag.yaw - (a.ax - this._drag.ax));
set("pitch", this._drag.pitch - (a.ay - this._drag.ay));
}
const yw = W("yaw");
if (yw) {
if (yw.value > 180) yw.value -= 360;
if (yw.value < -180) yw.value += 360;
}
this.setDirtyCanvas(true, true);
return true;
};
nodeType.prototype.onMouseUp = function () {
if (!this._drag) return false;
this._drag = null;
this.captureInput?.(false);
// Commit through the callbacks so the graph registers the change.
["yaw", "pitch", "roll"].forEach((n) => {
const w = this._W(n);
w?.callback?.(w.value, app.canvas, this);
});
this.setDirtyCanvas(true, true);
return true;
};
nodeType.prototype.onDblClick = function (e, pos) {
if (!inRect(pos, this._previewRect)) return false;
["yaw", "pitch", "roll", "perspective_warp"].forEach((n) => {
const w = this._W(n);
if (!w) return;
w.value = 0;
w.callback?.(0, app.canvas, this);
});
this.setDirtyCanvas(true, true);
return true;
};
// ---------------- pano ----------------
// Pull the cached pano over HTTP rather than pushing base64 through the
// socket. Survives page reload; no re-run needed.
nodeType.prototype._tryLoadPano = function () {
if (!this._panoView || this.id == null || this.id === -1) return;
const url = `/reframe360/pano/${this.id}?t=${Date.now()}`;
fetch(url, { method: "HEAD" })
.then((r) => {
if (!r.ok) return;
return this._panoView.setPano(url).then(() => this.setDirtyCanvas(true, true));
})
.catch(() => {});
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (msg) {
onExecuted?.apply(this, arguments);
if (msg?.pano_ready) this._tryLoadPano?.();
};
// Store values keyed by NAME alongside litegraph's positional array.
// Names survive reordering the inputs; positions do not.
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (o) {
onSerialize?.apply(this, arguments);
o.reframe360 = {};
for (const w of this.widgets || []) {
if (!w.name || w.name.startsWith("__")) continue;
o.reframe360[w.name] = w.value;
}
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (o) {
onConfigure?.apply(this, arguments);
// litegraph has already dealt out widgets_values by index, which our
// headers make meaningless. Overwrite from the name map.
const saved = o?.reframe360;
if (saved) {
for (const [name, val] of Object.entries(saved)) {
const w = this._W?.(name);
if (w) w.value = val;
}
}
// Belt and braces: repairs graphs saved before this fix, and anything
// the name map didn't cover.
this._sanitize?.();
setTimeout(() => {
this._applyVisibility?.();
this._tryLoadPano?.();
this.setDirtyCanvas(true, true);
}, 0);
};
},
});