Files
supart-ComfyUI-TY360-Photo-…/web/pano_preview_runtime.js
T

3921 lines
139 KiB
JavaScript

import * as appModule from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { drawCutoutProjectionPreview } from "./pano_cutout_projection.js";
import { renderCutoutViewToContext2D, renderErpViewToContext2D, renderSceneToContext2D } from "./pano_gl_viewport.js";
import {
createPanoInteractionController,
PANO_DRAG_SENSITIVITY,
PANO_WHEEL_STEP,
PANO_FOV_MIN,
PANO_FOV_MAX,
PANO_INERTIA_BLEND_INST,
PANO_INERTIA_DAMPING,
PANO_INERTIA_START_SPEED,
PANO_INERTIA_STOP_SPEED,
PANO_MODAL_PARITY_CONSTANTS,
} from "./pano_interaction_controller.js";
import { clamp, wrapYaw } from "./pano_math.js";
import { isPanoramaPreviewNodeName } from "./pano_preview_identity.js";
import {
buildCutoutViewParamsFromShot,
buildPanoramaViewParamsFromRuntime,
buildStickerSceneFromState,
buildStickerTexturesFromState,
} from "./pano_gl_scene.js";
const { app } = appModule;
function getAnimPreviewWidgetName() {
return String(
window?.comfyAPI?.app?.ANIM_PREVIEW_WIDGET
|| appModule?.ANIM_PREVIEW_WIDGET
|| "$$comfy_animation_preview"
);
}
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const STANDALONE_DRAG_SENSITIVITY = PANO_DRAG_SENSITIVITY;
const STANDALONE_WHEEL_STEP = PANO_WHEEL_STEP;
const STANDALONE_FOV_MIN = PANO_FOV_MIN;
const STANDALONE_FOV_MAX = PANO_FOV_MAX;
const STANDALONE_INERTIA_BLEND = PANO_INERTIA_BLEND_INST;
const STANDALONE_INERTIA_DECAY = PANO_INERTIA_DAMPING;
const STANDALONE_INERTIA_START_SPEED = PANO_INERTIA_START_SPEED;
const STANDALONE_INERTIA_STOP_SPEED = PANO_INERTIA_STOP_SPEED;
export const STANDALONE_MESH_LOW = { Nu: 14, Nv: 9 };
const STANDALONE_MESH_BALANCED = { Nu: 24, Nv: 14 };
const STANDALONE_MESH_HIGH = { Nu: 32, Nv: 20 };
const STANDALONE_HQ_FRAMES_AFTER_INTERACTION = 10;
const STANDALONE_FRAME_EMA_ALPHA = 0.16;
const STANDALONE_FRAME_DEGRADE_MS = 26;
const STANDALONE_DOM_PROBE_FRAMES = 3;
const STANDALONE_DOM_PROBE_TIMEOUT_MS = 220;
const SHARED_UI_SETTINGS_KEY = "pano_suite.ui_settings.v1";
const PANO_LOADING_MIN_MS = 120;
function loadSharedUiSettings() {
const base = {
invert_view_x: false,
invert_view_y: false,
preview_quality: "balanced",
};
try {
const text = String(window?.localStorage?.getItem(SHARED_UI_SETTINGS_KEY) || "").trim();
if (!text) return base;
const parsed = JSON.parse(text);
const q = String(parsed?.preview_quality || "balanced");
return {
invert_view_x: !!parsed?.invert_view_x,
invert_view_y: !!parsed?.invert_view_y,
preview_quality: (q === "draft" || q === "high") ? q : "balanced",
};
} catch {
return base;
}
}
function panoPreviewDebugEnabled() {
try {
if (window?.__PANO_PREVIEW_DEBUG__ === true) return true;
return String(window?.localStorage?.getItem("panoPreviewDebug") || "").trim() === "1";
} catch {
return false;
}
}
function panoPreviewVerboseEnabled() {
try {
if (window?.__PANO_PREVIEW_DEBUG_VERBOSE__ === true) return true;
return String(window?.localStorage?.getItem("panoPreviewDebugVerbose") || "").trim() === "1";
} catch {
return false;
}
}
function panoPreviewLog(node, tag, payload = null) {
if (!panoPreviewDebugEnabled()) return;
const nodeId = node?.id ?? "?";
if (payload == null) {
console.info(`[PANO_PREVIEW][${tag}] node=${nodeId}`);
return;
}
console.info(`[PANO_PREVIEW][${tag}] node=${nodeId}`, payload);
}
function previewElementSnapshot(el) {
if (!el) return null;
const rect = el.getBoundingClientRect?.();
return {
tag: String(el.tagName || "").toLowerCase(),
className: String(el.className || ""),
clientW: Number(el.clientWidth || 0),
clientH: Number(el.clientHeight || 0),
offsetW: Number(el.offsetWidth || 0),
offsetH: Number(el.offsetHeight || 0),
rectW: Number(rect?.width || 0),
rectH: Number(rect?.height || 0),
styleHeight: String(el.style?.height || ""),
styleWidth: String(el.style?.width || ""),
stylePos: String(el.style?.position || ""),
styleTransform: String(el.style?.transform || ""),
};
}
function ensurePreviewModeCss() {
if (document.getElementById("pano-node-preview-mode-style")) return;
const style = document.createElement("style");
style.id = "pano-node-preview-mode-style";
style.textContent = `
.pano-node-preview-dom {
outline: none !important;
}
.pano-node-preview-dom.pano-node-preview--stickers canvas {
object-fit: cover;
}
.pano-node-preview-dom.pano-node-preview--cutout canvas {
object-fit: contain;
}
.pano-node-preview-dom button:hover {
background: rgba(46,46,50,0.98) !important;
border-color: rgba(120,120,130,1) !important;
color: #fff !important;
box-shadow: 0 0 8px rgba(0,0,0,0.4);
}
.pano-node-preview-dom button:active {
background: rgba(28,28,30,1) !important;
transform: translateY(1px);
}
`;
document.head.appendChild(style);
}
function inspectNodeLayout(node, label, context = {}) {
if (!panoPreviewDebugEnabled()) return;
const dom = node.__panoDomPreview;
const widget = dom?.widget;
const root = dom?.root;
const wrap = root?.querySelector?.("div");
const canvas = dom?.canvas;
const parent = root?.parentElement;
const grandparent = parent?.parentElement;
const snap = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect?.();
return {
tag: el.tagName.toLowerCase(),
cw: el.clientWidth, ch: el.clientHeight,
ow: el.offsetWidth, oh: el.offsetHeight,
rect: r ? { w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top) } : null,
styleH: el.style.height,
styleW: el.style.width,
};
};
const cvInt = canvas ? { w: canvas.width, h: canvas.height } : null;
const nodeSize = getNodeSize(node);
panoPreviewLog(node, `layout.${label}`, {
...context,
nodeSize,
widgetCompute: widget?.__lastComputeSize || null,
dom: {
root: snap(root),
wrap: snap(wrap),
canvas: snap(canvas),
parent: snap(parent),
grandparent: snap(grandparent),
},
canvasIntrinsic: cvInt,
dpr: window.devicePixelRatio,
});
}
function getNodeSize(node) {
const raw = node?.size;
if (raw == null) return null;
const readPair = (a, b) => {
const w = Number(a);
const h = Number(b);
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
return [w, h];
};
// Simplified check: Access properties directly without strict prototype checks.
// This handles Arrays, TypedArrays, Proxies, and plain objects robustly.
if (typeof raw === "object" || typeof raw === "function") {
if (raw[0] !== undefined || raw[1] !== undefined) {
const p = readPair(raw[0], raw[1]);
if (p) return p;
}
if (raw.width !== undefined || raw.height !== undefined) {
const p = readPair(raw.width, raw.height);
if (p) return p;
}
if (raw.w !== undefined || raw.h !== undefined) {
const p = readPair(raw.w, raw.h);
if (p) return p;
}
}
return null;
}
function isNodeSizeInvalid(node) {
const s = getNodeSize(node);
return !s || s[0] < 10 || s[1] < 10;
}
function initNodeSizeIfInvalid(node, w, h) {
if (!isNodeSizeInvalid(node)) return;
node.size = [Math.max(1, Number(w || 1)), Math.max(1, Number(h || 1))];
}
function getActivePreviewBackend(node) {
if (node?.__panoLegacyPreviewHooked) return "legacy_draw";
if (node?.__panoDomPreview?.widget) return "dom";
return "none";
}
function getNodeTypeName(node) {
return String(node?.comfyClass || node?.type || node?.title || "");
}
function isStandalonePreviewNode(node) {
return isPanoramaPreviewNodeName(getNodeTypeName(node));
}
function getFrontendSignature(node = null) {
const frontendVersion = String(window?.__COMFYUI_FRONTEND_VERSION__ || "unknown");
const forcePath = String(window?.__PANO_PREVIEW_FORCE_PATH__ || "");
const domWidgetSignal = typeof node?.addDOMWidget === "function" ? "dom_widget" : "no_dom_widget";
const canvasCtor = String(app?.canvas?.constructor?.name || "");
return [frontendVersion, domWidgetSignal, forcePath, canvasCtor].join("|");
}
function isFrontendC25Compat() {
const v = String(window?.__COMFYUI_FRONTEND_VERSION__ || "");
return v.includes("c25f9a0e939145c155fbbd0fef24da4b02a40326");
}
function isLegacyFrontendRuntime() {
const v = String(window?.__COMFYUI_FRONTEND_VERSION__ || "").trim();
return !v || v === "unknown";
}
function shouldEnableInputShield() {
if (window?.__PANO_PREVIEW_INPUT_SHIELD__ === true) return true;
if (window?.__PANO_PREVIEW_INPUT_SHIELD__ === false) return false;
// Safe default: keep shield on unless explicitly disabled.
return true;
}
function resolveStickersPreviewPath(mode, node = null) {
const forceRaw = String(window?.__PANO_PREVIEW_FORCE_PATH__ || "").trim();
if (forceRaw === "legacy_draw" || forceRaw === "dom") {
return {
chosenPath: forceRaw,
reason: "forced",
frontendVersionRaw: String(window?.__COMFYUI_FRONTEND_VERSION__ || ""),
signature: getFrontendSignature(node),
};
}
if (String(mode) === "cutout") {
return {
chosenPath: "dom",
reason: "cutout_always_dom",
frontendVersionRaw: String(window?.__COMFYUI_FRONTEND_VERSION__ || ""),
signature: getFrontendSignature(node),
};
}
return {
chosenPath: "dom",
reason: "dom_default_probe",
frontendVersionRaw: String(window?.__COMFYUI_FRONTEND_VERSION__ || ""),
signature: getFrontendSignature(node),
};
}
const previewMonitor = {
nodes: new Set(),
intervalId: 0,
debounceId: 0,
lastSignature: "",
generation: 0,
register(node) {
if (!node || String(node.__panoPreviewMode || "") !== "stickers") return;
this.nodes.add(node);
if (!this.lastSignature) this.lastSignature = getFrontendSignature(node);
if (!this.intervalId) {
this.intervalId = window.setInterval(() => this.check(), 1000);
}
},
unregister(node) {
if (node) this.nodes.delete(node);
if (!this.nodes.size && this.intervalId) {
window.clearInterval(this.intervalId);
this.intervalId = 0;
}
if (!this.nodes.size && this.debounceId) {
window.clearTimeout(this.debounceId);
this.debounceId = 0;
}
},
check() {
const sampleNode = this.nodes.values().next().value || null;
const sig = getFrontendSignature(sampleNode);
if (sig === this.lastSignature) return;
this.lastSignature = sig;
if (this.debounceId) window.clearTimeout(this.debounceId);
this.debounceId = window.setTimeout(() => {
this.debounceId = 0;
this.generation += 1;
const nodes = [...this.nodes];
nodes.forEach((node) => {
if (!node || String(node.__panoPreviewMode || "") !== "stickers") return;
try {
node.__panoRebindGeneration = this.generation;
panoPreviewLog(node, "path.switch", {
from: String(node.__panoStickersPath || "unknown"),
to: "rebind",
reason: "frontend_signature_changed",
signature: sig,
generation: this.generation,
});
getPreviewController(node).rebind(node.__panoAttachOptions || { mode: "stickers" });
} catch (err) {
panoPreviewLog(node, "path.switch.error", { message: String(err?.message || err || "unknown") });
}
});
}, 300);
},
};
function getPreviewController(node) {
if (node?.__panoPreviewController) return node.__panoPreviewController;
const controller = {
activeBackend: "none",
attach(options = {}) {
const mode = String(options.mode || "stickers") === "cutout" ? "cutout" : "stickers";
const pathInfo = resolveStickersPreviewPath(mode, node);
const targetBackend = mode === "cutout" ? "dom" : pathInfo.chosenPath;
const activeBackend = getActivePreviewBackend(node);
this.activeBackend = activeBackend;
const sameRoute = activeBackend === targetBackend && activeBackend !== "none";
if (sameRoute) {
node.__panoAttachOptions = { ...options, mode };
node.__panoPreviewMode = mode;
node.__panoPreviewButtonText = String(options.buttonText || (mode === "cutout" ? "Open Cutout Editor" : "Open Stickers Editor"));
node.__panoOpenEditor = typeof options.onOpen === "function" ? options.onOpen : node.__panoOpenEditor;
return;
}
if (activeBackend !== "none") teardownPreview(node, { keepMonitor: mode === "stickers" });
attachPanoramaPreviewImpl(node, { ...options, mode, __panoForcedPath: targetBackend });
this.activeBackend = getActivePreviewBackend(node);
},
rebind(options = {}) {
const mode = String(options.mode || node?.__panoPreviewMode || "stickers") === "cutout" ? "cutout" : "stickers";
teardownPreview(node, { keepMonitor: mode === "stickers" });
this.activeBackend = "none";
this.attach({ ...options, mode });
},
teardown() {
teardownPreview(node);
this.activeBackend = "none";
},
};
node.__panoPreviewController = controller;
return controller;
}
function cancelDomMountProbe(node) {
if (!node?.__panoDomMountProbe) return;
const probe = node.__panoDomMountProbe;
probe.cancelled = true;
if (probe.timeoutId) {
clearTimeout(probe.timeoutId);
probe.timeoutId = 0;
}
node.__panoDomMountProbe = null;
}
function teardownPreview(node, options = {}) {
if (!node) return;
executedRefreshMonitor.unregister(node);
cancelDomMountProbe(node);
const hadDom = !!node.__panoDomPreview;
const hadLegacy = !!node.__panoLegacyPreviewHooked;
const hadDomRestore = typeof node.__panoDomRestore === "function";
const hadLegacyRestore = typeof node.__panoLegacyRestore === "function";
try {
node.__panoDomRestore?.();
} catch {
// ignore
}
node.__panoDomRestore = null;
try {
node.__panoLegacyRestore?.();
} catch {
// ignore
}
node.__panoLegacyRestore = null;
const dom = node.__panoDomPreview;
if (dom) {
try {
if (dom.state?.raf) cancelAnimationFrame(dom.state.raf);
} catch {
// ignore
}
try {
dom.root?.remove?.();
} catch {
// ignore
}
}
try {
if (Array.isArray(node.widgets)) {
// Ensure we remove the DOM widget even if name/type mapping varies by frontend version.
node.widgets = node.widgets.filter((w) => {
if (w === dom?.widget) return false;
const name = String(w?.name || "");
const type = String(w?.type || "");
const animWidgetName = getAnimPreviewWidgetName();
if (name === animWidgetName || type === animWidgetName) return false;
if (name === "pano_preview" || type === "pano_preview") return false;
if (name === "preview" && type === "pano_preview") return false;
return true;
});
}
} catch {
// ignore
}
node.__panoDomPreview = null;
node.__panoLegacyPreviewHooked = false;
node.__panoPreviewHooked = false;
node.__panoPreviewDrag = null;
node.__panoPreviewInertia = null;
node.__panoDomHealth = null;
node.__panoDomProbePending = false;
node.__panoDomMountProbe = null;
if (node.__panoBootMinHeightTimer) {
clearTimeout(node.__panoBootMinHeightTimer);
node.__panoBootMinHeightTimer = null;
}
node.__panoBootMinHeightActive = false;
node.__panoActiveBackend = "none";
panoPreviewLog(node, "teardown.done", {
reason: String(options.reason || ""),
removedDom: hadDom || hadDomRestore,
removedLegacy: hadLegacy || hadLegacyRestore,
keepMonitor: !!options.keepMonitor,
});
if (!options.keepMonitor) previewMonitor.unregister(node);
}
function cleanupPanoDomPreview(node) {
teardownPreview(node, { keepMonitor: true });
}
function vec3(x, y, z) { return { x, y, z }; }
function add(a, b) { return vec3(a.x + b.x, a.y + b.y, a.z + b.z); }
function mul(a, s) { return vec3(a.x * s, a.y * s, a.z * s); }
function dot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
function cross(a, b) {
return vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x,
);
}
function norm(a) {
const l = Math.hypot(a.x, a.y, a.z) || 1e-8;
return vec3(a.x / l, a.y / l, a.z / l);
}
function yawPitchToDir(yawDeg, pitchDeg) {
const yaw = yawDeg * DEG2RAD;
const pitch = pitchDeg * DEG2RAD;
const cp = Math.cos(pitch);
return vec3(cp * Math.sin(yaw), Math.sin(pitch), cp * Math.cos(yaw));
}
function cameraBasis(yawDeg, pitchDeg, rollDeg = 0) {
const fwd = yawPitchToDir(yawDeg, pitchDeg);
const worldUp = vec3(0, 1, 0);
let right = cross(worldUp, fwd);
if (Math.hypot(right.x, right.y, right.z) < 1e-6) right = vec3(1, 0, 0);
right = norm(right);
let up = norm(cross(fwd, right));
const rr = rollDeg * DEG2RAD;
const cr = Math.cos(rr);
const sr = Math.sin(rr);
const r2 = add(mul(right, cr), mul(up, sr));
const u2 = add(mul(right, -sr), mul(up, cr));
return { fwd, right: norm(r2), up: norm(u2) };
}
function parseState(text, bg = "#00ff00") {
const base = {
version: 1,
projection_model: "pinhole_rectilinear",
alpha_mode: "straight",
bg_color: bg,
output_preset: 2048,
assets: {},
stickers: [],
shots: [],
active: { selected_sticker_id: null },
};
if (!text || !text.trim()) return base;
try {
const p = JSON.parse(text);
if (!p || typeof p !== "object") return base;
return {
...base,
...p,
assets: p.assets && typeof p.assets === "object" ? p.assets : {},
stickers: Array.isArray(p.stickers) ? p.stickers : [],
shots: Array.isArray(p.shots) ? p.shots : [],
active: p.active && typeof p.active === "object" ? p.active : base.active,
};
} catch {
return base;
}
}
function getEffectiveStateText(node) {
const live = node?.__panoLiveStateOverride;
if (typeof live === "string" && live.trim()) return live;
return String(getWidget(node, "state_json")?.value || "");
}
function getCachedState(node) {
const text = getEffectiveStateText(node);
const bg = String(getWidget(node, "bg_color")?.value || "#1a1a1e");
const cache = node.__panoStateCache;
if (cache && cache.text === text && cache.bg === bg) {
return cache.parsed;
}
const parsed = parseState(text, bg);
node.__panoStateCache = { text, bg, parsed };
return parsed;
}
function getWidget(node, name) { return node.widgets?.find((w) => w.name === name) || null; }
function pointInRect(x, y, r) { return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h; }
function getGraphLinkById(graph, linkId) {
if (!graph || linkId == null) return null;
const links = graph.links;
if (!links) return null;
if (links instanceof Map) return links.get(linkId) || links.get(Number(linkId)) || links.get(String(linkId)) || null;
return links[linkId] || links[String(linkId)] || null;
}
function getGraphNodeById(graph, id) {
if (!graph || id == null) return null;
if (typeof graph.getNodeById === "function") return graph.getNodeById(id);
return graph._nodes_by_id?.[id] || graph._nodes_by_id?.[String(id)] || null;
}
function resolveOriginFromLinkInfo(linkInfo) {
if (!linkInfo) return { originId: null, originSlot: 0 };
if (typeof linkInfo === "object" && !Array.isArray(linkInfo)) {
return {
originId: linkInfo.origin_id ?? null,
originSlot: Number(linkInfo.origin_slot ?? 0),
};
}
if (Array.isArray(linkInfo)) {
return {
originId: linkInfo[1] ?? null,
originSlot: Number(linkInfo[2] ?? 0),
};
}
return { originId: null, originSlot: 0 };
}
function resolveInputOriginNode(node, inputIndex, fallbackOriginId = null) {
let originNode = null;
try {
originNode = typeof node?.getInputNode === "function" ? node.getInputNode(inputIndex) : null;
} catch {
originNode = null;
}
if (originNode?.isSubgraphNode?.()) {
try {
const inputLink = typeof node?.getInputLink === "function" ? node.getInputLink(inputIndex) : null;
const resolved = inputLink ? originNode.resolveSubgraphOutputLink?.(Number(inputLink.origin_slot ?? 0)) : null;
if (resolved?.outputNode) originNode = resolved.outputNode;
} catch {
// ignore
}
}
if (!originNode && fallbackOriginId != null) {
originNode = getGraphNodeById(node?.graph, fallbackOriginId);
}
return originNode;
}
function listInputNames(node) {
const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
return inputs.map((i) => String(i?.name || "")).filter(Boolean);
}
export function preferredImageInputsForNode(node, preferredInputNames = []) {
const names = listInputNames(node);
const out = [];
preferredInputNames.forEach((name) => {
if (names.includes(String(name))) out.push(String(name));
});
names.forEach((name) => {
if ((name === "erp_image" || name === "bg_erp") && !out.includes(name)) out.push(name);
});
return out.length ? out : preferredInputNames;
}
function normalizeExecutionId(value) {
const id = String(value ?? "").trim();
if (!id) return { full: "", tail: "" };
const parts = id.split(":").map((p) => String(p).trim()).filter(Boolean);
return { full: id, tail: parts.length ? parts[parts.length - 1] : id };
}
function executionIdMatches(value, target) {
const a = normalizeExecutionId(value);
const b = normalizeExecutionId(target);
if (!a.full || !b.full) return false;
return a.full === b.full || a.tail === b.full || a.full === b.tail || a.tail === b.tail;
}
function getLinkedOriginIds(node, preferredInputNames = ["erp_image", "bg_erp"]) {
const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
if (!inputs.length) return [];
const preferredResolved = preferredImageInputsForNode(node, preferredInputNames);
const preferred = preferredResolved
.map((name) => inputs.findIndex((i) => String(i?.name || "") === String(name)))
.filter((idx) => idx >= 0);
const imageTyped = inputs
.map((input, idx) => ({ input, idx }))
.filter(({ input }) => String(input?.type || "").toUpperCase() === "IMAGE")
.map(({ idx }) => idx);
const indices = [...new Set([...preferred, ...imageTyped])];
const ids = [];
for (const idx of indices) {
const originNode = resolveInputOriginNode(node, idx);
if (originNode?.id != null) {
ids.push(String(originNode.id));
continue;
}
const linkId = inputs[idx]?.link;
if (linkId == null) continue;
const linkInfo = getGraphLinkById(node?.graph, linkId);
const { originId } = resolveOriginFromLinkInfo(linkInfo);
if (originId != null) ids.push(String(originId));
}
return [...new Set(ids)];
}
const executedRefreshMonitor = {
nodes: new Set(),
bound: false,
listener: null,
ensureBound() {
if (this.bound || !api?.addEventListener) return;
this.listener = (evt) => {
const detail = evt?.detail || {};
const executedId = String(detail?.display_node ?? detail?.node ?? "").trim();
const targets = [...this.nodes];
targets.forEach((node) => {
if (!node) return;
const selfHit = executionIdMatches(executedId, node?.id);
const upstreamIds = getLinkedOriginIds(node, ["erp_image", "bg_erp"]);
const upstreamHit = upstreamIds.some((id) => executionIdMatches(executedId, id));
const reason = selfHit ? "self" : (upstreamHit ? "upstream" : "global_executed");
panoPreviewLog(node, "exec-refresh", { executedId, reason });
invalidatePreviewImageCaches(node);
panoPreviewLog(node, "invalidate", { cachesCleared: true });
node.__panoDomPreview?.requestDraw?.();
node.setDirtyCanvas?.(true, true);
node.graph?.setDirtyCanvas?.(true, true);
panoPreviewLog(node, "draw-request", {
route: String(node.__panoPreviewMode || ""),
mode: String(node.__panoPreviewMode || ""),
reason,
});
});
};
api.addEventListener("executed", this.listener);
this.bound = true;
},
ensureUnbound() {
if (!this.bound || !this.listener || !api?.removeEventListener) return;
api.removeEventListener("executed", this.listener);
this.listener = null;
this.bound = false;
},
register(node) {
if (!node) return;
this.nodes.add(node);
this.ensureBound();
},
unregister(node) {
if (node) this.nodes.delete(node);
if (!this.nodes.size) this.ensureUnbound();
},
};
function comfyImageEntryToUrl(entry) {
if (!entry || typeof entry !== "object") return "";
const filename = String(entry.filename || "");
if (!filename) return "";
const params = new URLSearchParams();
params.set("filename", filename);
params.set("type", String(entry.type || "output"));
if (entry.subfolder) params.set("subfolder", String(entry.subfolder));
const q = `/view?${params.toString()}`;
return typeof api?.apiURL === "function" ? api.apiURL(q) : q;
}
function isDirectImageUrl(src) {
const s = String(src || "").trim();
if (!s) return false;
return (
/^https?:\/\//i.test(s)
|| s.startsWith("/")
|| s.startsWith("blob:")
|| s.startsWith("data:")
);
}
function splitFilenameAndSubfolder(pathish) {
const normalized = String(pathish || "").trim().replaceAll("\\", "/");
const trimmed = normalized.replace(/^\.\/+/, "").replace(/^\/+/, "");
if (!trimmed) return { filename: "", subfolder: "" };
const parts = trimmed.split("/").filter(Boolean);
if (!parts.length) return { filename: "", subfolder: "" };
const filename = String(parts.pop() || "").trim();
const subfolder = parts.join("/");
return { filename, subfolder };
}
function uniqStrings(values) {
const out = [];
const seen = new Set();
values.forEach((v) => {
const s = String(v || "").trim();
if (!s || seen.has(s)) return;
seen.add(s);
out.push(s);
});
return out;
}
function buildImageSrcCandidates(srcRaw) {
const src = String(srcRaw || "").trim();
if (!src) return [];
if (isDirectImageUrl(src)) return [src];
const { filename, subfolder } = splitFilenameAndSubfolder(src);
if (!filename) return [src];
const byView = ["temp", "output", "input"].map((type) => comfyImageEntryToUrl({
filename,
subfolder,
type,
}));
return uniqStrings([...byView, src]);
}
function stickerAssetToPreviewSrc(asset) {
if (!asset || typeof asset !== "object") return "";
const type = String(asset.type || "").trim().toLowerCase();
if (type === "dataurl") return String(asset.value || "");
if (type === "comfy_image") {
const filename = String(asset.filename || "").trim();
if (!filename) return "";
return comfyImageEntryToUrl({
filename,
subfolder: String(asset.subfolder || ""),
type: String(asset.storage || "input"),
});
}
return "";
}
function lookupNodeOutputEntry(nodeId) {
const store = app?.nodeOutputs;
if (!store || nodeId == null) return null;
// Performance fix: Prefer strictly direct lookup.
// Iterating all outputs every frame causes massive lag when many nodes are present.
const raw = String(nodeId);
let val = null;
if (store instanceof Map) {
val = store.get(nodeId) || store.get(raw) || store.get(Number(raw)) || null;
} else {
val = store[nodeId] || store[raw] || null;
}
if (val) return val;
// Compatibility Fallback: Direct lookup failed, possibly due to execution-scoped keys (e.g. "promptId:nodeId").
// We iterate keys ONLY if direct lookup misses.
const targetId = raw;
if (store instanceof Map) {
for (const key of store.keys()) {
if (executionIdMatches(key, targetId)) return store.get(key);
}
} else {
const keys = Object.keys(store);
for (const key of keys) {
if (executionIdMatches(key, targetId)) return store[key];
}
}
return null;
}
function imageSourceFromCandidate(candidate) {
if (!candidate) return "";
if (typeof candidate === "string") {
return String(candidate || "").trim();
}
if (Array.isArray(candidate)) {
if (candidate.length === 0) return "";
if (candidate.length === 1) return imageSourceFromCandidate(candidate[0]);
const filename = String(candidate[0] || "").trim();
if (filename) {
const subfolder = String(candidate[1] || "").trim();
const type = String(candidate[2] || "output").trim() || "output";
return comfyImageEntryToUrl({ filename, subfolder, type });
}
for (const entry of candidate) {
const src = imageSourceFromCandidate(entry);
if (src) return src;
}
return "";
}
if (typeof candidate?.src === "string" && candidate.src) return candidate.src;
if (typeof candidate?.url === "string" && candidate.url) return candidate.url;
return comfyImageEntryToUrl(candidate);
}
function appendImageRevision(src, rev) {
const raw = String(src || "").trim();
if (!raw) return "";
try {
const base = typeof window !== "undefined" && window?.location?.href ? window.location.href : "http://127.0.0.1/";
const u = new URL(raw, base);
if (rev > 0) u.searchParams.set("pano_rev", String(rev));
return u.toString();
} catch {
const sep = raw.includes("?") ? "&" : "?";
return rev > 0 ? `${raw}${sep}pano_rev=${rev}` : raw;
}
}
function collectExecutedImageCandidates(output) {
const out = [];
const visited = new Set();
const pushAll = (arr) => {
if (!Array.isArray(arr)) return;
arr.forEach((v) => out.push(v));
};
const walk = (obj, depth = 0) => {
if (!obj || depth > 3) return;
if (visited.has(obj)) return;
if (typeof obj !== "object") return;
visited.add(obj);
pushAll(obj.images);
walk(obj.output, depth + 1);
walk(obj.ui, depth + 1);
walk(obj.data, depth + 1);
walk(obj.result, depth + 1);
};
walk(output, 0);
return out;
}
function syncOwnOutputSourceFromExecuted(node, output) {
try {
const images = collectExecutedImageCandidates(output);
let src = "";
for (const cand of images) {
src = imageSourceFromCandidate(cand);
if (src) break;
}
if (src) {
node.__panoOwnOutputRev = Number(node.__panoOwnOutputRev || 0) + 1;
node.__panoOwnOutputSrc = String(src || "");
panoPreviewLog(node, "cutout.output-sync", {
hasOutputImage: true,
src: appendImageRevision(node.__panoOwnOutputSrc, node.__panoOwnOutputRev),
rev: node.__panoOwnOutputRev,
});
} else {
panoPreviewLog(node, "cutout.output-sync", { hasOutputImage: false, keys: Object.keys(output || {}) });
}
} catch {
panoPreviewLog(node, "cutout.output-sync", { hasOutputImage: false });
}
}
function getNodeOwnOutputImage(node, onLoad = null) {
const id = node?.id;
if (id == null) return null;
const ownSrc = String(node?.__panoOwnOutputSrc || "").trim();
const ownOutput = lookupNodeOutputEntry(id);
const outImgs = Array.isArray(ownOutput?.images) ? ownOutput.images : [];
const nodeImgs = Array.isArray(node?.imgs) ? node.imgs : [];
const candidates = ownSrc ? [ownSrc, ...outImgs, ...nodeImgs] : [...outImgs, ...nodeImgs];
let src = "";
for (const cand of candidates) {
src = imageSourceFromCandidate(cand);
if (src) break;
}
if (!src) return null;
const rev = Number(node?.__panoOwnOutputRev || 0);
const rawCandidates = buildImageSrcCandidates(src);
const srcCandidates = rawCandidates.map((s) => appendImageRevision(s, rev));
if (!srcCandidates.length) return null;
const srcKey = `${src}|rev:${rev}`;
if (!node.__panoOwnOutputImageCache) node.__panoOwnOutputImageCache = { src: "", img: null };
const cached = node.__panoOwnOutputImageCache;
if (cached.img && cached.src === srcKey) return cached.img;
const img = new Image();
let attempt = -1;
const tryLoadNext = () => {
attempt += 1;
if (attempt >= srcCandidates.length) {
node.__panoOwnOutputImageCache = null;
return;
}
img.src = srcCandidates[attempt];
};
img.onload = () => {
onLoad?.();
node.setDirtyCanvas?.(true, true);
};
img.onerror = () => {
if (attempt + 1 >= srcCandidates.length) {
node.__panoOwnOutputImageCache = null;
return;
}
tryLoadNext();
};
tryLoadNext();
node.__panoOwnOutputImageCache = { src: srcKey, img };
return img;
}
function findLinkedInputImageSource(node, preferredInputNames = []) {
const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
if (!inputs.length) return { src: "", sourceType: "", inputName: "" };
const preferredResolved = preferredImageInputsForNode(node, preferredInputNames);
const preferred = preferredResolved
.map((name) => inputs.findIndex((i) => String(i?.name || "") === String(name)))
.filter((idx) => idx >= 0);
const imageTyped = inputs
.map((input, idx) => ({ input, idx }))
.filter(({ input }) => String(input?.type || "").toUpperCase() === "IMAGE")
.map(({ idx }) => idx);
const indices = [...new Set([...preferred, ...imageTyped])];
panoPreviewLog(node, "image-resolve", {
inputCandidates: preferredResolved,
resolvedIndices: indices,
stage: "start",
});
for (const idx of indices) {
const input = inputs[idx];
const linkId = input?.link;
if (linkId == null) continue;
const linkInfo = getGraphLinkById(node.graph, linkId);
const { originId, originSlot } = resolveOriginFromLinkInfo(linkInfo);
if (originId == null) continue;
const originNode = resolveInputOriginNode(node, idx, originId);
if (!originNode) continue;
const resolvedOriginSlot = Number(originSlot || 0);
let appNodeImageUrls = [];
try {
appNodeImageUrls = typeof app?.getNodeImageUrls === "function" ? (app.getNodeImageUrls(originNode) || []) : [];
} catch {
appNodeImageUrls = [];
}
if (Array.isArray(appNodeImageUrls) && appNodeImageUrls.length) {
const ordered = [];
if (resolvedOriginSlot >= 0 && resolvedOriginSlot < appNodeImageUrls.length) ordered.push(appNodeImageUrls[resolvedOriginSlot]);
ordered.push(...appNodeImageUrls);
for (const cand of ordered) {
const src = imageSourceFromCandidate(cand);
if (src) {
panoPreviewLog(node, "image-resolve", {
inputName: String(input?.name || ""),
sourceType: "appNodeImageUrls",
stage: "hit",
});
return { src, sourceType: "appNodeImageUrls", inputName: String(input?.name || "") };
}
}
}
const outputs = lookupNodeOutputEntry(originNode?.id ?? originId);
const outImgs = Array.isArray(outputs?.images) ? outputs.images : [];
if (outImgs.length) {
const ordered = [];
if (resolvedOriginSlot >= 0 && resolvedOriginSlot < outImgs.length) ordered.push(outImgs[resolvedOriginSlot]);
ordered.push(...outImgs);
for (const cand of ordered) {
const src = imageSourceFromCandidate(cand);
if (src) {
panoPreviewLog(node, "image-resolve", {
inputName: String(input?.name || ""),
sourceType: "nodeOutputs",
stage: "hit",
});
return { src, sourceType: "nodeOutputs", inputName: String(input?.name || "") };
}
}
}
const nodeImgs = Array.isArray(originNode?.imgs) ? originNode.imgs : [];
if (nodeImgs.length) {
const ordered = [];
if (resolvedOriginSlot >= 0 && resolvedOriginSlot < nodeImgs.length) ordered.push(nodeImgs[resolvedOriginSlot]);
ordered.push(...nodeImgs);
for (const cand of ordered) {
const src = imageSourceFromCandidate(cand);
if (src) {
panoPreviewLog(node, "image-resolve", {
inputName: String(input?.name || ""),
sourceType: "nodeImgs",
stage: "hit",
});
return { src, sourceType: "nodeImgs", inputName: String(input?.name || "") };
}
}
}
const imageWidget = originNode?.widgets?.find((w) => String(w?.name || "").toLowerCase() === "image");
if (imageWidget) {
let src = imageSourceFromCandidate(imageWidget.value);
if (src && !src.includes("/") && !src.includes(":") && (originNode.comfyClass === "LoadImage" || originNode.type === "LoadImage")) {
src = api.apiURL(`/view?filename=${encodeURIComponent(src)}&type=input&subfolder=`);
}
if (src) {
panoPreviewLog(node, "image-resolve", {
inputName: String(input?.name || ""),
sourceType: "widget",
stage: "hit",
});
return { src, sourceType: "widget", inputName: String(input?.name || "") };
}
}
}
// Fallback: Check if the current node has explicitly saved input images (e.g. from upstream non-file nodes)
// We do this check regardless of linked inputs if preferred inputs are exhausted, to support
// scenarios where the link exists but provides no image data (e.g. some custom nodes).
const selfOutput = lookupNodeOutputEntry(node?.id);
const fallbackCandidates = [];
if (Array.isArray(selfOutput?.pano_input_images)) fallbackCandidates.push(...selfOutput.pano_input_images);
if (Array.isArray(selfOutput?.ui?.pano_input_images)) fallbackCandidates.push(...selfOutput.ui.pano_input_images);
if (fallbackCandidates.length > 0) {
for (const item of fallbackCandidates) {
const src = imageSourceFromCandidate(item);
if (src) {
panoPreviewLog(node, "image-resolve", { sourceType: "selfOutput", src, stage: "hit" });
return { src, sourceType: "selfOutput", inputName: "fallback" };
}
}
}
panoPreviewLog(node, "image-resolve", { stage: "miss" });
return { src: "", sourceType: "", inputName: "" };
}
function getLinkedInputImage(node, preferredInputNames = []) {
const resolved = findLinkedInputImageSource(node, preferredInputNames);
const srcRaw = String(resolved?.src || "").trim();
if (!srcRaw) return null;
const candidates = buildImageSrcCandidates(srcRaw);
if (!candidates.length) return null;
if (!node.__panoLinkedInputImageCache) node.__panoLinkedInputImageCache = new Map();
const key = preferredInputNames.join("|") || "image";
const cached = node.__panoLinkedInputImageCache.get(key);
if (cached && cached.srcRaw === srcRaw && cached.img) return cached.img;
const img = new Image();
const cacheEntry = { srcRaw, resolvedSrc: "", img };
node.__panoLinkedInputImageCache.set(key, cacheEntry);
let attempt = -1;
const tryLoadNext = () => {
attempt += 1;
if (attempt >= candidates.length) {
try { node.__panoLinkedInputImageCache?.delete?.(key); } catch { }
panoPreviewLog(node, "image-load", {
ok: false,
src: srcRaw,
preferredInputNames,
sourceType: String(resolved?.sourceType || ""),
attemptCount: candidates.length,
errorType: "all_candidates_failed",
});
return;
}
const nextSrc = candidates[attempt];
cacheEntry.resolvedSrc = nextSrc;
img.src = nextSrc;
};
img.onload = () => {
panoPreviewLog(node, "image-load", {
ok: true,
src: srcRaw,
resolvedSrc: String(cacheEntry.resolvedSrc || img.src || ""),
preferredInputNames,
sourceType: String(resolved?.sourceType || ""),
attemptCount: attempt + 1,
});
node.setDirtyCanvas?.(true, true);
};
img.onerror = (ev) => {
if (attempt + 1 < candidates.length) {
tryLoadNext();
return;
}
try { node.__panoLinkedInputImageCache?.delete?.(key); } catch { }
panoPreviewLog(node, "image-load", {
ok: false,
src: srcRaw,
resolvedSrc: String(cacheEntry.resolvedSrc || ""),
preferredInputNames,
sourceType: String(resolved?.sourceType || ""),
attemptCount: attempt + 1,
errorType: String(ev?.type || "error"),
});
};
tryLoadNext();
return img;
}
export function getLinkedInputImageForPreview(node, preferredInputNames = [], onLoad = null) {
const img = getLinkedInputImage(node, preferredInputNames);
if (!img) return null;
if (typeof onLoad === "function") {
if (!(img.complete && (img.naturalWidth || img.width))) {
img.addEventListener("load", () => onLoad(), { once: true });
}
}
return img;
}
function invalidatePreviewImageCaches(node) {
if (!node) return;
try { node.__panoLinkedInputImageCache?.clear?.(); } catch { }
node.__panoOwnOutputImageCache = null;
node.__panoWrappedErpCache = null;
}
function ensureRenderCache(node, mode) {
if (!node.__panoRenderCache) {
node.__panoRenderCache = {
mode: String(mode || ""),
isLoading: false,
loadingSinceTs: 0,
};
}
const cache = node.__panoRenderCache;
const nextMode = String(mode || "");
if (cache.mode !== nextMode) {
cache.mode = nextMode;
cache.isLoading = false;
cache.loadingSinceTs = 0;
}
return cache;
}
function setRenderLoading(node, isLoading, src = "") {
const cache = ensureRenderCache(node, node?.__panoPreviewMode || "unknown");
const now = performance.now();
if (isLoading) {
if (!cache.isLoading) {
cache.isLoading = true;
cache.loadingSinceTs = now;
panoPreviewLog(node, "loading", { phase: "enter", src: String(src || "") });
}
return true;
}
if (!cache.isLoading) return false;
const elapsed = now - Number(cache.loadingSinceTs || 0);
if (elapsed < PANO_LOADING_MIN_MS) return true;
cache.isLoading = false;
cache.loadingSinceTs = 0;
panoPreviewLog(node, "loading", { phase: "exit", src: String(src || "") });
return false;
}
function getNodeUiScale(canvas, rect) {
const rw = Math.max(1, Number(rect?.w || 1));
const rh = Math.max(1, Number(rect?.h || 1));
const cw = Math.max(1, Number(canvas?.clientWidth || rw));
const ch = Math.max(1, Number(canvas?.clientHeight || rh));
const sx = rw / cw;
const sy = rh / ch;
const scale = Math.min(sx, sy);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function drawLoadingOverlay(ctx, rect, uiScale = 1) {
const x = Number(rect?.x || 0);
const y = Number(rect?.y || 0);
const w = Number(rect?.w || 0);
const h = Number(rect?.h || 0);
if (w <= 0 || h <= 0) return;
const fontPx = Math.max(14, Math.round(14 * uiScale));
const bw = Math.min(Math.round(220 * uiScale), Math.max(Math.round(120 * uiScale), w * 0.44));
const bh = Math.max(Math.round(34 * uiScale), fontPx + Math.round(14 * uiScale));
const bx = x + (w - bw) * 0.5;
const by = y + (h - bh) * 0.5;
ctx.save();
ctx.fillStyle = "rgba(20,20,24,0.72)";
ctx.beginPath();
ctx.roundRect(bx, by, bw, bh, 8);
ctx.fill();
ctx.fillStyle = "rgba(161,161,170,0.95)";
ctx.font = `600 ${fontPx}px Plus Jakarta Sans, Geist, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Loading...", bx + bw * 0.5, by + bh * 0.5 + 0.5);
ctx.restore();
}
function fitHintLines(ctx, text, maxWidth, maxFontPx, minFontPx = 12, maxLines = 2) {
const phrase = String(text || "").trim();
if (!phrase) {
return { lines: [], fontPx: minFontPx, lineHeight: Math.round(minFontPx * 1.25) };
}
const words = phrase.split(/\s+/).filter(Boolean);
const hi = Math.max(1, Math.round(maxFontPx));
const lo = Math.max(1, Math.round(minFontPx));
const tryWrap = (fontPx) => {
ctx.font = `600 ${fontPx}px Plus Jakarta Sans, Geist, sans-serif`;
if (ctx.measureText(phrase).width <= maxWidth) {
return [phrase];
}
if (words.length <= 1) return null;
const lines = [];
let current = "";
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (ctx.measureText(next).width <= maxWidth) {
current = next;
} else if (!current) {
return null;
} else {
lines.push(current);
current = word;
}
}
if (current) lines.push(current);
if (lines.length > maxLines) return null;
if (lines.some((line) => ctx.measureText(line).width > maxWidth)) return null;
return lines;
};
for (let fontPx = hi; fontPx >= lo; fontPx -= 1) {
const lines = tryWrap(fontPx);
if (lines) {
return { lines, fontPx, lineHeight: Math.round(fontPx * 1.25) };
}
}
const fontPx = lo;
const lineHeight = Math.round(fontPx * 1.25);
ctx.font = `600 ${fontPx}px Plus Jakarta Sans, Geist, sans-serif`;
let fallback = phrase;
const ellipsis = "...";
while (fallback.length > 1 && ctx.measureText(`${fallback}${ellipsis}`).width > maxWidth) {
fallback = fallback.slice(0, -1);
}
return { lines: [`${fallback}${ellipsis}`], fontPx, lineHeight };
}
function drawCenteredAutoFitHint(ctx, rect, text, options = {}) {
const x = Number(rect?.x || 0);
const y = Number(rect?.y || 0);
const w = Math.max(1, Number(rect?.w || 0));
const h = Math.max(1, Number(rect?.h || 0));
const fontScale = Math.max(0.5, Math.min(2.0, Number(options.fontScale || 1.0)));
const maxWidth = Math.max(40, w * Number(options.maxWidthRatio || 0.86));
const maxFontPx = Math.max(10, Math.min(34, Math.round(Math.min(w * 0.09, h * 0.24) * fontScale)));
const minFontPx = Math.max(8, Math.min(12, Math.round(h * 0.14 * fontScale)));
const fit = fitHintLines(ctx, text, maxWidth, maxFontPx, minFontPx, 2);
if (!fit.lines.length) return;
const totalHeight = fit.lineHeight * fit.lines.length;
const startY = y + h * 0.5 - totalHeight * 0.5 + fit.lineHeight * 0.5;
ctx.save();
ctx.fillStyle = String(options.color || "rgba(161,161,170,0.95)");
ctx.font = `600 ${fit.fontPx}px Plus Jakarta Sans, Geist, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
fit.lines.forEach((line, idx) => {
ctx.fillText(line, x + w * 0.5, startY + idx * fit.lineHeight);
});
ctx.restore();
}
function setCutoutEmptyHint(node, visible, text = "Open editor and add frame") {
const el = node?.__panoDomPreview?.emptyHintEl;
if (!el) return;
el.textContent = String(text || "Open editor and add frame");
el.style.display = visible ? "flex" : "none";
}
function getActiveShot(state) {
const shots = Array.isArray(state?.shots) ? state.shots : [];
if (!shots.length) return null;
const selectedId = String(state?.active?.selected_shot_id || "");
if (selectedId) {
const hit = shots.find((s) => String(s?.id || "") === selectedId);
if (hit) return hit;
}
return shots[0] || null;
}
function containRect(outer, aspect) {
const ox = Number(outer?.x || 0);
const oy = Number(outer?.y || 0);
const ow = Math.max(1, Number(outer?.w || 0));
const oh = Math.max(1, Number(outer?.h || 0));
const a = Number(aspect) > 0 ? Number(aspect) : 1;
let w = ow;
let h = ow / a;
if (h > oh) {
h = oh;
w = oh * a;
}
return {
x: ox + (ow - w) * 0.5,
y: oy + (oh - h) * 0.5,
w,
h,
};
}
function getCutoutContainRect(baseRect, shot) {
const w = Math.max(1, Number(baseRect?.w || 0));
const h = Math.max(1, Number(baseRect?.h || 0));
const fallback = { x: Number(baseRect?.x || 0), y: Number(baseRect?.y || 0), w, h };
if (!shot) return { drawRect: fallback, targetAspect: null };
const view = buildCutoutViewParamsFromShot(shot);
const aspect = clamp(Number(view?.aspect || 1), 0.05, 20.0);
return {
drawRect: containRect(fallback, aspect),
targetAspect: aspect,
};
}
function hasValidCutoutStats(node) {
const stats = node?.__panoCutoutProjectionStats;
const tri = Number(stats?.drawnTriCount || 0);
return Number.isFinite(tri) && tri > 0;
}
function getWrappedErpCanvas(node, img) {
if (!img || !img.complete || !(img.naturalWidth || img.width)) return null;
const iw = Number(img.naturalWidth || img.width || 0);
const ih = Number(img.naturalHeight || img.height || 0);
if (iw <= 1 || ih <= 1) return null;
if (!node.__panoWrappedErpCache) node.__panoWrappedErpCache = { src: "", w: 0, h: 0, canvas: null };
const src = String(img.src || "");
const cached = node.__panoWrappedErpCache;
if (cached.canvas && cached.src === src && cached.w === iw && cached.h === ih) return cached.canvas;
const cv = document.createElement("canvas");
cv.width = iw * 2;
cv.height = ih;
const cctx = cv.getContext("2d");
if (!cctx) return null;
cctx.drawImage(img, 0, 0, iw, ih);
cctx.drawImage(img, iw, 0, iw, ih);
node.__panoWrappedErpCache = { src, w: iw, h: ih, canvas: cv };
return cv;
}
function getNodeWidgetsBottom(node, excludeWidget = null) {
const widgetTop = 32;
const widgets = Array.isArray(node.widgets) ? node.widgets : [];
let y = widgetTop;
widgets.forEach((w) => {
if (w === excludeWidget) return;
if (!w || w.hidden || w.type === "hidden") return;
let h = 22;
try {
const size = typeof w.computeSize === "function" ? w.computeSize(getNodeSize(node)?.[0] || 0) : null;
if (Array.isArray(size) && Number.isFinite(Number(size[1]))) h = Number(size[1]);
} catch {
h = 22;
}
y += h;
});
return y;
}
function removeNodeWidget(node, widget) {
if (!node || !widget || !Array.isArray(node.widgets)) return false;
const idx = node.widgets.indexOf(widget);
if (idx < 0) return false;
node.widgets.splice(idx, 1);
if (Array.isArray(node.widgets_values) && node.widgets_values.length > idx) {
node.widgets_values.splice(idx, 1);
}
return true;
}
function getNodePreviewRect(node) {
const pad = 8;
const top = getNodeWidgetsBottom(node) + 2;
const x = pad;
const size = getNodeSize(node);
const w = Math.max(120, Number(size?.[0] || 0) - pad * 2);
const h = Math.max(84, Number(size?.[1] || 0) - top - pad);
if (h < 40 || w < 80) return null;
return { x, y: top, w, h };
}
function getNodeEditorButtonRect(node) {
if (!node?.__panoPreviewButtonText) return null;
const pad = 8;
const y = getNodeWidgetsBottom(node) + 2;
const size = getNodeSize(node);
const w = Math.max(120, Number(size?.[0] || 0) - pad * 2);
return { x: pad, y, w, h: 30 };
}
function getStandaloneLegacyPreviewRect(node) {
const pad = 8;
const btn = getNodeEditorButtonRect(node);
const top = btn ? (btn.y + btn.h + 2) : (getNodeWidgetsBottom(node) + 2);
const x = pad;
const size = getNodeSize(node);
const w = Math.max(120, Number(size?.[0] || 0) - pad * 2);
const h = Math.max(84, Number(size?.[1] || 0) - top - pad);
if (h < 40 || w < 80) return null;
return { x, y: top, w, h };
}
function getNodePreviewResetButtonRect(rect) {
const w = 50;
const h = 20;
const m = 8;
return { x: rect.x + rect.w - w - m, y: rect.y + rect.h - h - m, w, h };
}
function drawNodeEditorButton(node, ctx) {
const r = getNodeEditorButtonRect(node);
if (!r) return;
const hover = !!node.__panoPreviewBtnHover;
ctx.save();
ctx.fillStyle = hover ? "rgba(44, 44, 47, 0.96)" : "rgba(32, 32, 35, 0.96)";
ctx.strokeStyle = "rgba(98, 98, 105, 0.9)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(r.x, r.y, r.w, r.h, 7);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(244, 244, 246, 0.95)";
ctx.font = "500 12px Plus Jakarta Sans, Geist, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(node.__panoPreviewButtonText || "Open Editor"), r.x + r.w * 0.5, r.y + r.h * 0.5 + 0.5);
ctx.restore();
}
function getNodePreviewImage(node, assetId, asset) {
if (!node.__panoPreviewImageCache) node.__panoPreviewImageCache = new Map();
const key = String(assetId || "");
if (!key) return null;
const src = stickerAssetToPreviewSrc(asset);
if (!src) return null;
if (!window.__panoSharedImageCache) window.__panoSharedImageCache = new Map();
const shared = window.__panoSharedImageCache.get(src);
if (shared?.img) {
node.__panoPreviewImageCache.set(key, { src, img: shared.img });
return shared.img;
}
const cached = node.__panoPreviewImageCache.get(key);
if (cached && cached.src === src) return cached.img;
const img = new Image();
img.src = src;
img.onload = () => node.setDirtyCanvas?.(true, true);
window.__panoSharedImageCache.set(src, { img });
node.__panoPreviewImageCache.set(key, { src, img });
return img;
}
function getSortedStickers(node, state) {
const stickers = Array.isArray(state?.stickers) ? state.stickers : [];
const cache = node.__panoStickerSortCache;
if (cache?.source === stickers && Array.isArray(cache.sorted)) return cache.sorted;
const sorted = [...stickers].sort((a, b) => Number(a?.z_index || 0) - Number(b?.z_index || 0));
node.__panoStickerSortCache = { source: stickers, sorted };
return sorted;
}
function buildRuntimePreviewScene(node, state) {
return buildStickerSceneFromState(state, {
selectedId: null,
hoveredId: null,
});
}
function buildRuntimePreviewTextures(node, state, scene) {
return buildStickerTexturesFromState(state, (assetId, asset) => getNodePreviewImage(node, assetId, asset), { scene });
}
function expandTri(d0, d1, d2, px = 0.45) {
const cx = (d0.x + d1.x + d2.x) / 3;
const cy = (d0.y + d1.y + d2.y) / 3;
const grow = (p) => {
const vx = p.x - cx;
const vy = p.y - cy;
const ll = Math.hypot(vx, vy) || 1;
return { x: p.x + (vx / ll) * px, y: p.y + (vy / ll) * px };
};
return [grow(d0), grow(d1), grow(d2)];
}
function drawImageTri(ctx, img, s0, s1, s2, d0, d1, d2) {
const den = (s0.x * (s1.y - s2.y) + s1.x * (s2.y - s0.y) + s2.x * (s0.y - s1.y));
if (Math.abs(den) < 1e-6) return;
const [e0, e1, e2] = expandTri(d0, d1, d2, Number(ctx.__panoTriExpandPx || 0.45));
ctx.save();
ctx.beginPath();
ctx.moveTo(e0.x, e0.y);
ctx.lineTo(e1.x, e1.y);
ctx.lineTo(e2.x, e2.y);
ctx.closePath();
ctx.clip();
const m11 = (d0.x * (s1.y - s2.y) + d1.x * (s2.y - s0.y) + d2.x * (s0.y - s1.y)) / den;
const m12 = (d0.x * (s2.x - s1.x) + d1.x * (s0.x - s2.x) + d2.x * (s1.x - s0.x)) / den;
const m13 = (d0.x * (s1.x * s2.y - s2.x * s1.y) + d1.x * (s2.x * s0.y - s0.x * s2.y) + d2.x * (s0.x * s1.y - s1.x * s0.y)) / den;
const m21 = (d0.y * (s1.y - s2.y) + d1.y * (s2.y - s0.y) + d2.y * (s0.y - s1.y)) / den;
const m22 = (d0.y * (s2.x - s1.x) + d1.y * (s0.x - s2.x) + d2.y * (s1.x - s0.x)) / den;
const m23 = (d0.y * (s1.x * s2.y - s2.x * s1.y) + d1.y * (s2.x * s0.y - s0.x * s2.y) + d2.y * (s0.x * s1.y - s1.x * s0.y)) / den;
ctx.transform(m11, m21, m12, m22, m13, m23);
ctx.drawImage(img, 0, 0);
ctx.restore();
}
function projectDirToPreview(dir, viewBasis, rect, tanHalfY) {
const cx = dot(dir, viewBasis.right);
const cy = dot(dir, viewBasis.up);
const cz = dot(dir, viewBasis.fwd);
if (cz <= 1e-4) return null;
const sy = (cy / cz) / tanHalfY;
const sx = (cx / cz) / tanHalfY;
return { x: rect.x + rect.w * 0.5 + sx * rect.h * 0.5, y: rect.y + rect.h * 0.5 - sy * rect.h * 0.5 };
}
function drawSticker(ctx, node, rect, viewBasis, tanHalfY, state, item, Nu = 12, Nv = 9) {
const hf = clamp(Number(item.hFOV_deg || 30), 1, 179) * DEG2RAD;
const vf = clamp(Number(item.vFOV_deg || 30), 1, 179) * DEG2RAD;
const tx = Math.tan(hf * 0.5);
const ty = Math.tan(vf * 0.5);
const crop = item.crop || {};
const c0x = clamp(Number(crop.x0 ?? 0), 0, 1);
const c0y = clamp(Number(crop.y0 ?? 0), 0, 1);
const c1x = clamp(Number(crop.x1 ?? 1), 0, 1);
const c1y = clamp(Number(crop.y1 ?? 1), 0, 1);
const cw = Math.max(1e-4, c1x - c0x);
const ch = Math.max(1e-4, c1y - c0y);
const basis = cameraBasis(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0), Number(item.rot_deg || item.roll_deg || 0));
const vertsX = [];
const vertsY = [];
const sampleX = [];
const sampleY = [];
for (let j = 0; j <= Nv; j++) {
const v = j / Nv;
const vv = c0y + v * ch;
const yScaled = (1 - vv * 2) * ty;
for (let i = 0; i <= Nu; i++) {
const u = i / Nu;
const uu = c0x + u * cw;
const xScaled = (uu * 2 - 1) * tx;
// Inlined dot, add, mul, norm
const dx = basis.fwd.x + basis.right.x * xScaled + basis.up.x * yScaled;
const dy = basis.fwd.y + basis.right.y * xScaled + basis.up.y * yScaled;
const dz = basis.fwd.z + basis.right.z * xScaled + basis.up.z * yScaled;
const mag = Math.hypot(dx, dy, dz) || 1e-8;
const nx = dx / mag;
const ny = dy / mag;
const nz = dz / mag;
// Inlined projectDirToPreview
const pcx = nx * viewBasis.right.x + ny * viewBasis.right.y + nz * viewBasis.right.z;
const pcy = nx * viewBasis.up.x + ny * viewBasis.up.y + nz * viewBasis.up.z;
const pcz = nx * viewBasis.fwd.x + ny * viewBasis.fwd.y + nz * viewBasis.fwd.z;
if (pcz <= 1e-4) {
if (!vertsX[j]) vertsX[j] = [];
if (!vertsY[j]) vertsY[j] = [];
vertsX[j][i] = null;
vertsY[j][i] = null;
} else {
const sy = (pcy / pcz) / tanHalfY;
const sx = (pcx / pcz) / tanHalfY;
if (!vertsX[j]) vertsX[j] = [];
if (!vertsY[j]) vertsY[j] = [];
vertsX[j][i] = rect.x + rect.w * 0.5 + sx * rect.h * 0.5;
vertsY[j][i] = rect.y + rect.h * 0.5 - sy * rect.h * 0.5;
}
if (!sampleX[j]) sampleX[j] = [];
if (!sampleY[j]) sampleY[j] = [];
sampleX[j][i] = u;
sampleY[j][i] = v;
}
}
const asset = state.assets?.[item.asset_id];
const img = getNodePreviewImage(node, item.asset_id, asset);
if (!img || !img.complete || !(img.naturalWidth || 0)) return;
const iw = Number(img.naturalWidth || img.width || 1);
const ih = Number(img.naturalHeight || img.height || 1);
const triExpand = (Nu < 10) ? 0.35 : 0.45;
ctx.__panoTriExpandPx = triExpand;
for (let j = 0; j < Nv; j++) {
for (let i = 0; i < Nu; i++) {
const x00 = vertsX[j][i];
const y00 = vertsY[j][i];
const x10 = vertsX[j][i + 1];
const y10 = vertsY[j][i + 1];
const x01 = vertsX[j + 1][i];
const y01 = vertsY[j + 1][i];
const x11 = vertsX[j + 1][i + 1];
const y11 = vertsY[j + 1][i + 1];
if (x00 === null || x10 === null || x01 === null || x11 === null) continue;
const s00x = sampleX[j][i] * iw, s00y = sampleY[j][i] * ih;
const s10x = sampleX[j][i + 1] * iw, s10y = sampleY[j][i + 1] * ih;
const s01x = sampleX[j + 1][i] * iw, s01y = sampleY[j + 1][i] * ih;
const s11x = sampleX[j + 1][i + 1] * iw, s11y = sampleY[j + 1][i + 1] * ih;
drawImageTri(ctx, img, { x: s00x, y: s00y }, { x: s10x, y: s10y }, { x: s11x, y: s11y }, { x: x00, y: y00 }, { x: x10, y: y10 }, { x: x11, y: y11 });
drawImageTri(ctx, img, { x: s00x, y: s00y }, { x: s11x, y: s11y }, { x: s01x, y: s01y }, { x: x00, y: y00 }, { x: x11, y: y11 }, { x: x01, y: y01 });
}
}
}
function applyPreviewInertia(node, ts = performance.now()) {
const m = node.__panoPreviewInertia;
if (!m || !m.active || !node.__panoPreviewView) return;
const dt = m.lastTs > 0 ? Math.max(0.001, (ts - m.lastTs) / 1000) : (1 / 60);
m.lastTs = ts;
node.__panoPreviewView.yaw = wrapYaw(Number(node.__panoPreviewView.yaw || 0) + m.vx * dt);
node.__panoPreviewView.pitch = clamp(Number(node.__panoPreviewView.pitch || 0) + m.vy * dt, -89.9, 89.9);
const damping = Math.exp(-5.5 * dt);
m.vx *= damping;
m.vy *= damping;
if (Math.abs(m.vx) < 0.8 && Math.abs(m.vy) < 0.8) {
m.vx = 0;
m.vy = 0;
m.active = false;
} else {
node.setDirtyCanvas?.(true, false);
}
}
function drawPanoramaPreview(node, ctx, interaction = null) {
const mode = String(node.__panoPreviewMode || "stickers");
const state = getCachedState(node);
const rect = getNodePreviewRect(node);
if (!rect) return;
if (!node.__panoPreviewView) node.__panoPreviewView = { yaw: 0, pitch: 0, fov: 100 };
const viewBasis = cameraBasis(Number(node.__panoPreviewView.yaw || 0), Number(node.__panoPreviewView.pitch || 0), 0);
const tanHalfY = Math.tan((Number(node.__panoPreviewView.fov || 100) * DEG2RAD) * 0.5);
ctx.save();
ctx.fillStyle = "#1a1a1e";
ctx.strokeStyle = "#3f3f46";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
ctx.clip();
const bgImg = getLinkedInputImageForPreview(
node,
preferredImageInputsForNode(node, ["erp_image", "bg_erp"]),
() => node.setDirtyCanvas?.(true, false)
);
const bgReady = !!(bgImg && bgImg.complete && (bgImg.naturalWidth || bgImg.width));
const resizingNow = isPreviewResizing(node);
const movingBase = interaction?.state ? (interaction.state.inertia.active || interaction.state.drag.active) : false;
const moving = resizingNow || movingBase;
const mesh = STANDALONE_MESH_LOW;
const scene = buildRuntimePreviewScene(node, state);
const textures = buildRuntimePreviewTextures(node, state, scene);
const view = buildPanoramaViewParamsFromRuntime(node.__panoPreviewView);
const drawn = bgReady ? renderSceneToContext2D({
owner: node,
cacheKey: "runtime_panorama_scene",
ctx,
rect,
backgroundSource: bgImg,
backgroundRevision: [
String(bgImg.currentSrc || bgImg.src || ""),
Number(bgImg.naturalWidth || bgImg.width || 0),
Number(bgImg.naturalHeight || bgImg.height || 0),
].join("|"),
textures,
scene,
view,
}) : false;
const stickerNu = moving ? 8 : 12;
const stickerNv = moving ? 6 : 9;
const stickers = getSortedStickers(node, state);
// Always draw grid if no background or if specifically needed for orientation
if (!bgReady || stickers.length === 0) {
const uiScale = getNodeUiScale(ctx?.canvas, rect);
drawPanoGrid(ctx, rect, viewBasis, tanHalfY, uiScale);
}
if (!drawn && bgReady) {
drawErpBackground(node, ctx, rect, viewBasis, tanHalfY, bgImg, mesh);
}
if (!drawn && stickers.length > 0) {
stickers.forEach((item) => drawSticker(ctx, node, rect, viewBasis, tanHalfY, state, item, stickerNu, stickerNv));
}
ctx.restore();
}
export function localPosFromEvent(node, e, arg2, canvas) {
if (Array.isArray(arg2) && arg2.length >= 2) return { x: arg2[0], y: arg2[1] };
if (arg2 && typeof arg2 === "object" && Number.isFinite(Number(arg2.x)) && Number.isFinite(Number(arg2.y))) {
return { x: Number(arg2.x), y: Number(arg2.y) };
}
if (canvas && Array.isArray(canvas.graph_mouse) && canvas.graph_mouse.length >= 2) {
return {
x: Number(canvas.graph_mouse[0]) - Number(node?.pos?.[0] || 0),
y: Number(canvas.graph_mouse[1]) - Number(node?.pos?.[1] || 0),
};
}
if (canvas && typeof canvas.convertEventToCanvasOffset === "function" && e) {
try {
const p = canvas.convertEventToCanvasOffset(e);
if (Array.isArray(p) && p.length >= 2) {
return {
x: Number(p[0]) - Number(node?.pos?.[0] || 0),
y: Number(p[1]) - Number(node?.pos?.[1] || 0),
};
}
} catch {
// ignore
}
}
if (Number.isFinite(Number(e?.canvasX)) && Number.isFinite(Number(e?.canvasY))) {
return {
x: Number(e.canvasX) - Number(node?.pos?.[0] || 0),
y: Number(e.canvasY) - Number(node?.pos?.[1] || 0),
};
}
const fromClient = localPosFromClientEvent(node, e, canvas);
if (fromClient) return fromClient;
return null;
}
function canvasGraphPosFromClient(e, canvas) {
const clientX = Number(e?.clientX);
const clientY = Number(e?.clientY);
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
const cv = canvas?.canvas || canvas?.canvasEl || canvas?.canvas_element || canvas?.front_canvas || null;
const rect = cv?.getBoundingClientRect?.();
const ds = canvas?.ds || app?.canvas?.ds;
if (!rect || !ds) return null;
const scale = Number(ds.scale || 1);
if (!Number.isFinite(scale) || Math.abs(scale) < 1e-6) return null;
const off = Array.isArray(ds.offset) ? ds.offset : [0, 0];
const gx = (clientX - Number(rect.left || 0)) / scale - Number(off[0] || 0);
const gy = (clientY - Number(rect.top || 0)) / scale - Number(off[1] || 0);
if (!Number.isFinite(gx) || !Number.isFinite(gy)) return null;
return { x: gx, y: gy };
}
function localPosFromClientEvent(node, e, canvas) {
const graphPos = canvasGraphPosFromClient(e, canvas || app?.canvas);
if (!graphPos) return null;
return {
x: graphPos.x - Number(node?.pos?.[0] || 0),
y: graphPos.y - Number(node?.pos?.[1] || 0),
};
}
function isLeftButtonPressed(e) {
if (Number(e?.button) === 0 && String(e?.type || "").toLowerCase().includes("down")) return true;
if (String(e?.type || "").toLowerCase() === "mouseup") return false;
if (typeof e?.buttons === "number") return (e.buttons & 1) === 1;
if (typeof e?.which === "number") return e.which === 1;
if (typeof e?.button === "number") return e.button === 0;
return false;
}
function attachLegacyStickersPreview(node) {
if (node.__panoLegacyPreviewHooked) return;
panoPreviewLog(node, "legacy.attach", {
mode: node.__panoPreviewMode,
nodeType: String(node?.comfyClass || node?.type || node?.title || ""),
nodeSize: getNodeSize(node),
});
node.__panoLegacyPreviewHooked = true;
node.__panoPreviewHooked = true;
node.__panoActiveBackend = "legacy_draw";
initNodeSizeIfInvalid(node, 320, 180);
suppressBuiltInPreviewImgs(node);
const prevDrawForeground = node.onDrawForeground;
const prevResize = node.onResize;
node.onDrawForeground = function () {
const r = prevDrawForeground ? prevDrawForeground.apply(this, arguments) : undefined;
try {
const ctx = arguments[0];
if (ctx && !this.flags?.collapsed) {
if (interaction.stepInertia(performance.now())) {
this.setDirtyCanvas?.(true, false);
}
drawPanoramaPreview(this, ctx, interaction);
}
} catch {
// keep resilient
}
return r;
};
node.onResize = function () {
const r = prevResize ? prevResize.apply(this, arguments) : undefined;
this.__panoUserResized = true;
markPreviewResizing(this, 150);
this.setDirtyCanvas?.(true, true);
scheduleResizeSettleDraw(this, 220, null);
return r;
};
const interaction = createPanoInteractionController({
getView: () => {
if (!node.__panoPreviewView) node.__panoPreviewView = { yaw: 0, pitch: 0, fov: 100 };
return node.__panoPreviewView;
},
setView: (v) => {
node.__panoPreviewView = v;
},
onInteraction: () => {
markStandaloneInteractionSettled(node);
node.setDirtyCanvas?.(true, false);
},
});
const prevMouseDown = node.onMouseDown;
node.onMouseDown = function (e, localPos, canvas) {
try {
if (!this.flags?.collapsed) {
const p = localPosFromEvent(this, e, localPos, canvas) || { x: 0, y: 0 };
const nodeSize = getNodeSize(this);
const nodeW = Number(nodeSize?.[0] || 0);
const nodeH = Number(nodeSize?.[1] || 0);
const gripHit = p.x >= nodeW - 20 && p.y >= nodeH - 20;
if (gripHit) {
return prevMouseDown ? prevMouseDown.apply(this, arguments) : undefined;
}
const rect = getNodePreviewRect(this);
if (pointInRect(p.x, p.y, rect)) {
if (e?.button === 0) {
interaction.startDrag(p.x, p.y);
return true;
}
return true;
}
}
} catch { }
return prevMouseDown ? prevMouseDown.apply(this, arguments) : undefined;
};
const prevMouseMove = node.onMouseMove;
node.onMouseMove = function (e, localPos, canvas) {
try {
const p = localPosFromEvent(this, e, localPos, canvas);
if (interaction.state.drag.active) {
if (typeof e?.buttons === "number" && (e.buttons & 1) === 0) {
interaction.endDrag();
return true;
}
const pp = p || interaction.state.drag;
interaction.moveDrag(pp.x, pp.y, "pano");
return true;
}
} catch { }
return prevMouseMove ? prevMouseMove.apply(this, arguments) : undefined;
};
const prevMouseUp = node.onMouseUp;
node.onMouseUp = function () {
if (interaction.state.drag.active) {
interaction.endDrag();
return true;
}
return prevMouseUp ? prevMouseUp.apply(this, arguments) : undefined;
};
const prevMouseLeave = node.onMouseLeave;
node.onMouseLeave = function () {
const r = prevMouseLeave ? prevMouseLeave.apply(this, arguments) : undefined;
if (interaction.state.drag.active) {
interaction.endDrag();
}
return r;
};
const prevMouseWheel = node.onMouseWheel;
node.onMouseWheel = function (e, arg2, canvas) {
try {
if (this.flags?.collapsed) return prevMouseWheel ? prevMouseWheel.apply(this, arguments) : undefined;
const p = localPosFromEvent(this, e, arg2, canvas);
const rect = getNodePreviewRect(this);
if (!p || !pointInRect(p.x, p.y, rect)) return prevMouseWheel ? prevMouseWheel.apply(this, arguments) : undefined;
const raw = Number(e?.deltaY ?? e?.wheelDeltaY ?? (typeof arg2 === "number" ? arg2 : 0));
if (interaction.applyWheel(Math.sign(raw))) {
this.setDirtyCanvas?.(true, false);
}
if (typeof e?.preventDefault === "function") e.preventDefault();
if (typeof e?.stopPropagation === "function") e.stopPropagation();
return true;
} catch {
return prevMouseWheel ? prevMouseWheel.apply(this, arguments) : undefined;
}
};
const prevRemoved = node.onRemoved;
const restoreLegacy = () => {
if (!node.__panoLegacyPreviewHooked) return;
if (node.__panoResizeSettleTimer) {
clearTimeout(node.__panoResizeSettleTimer);
node.__panoResizeSettleTimer = null;
}
node.onResize = prevResize;
node.onDrawForeground = prevDrawForeground;
node.onMouseDown = prevMouseDown;
node.onMouseMove = prevMouseMove;
node.onMouseUp = prevMouseUp;
node.onMouseLeave = prevMouseLeave;
node.onMouseWheel = prevMouseWheel;
node.onRemoved = prevRemoved;
node.__panoLegacyPreviewHooked = false;
node.__panoPreviewHooked = false;
node.__panoPreviewDrag = null;
node.__panoLegacyRestore = null;
};
node.__panoLegacyRestore = restoreLegacy;
node.onRemoved = function () {
restoreLegacy();
previewMonitor.unregister(this);
executedRefreshMonitor.unregister(this);
return prevRemoved ? prevRemoved.apply(this, arguments) : undefined;
};
}
function drawCanvas(node, canvas, fovBtn, interaction = null) {
const mode = String(node.__panoPreviewMode || "stickers");
ensureRenderCache(node, mode);
const state = getCachedState(node);
const ownOut = mode === "cutout"
? getNodeOwnOutputImage(node, () => node.__panoDomPreview?.requestDraw?.())
: null;
const ownOutReady = !!(ownOut && ownOut.complete && (ownOut.naturalWidth || ownOut.width));
const wrap = canvas.parentElement;
const dpr = 1;
const cw = Math.max(1, Number(wrap?.clientWidth || canvas.clientWidth || 0));
const ch = Math.max(1, Number(wrap?.clientHeight || canvas.clientHeight || 0));
const surfW = Math.max(64, Math.round(cw * dpr));
const surfH = Math.max(64, Math.round(ch * dpr));
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (mode === "cutout") {
const shot = getActiveShot(state);
if (canvas.width !== surfW || canvas.height !== surfH) {
canvas.width = surfW;
canvas.height = surfH;
}
const rect = { x: 0, y: 0, w: surfW, h: surfH };
const ownAspect = ownOutReady
? clamp(Number((ownOut.naturalWidth || ownOut.width) / Math.max(1, Number(ownOut.naturalHeight || ownOut.height || 1))), 0.05, 20.0)
: 1;
const cutoutView = shot ? buildCutoutViewParamsFromShot(shot) : null;
const aspect = clamp(Number(cutoutView?.aspect || ownAspect || 1), 0.05, 20.0);
const contain = containRect(rect, aspect);
const uiScale = getNodeUiScale(canvas, rect);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "#070707";
ctx.fillRect(0, 0, surfW, surfH);
const bgImg = getLinkedInputImageForPreview(
node,
["erp_image", "bg_erp"],
() => node.__panoDomPreview?.requestDraw?.(),
);
const bgReady = !!(bgImg && bgImg.complete && (bgImg.naturalWidth || bgImg.width));
let statusType = "none";
let hintText = "Open editor and add frame";
if (!shot) {
if (ownOutReady) {
ctx.drawImage(ownOut, contain.x, contain.y, contain.w, contain.h);
} else {
statusType = "empty";
hintText = "Open editor and add frame";
}
} else if (bgReady) {
const glDrawn = renderCutoutViewToContext2D({
owner: node,
cacheKey: "runtime_cutout_bg",
ctx,
rect: contain,
img: bgImg,
view: cutoutView,
});
const fallbackDrawn = !glDrawn && !!drawCutoutProjectionPreview(ctx, node, bgImg, contain, shot, "draft");
const rawLiveDrawn = glDrawn || fallbackDrawn;
panoPreviewLog(node, "cutout.path", {
glDrawn: !!glDrawn,
fallbackDrawn: !!fallbackDrawn,
hasShot: !!shot,
});
const liveDrawnValidated = !!glDrawn || (!!fallbackDrawn && hasValidCutoutStats(node));
if (liveDrawnValidated) {
} else if (ownOutReady) {
ctx.drawImage(ownOut, contain.x, contain.y, contain.w, contain.h);
} else {
statusType = "empty";
hintText = "Cutout preview unavailable";
}
} else if (ownOutReady) {
ctx.drawImage(ownOut, contain.x, contain.y, contain.w, contain.h);
} else if (bgImg && !bgReady) {
statusType = "loading";
} else {
statusType = "empty";
hintText = "Connect ERP image";
}
if (statusType === "loading") {
drawLoadingOverlay(ctx, contain, uiScale);
setCutoutEmptyHint(node, false);
setRenderLoading(node, true, String(bgImg?.src || ""));
} else if (statusType === "empty") {
setCutoutEmptyHint(node, true, hintText);
setRenderLoading(node, false, "");
} else {
setCutoutEmptyHint(node, false);
setRenderLoading(node, false, "");
}
} else {
setCutoutEmptyHint(node, false);
// Stickers mode: Canvas resolution follows DOM size
if (canvas.width !== surfW || canvas.height !== surfH) {
canvas.width = surfW;
canvas.height = surfH;
}
const rect = { x: 0, y: 0, w: surfW, h: surfH };
const uiScale = getNodeUiScale(canvas, rect);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, surfW, surfH);
if (!node.__panoPreviewView) node.__panoPreviewView = { yaw: 0, pitch: 0, fov: 100 };
const viewBasis = cameraBasis(Number(node.__panoPreviewView.yaw || 0), Number(node.__panoPreviewView.pitch || 0), 0);
const tanHalfY = Math.tan((Number(node.__panoPreviewView.fov || 100) * DEG2RAD) * 0.5);
const bgImg = getLinkedInputImageForPreview(
node,
["erp_image", "bg_erp"],
() => node.__panoDomPreview?.requestDraw?.()
);
const bgReady = !!(bgImg && bgImg.complete && (bgImg.naturalWidth || bgImg.width));
// Base background (distinct from pure black to indicate active rendering)
ctx.fillStyle = "#1a1a1e";
ctx.fillRect(0, 0, surfW, surfH);
const scene = buildRuntimePreviewScene(node, state);
const textures = buildRuntimePreviewTextures(node, state, scene);
const view = buildPanoramaViewParamsFromRuntime(node.__panoPreviewView);
const drawn = bgReady ? renderSceneToContext2D({
owner: node,
cacheKey: "runtime_dom_scene",
ctx,
rect,
backgroundSource: bgImg,
backgroundRevision: [
String(bgImg.currentSrc || bgImg.src || ""),
Number(bgImg.naturalWidth || bgImg.width || 0),
Number(bgImg.naturalHeight || bgImg.height || 0),
].join("|"),
textures,
scene,
view,
}) : false;
const stickers = scene.stickers;
if (bgReady && drawn) {
setRenderLoading(node, false, "");
} else if (bgReady) {
drawErpBackground(node, ctx, rect, viewBasis, tanHalfY, bgImg, STANDALONE_MESH_LOW);
setRenderLoading(node, false, "");
} else {
const loading = !!bgImg && !bgReady;
setRenderLoading(node, loading, String(bgImg?.src || ""));
if (loading) {
drawLoadingOverlay(ctx, rect, uiScale);
}
}
// Always draw guide grid when no background or just to assist orientation
if (!bgReady || stickers.length === 0) {
drawPanoGrid(ctx, rect, viewBasis, tanHalfY, uiScale);
}
if (!drawn && stickers.length > 0) {
const resizingNow = isPreviewResizing(node);
const movingBase = interaction?.state ? (interaction.state.inertia.active || interaction.state.drag.active) : false;
const moving = resizingNow || movingBase;
const stickerNu = moving ? 8 : 12;
const stickerNv = moving ? 6 : 9;
stickers.forEach((item) => drawSticker(ctx, node, rect, viewBasis, tanHalfY, state, item, stickerNu, stickerNv));
} else if (!bgImg || !bgReady) {
const hintPx = Math.max(14, Math.round(16 * uiScale));
ctx.fillStyle = "rgba(212,212,216,0.85)";
ctx.font = `600 ${hintPx}px Plus Jakarta Sans, Geist, sans-serif`;
ctx.textAlign = "center";
ctx.fillText("Open editor to add stickers", surfW * 0.5, surfH * 0.5 + 24 * uiScale);
}
if (fovBtn) {
fovBtn.textContent = `FOV ${Math.round(Number(node.__panoPreviewView.fov || 100))}`;
}
}
}
export function drawErpBackground(node, ctx, rect, viewBasis, tanHalfY, img, mesh = STANDALONE_MESH_BALANCED) {
if (!img || !img.complete || !(img.naturalWidth || img.width)) return;
const view = node?.__panoPreviewView || { yaw: 0, pitch: 0, fov: 100 };
if (renderErpViewToContext2D({
owner: node,
cacheKey: "runtime_pano_bg",
ctx,
rect,
img,
mode: "panorama",
yawDeg: Number(view.yaw || 0),
pitchDeg: Number(view.pitch || 0),
fovDeg: Number(view.fov || 100),
})) return;
const iw = Number(img.naturalWidth || img.width || 0);
const ih = Number(img.naturalHeight || img.height || 0);
if (iw <= 1 || ih <= 1) return;
const wrapped = getWrappedErpCanvas(node, img);
const source = wrapped || img;
const Nu = Math.max(4, Number(mesh?.Nu || STANDALONE_MESH_BALANCED.Nu));
const Nv = Math.max(4, Number(mesh?.Nv || STANDALONE_MESH_BALANCED.Nv));
const triExpand = (Nu <= STANDALONE_MESH_LOW.Nu && Nv <= STANDALONE_MESH_LOW.Nv) ? 0.24 : ((Nu >= STANDALONE_MESH_HIGH.Nu && Nv >= STANDALONE_MESH_HIGH.Nv) ? 0.42 : 0.34);
const verts = [];
const sample = [];
for (let j = 0; j <= Nv; j++) {
verts[j] = [];
sample[j] = [];
}
const cx = rect.x + rect.w * 0.5;
const cy = rect.y + rect.h * 0.5;
const hRectH = rect.h * 0.5;
for (let j = 0; j <= Nv; j++) {
const y = rect.y + (rect.h * j) / Nv;
const sy = ((cy - y) / hRectH) * tanHalfY;
for (let i = 0; i <= Nu; i++) {
const x = rect.x + (rect.w * i) / Nu;
const sx = ((x - cx) / hRectH) * tanHalfY;
// Inlined dot, add, mul, norm for d
const dx = viewBasis.fwd.x + viewBasis.right.x * sx + viewBasis.up.x * sy;
const dy = viewBasis.fwd.y + viewBasis.right.y * sx + viewBasis.up.y * sy;
const dz = viewBasis.fwd.z + viewBasis.right.z * sx + viewBasis.up.z * sy;
const dmag = Math.hypot(dx, dy, dz) || 1e-8;
const dnx = dx / dmag;
const dny = dy / dmag;
const dnz = dz / dmag;
const lon = Math.atan2(dnx, dnz);
const lat = Math.asin(clamp(dny, -1, 1));
let u = (lon / (2 * Math.PI) + 0.5) * iw;
while (u < 0) u += iw;
while (u >= iw) u -= iw;
const v = (0.5 - lat / Math.PI) * ih;
verts[j][i] = { x, y };
sample[j][i] = { x: u, y: v };
}
}
ctx.save();
ctx.__panoTriExpandPx = triExpand;
// Use opaque rendering for background to avoid seam transparency.
ctx.globalAlpha = 1.0;
for (let j = 0; j < Nv; j += 1) {
for (let i = 0; i < Nu; i += 1) {
const p00 = verts[j][i];
const p10 = verts[j][i + 1];
const p01 = verts[j + 1][i];
const p11 = verts[j + 1][i + 1];
if (!p00 || !p10 || !p01 || !p11) continue;
const s00 = { ...sample[j][i] };
const s10 = { ...sample[j][i + 1] };
const s01 = { ...sample[j + 1][i] };
const s11 = { ...sample[j + 1][i + 1] };
const umin = Math.min(s00.x, s10.x, s01.x, s11.x);
const umax = Math.max(s00.x, s10.x, s01.x, s11.x);
if (umax - umin > iw * 0.5) {
[s00, s10, s01, s11].forEach((s) => {
if (s.x < iw * 0.5) s.x += iw;
});
}
drawImageTri(ctx, source, s00, s10, s11, p00, p10, p11);
drawImageTri(ctx, source, s00, s11, s01, p00, p11, p01);
}
}
ctx.__panoTriExpandPx = 0.45;
ctx.restore();
}
function drawLineOnSphere(ctx, pointsDir, viewBasis, rect, tanHalfY, color, width = 1) {
let started = false;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
for (const d of pointsDir) {
const p = projectDirToPreview(d, viewBasis, rect, tanHalfY);
if (!p) { started = false; continue; }
if (!started) { ctx.moveTo(p.x, p.y); started = true; }
else { ctx.lineTo(p.x, p.y); }
}
ctx.stroke();
}
function drawPanoGrid(ctx, rect, viewBasis, tanHalfY, uiScale = 1) {
ctx.save();
const gridColor = "rgba(63, 63, 70, 0.5)";
const equatorColor = "rgba(250, 250, 250, 0.35)";
// Latitudes
for (let lat = -75; lat <= 75; lat += 15) {
const pts = [];
for (let lon = -180; lon <= 180; lon += 15) pts.push(yawPitchToDir(lon, lat));
drawLineOnSphere(ctx, pts, viewBasis, rect, tanHalfY, lat === 0 ? equatorColor : gridColor, lat === 0 ? 1.5 : 1);
}
// Longitudes
for (let lon = -180; lon <= 180; lon += 45) {
const pts = [];
for (let lat = -89; lat <= 89; lat += 15) pts.push(yawPitchToDir(lon, lat));
drawLineOnSphere(ctx, pts, viewBasis, rect, tanHalfY, lon % 90 === 0 ? "rgba(82, 82, 91, 0.4)" : gridColor, lon % 90 === 0 ? 1.2 : 1);
}
// Labels
const labels = [
{ name: "Front", dir: yawPitchToDir(0, 0) },
{ name: "Right", dir: yawPitchToDir(90, 0) },
{ name: "Back", dir: yawPitchToDir(180, 0) },
{ name: "Left", dir: yawPitchToDir(-90, 0) },
];
ctx.fillStyle = "rgba(250, 250, 250, 0.32)";
const fontSize = Math.max(9, Math.round(11 * uiScale));
ctx.font = `500 ${fontSize}px Plus Jakarta Sans, Geist, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
labels.forEach((l) => {
const p = projectDirToPreview(l.dir, viewBasis, rect, tanHalfY);
if (p) {
ctx.fillText(l.name, p.x, p.y + 20 * uiScale);
}
});
ctx.restore();
}
function stepStandaloneInertia(node, ts = performance.now()) {
const m = node.__panoPreviewInertia;
if (!m || !m.active || !node.__panoPreviewView) return false;
if (!Number.isFinite(Number(m.vx)) || !Number.isFinite(Number(m.vy))) {
m.vx = 0;
m.vy = 0;
m.active = false;
return false;
}
const dt = m.lastTs > 0 ? Math.max(0.001, (ts - m.lastTs) / 1000) : (1 / 60);
m.lastTs = ts;
node.__panoPreviewView.yaw = wrapYaw(Number(node.__panoPreviewView.yaw || 0) + m.vx * dt);
node.__panoPreviewView.pitch = clamp(Number(node.__panoPreviewView.pitch || 0) + m.vy * dt, -89.9, 89.9);
if (!Number.isFinite(Number(node.__panoPreviewView.yaw)) || !Number.isFinite(Number(node.__panoPreviewView.pitch))) {
node.__panoPreviewView.yaw = 0;
node.__panoPreviewView.pitch = 0;
m.vx = 0;
m.vy = 0;
m.active = false;
return false;
}
const damping = Math.exp(-STANDALONE_INERTIA_DECAY * dt);
m.vx *= damping;
m.vy *= damping;
if (Math.abs(m.vx) < STANDALONE_INERTIA_STOP_SPEED && Math.abs(m.vy) < STANDALONE_INERTIA_STOP_SPEED) {
m.vx = 0;
m.vy = 0;
m.active = false;
}
return m.active;
}
function ensureStandaloneRenderState(node) {
if (!node.__panoStandaloneRenderState) {
node.__panoStandaloneRenderState = { dragging: false, hqFrames: 0, frameEmaMs: 0, lastFrameTs: 0, qualityTier: "balanced" };
}
return node.__panoStandaloneRenderState;
}
function markStandaloneInteractionActive(node, active) {
const st = ensureStandaloneRenderState(node);
st.dragging = !!active;
if (active) st.hqFrames = 0;
}
function markStandaloneInteractionSettled(node) {
const st = ensureStandaloneRenderState(node);
st.dragging = false;
st.hqFrames = STANDALONE_HQ_FRAMES_AFTER_INTERACTION;
}
function markPreviewResizing(node, windowMs = 150, ts = performance.now()) {
if (!node) return;
node.__panoResizeUntil = Number(ts || performance.now()) + Math.max(0, Number(windowMs || 0));
}
function isPreviewResizing(node, ts = performance.now()) {
return Number(ts || performance.now()) < Number(node?.__panoResizeUntil || 0);
}
function recordStandaloneFrame(node, ts = performance.now()) {
const st = ensureStandaloneRenderState(node);
if (st.lastFrameTs > 0) {
const dt = Math.max(1, ts - st.lastFrameTs);
if (st.frameEmaMs <= 0) st.frameEmaMs = dt;
else st.frameEmaMs = st.frameEmaMs * (1 - STANDALONE_FRAME_EMA_ALPHA) + dt * STANDALONE_FRAME_EMA_ALPHA;
}
st.lastFrameTs = ts;
return st.frameEmaMs;
}
function pickStandaloneMeshQuality(node, moving = false, frameEmaMs = 0) {
const st = ensureStandaloneRenderState(node);
const ui = loadSharedUiSettings();
let tier = "balanced";
if (st.dragging || moving) tier = "low";
else if (st.hqFrames > 0) {
st.hqFrames -= 1;
tier = "high";
} else if (ui.preview_quality === "draft") tier = "low";
else if (ui.preview_quality === "high") tier = "high";
if (Number(frameEmaMs || 0) > STANDALONE_FRAME_DEGRADE_MS) {
if (tier === "high") tier = "balanced";
else if (tier === "balanced") tier = "low";
}
st.qualityTier = tier;
if (tier === "low") return STANDALONE_MESH_LOW;
if (tier === "high") return STANDALONE_MESH_HIGH;
return STANDALONE_MESH_BALANCED;
}
function beginStandaloneDrag(node, x, y, ts = performance.now()) {
const drag = { active: true, lastX: Number(x || 0), lastY: Number(y || 0), lastTs: Number(ts || performance.now()) };
node.__panoPreviewDrag = drag;
node.__panoPreviewInertia = { vx: 0, vy: 0, active: false, lastTs: drag.lastTs };
markStandaloneInteractionActive(node, true);
return drag;
}
function updateStandaloneDrag(node, x, y, ts = performance.now(), uiSettings = null) {
const d = node.__panoPreviewDrag;
if (!d?.active) return false;
const now = Number(ts || performance.now());
const px = Number.isFinite(Number(x)) ? Number(x) : d.lastX;
const py = Number.isFinite(Number(y)) ? Number(y) : d.lastY;
const dt = Math.max(0.001, (now - (d.lastTs || now)) / 1000);
const dx = px - d.lastX;
const dy = py - d.lastY;
d.lastX = px;
d.lastY = py;
d.lastTs = now;
const ui = uiSettings || loadSharedUiSettings();
const invX = ui.invert_view_x ? -1 : 1;
const invY = ui.invert_view_y ? -1 : 1;
const dYaw = -dx * STANDALONE_DRAG_SENSITIVITY * invX;
const dPitch = dy * STANDALONE_DRAG_SENSITIVITY * invY;
const v = ensureStandaloneView(node);
v.yaw = wrapYaw(Number(v.yaw || 0) + dYaw);
v.pitch = clamp(Number(v.pitch || 0) + dPitch, -89.9, 89.9);
if (!node.__panoPreviewInertia) node.__panoPreviewInertia = { vx: 0, vy: 0, active: false, lastTs: now };
node.__panoPreviewInertia.vx = node.__panoPreviewInertia.vx * (1 - STANDALONE_INERTIA_BLEND) + (dYaw / dt) * STANDALONE_INERTIA_BLEND;
node.__panoPreviewInertia.vy = node.__panoPreviewInertia.vy * (1 - STANDALONE_INERTIA_BLEND) + (dPitch / dt) * STANDALONE_INERTIA_BLEND;
node.__panoPreviewInertia.active = false;
node.__panoPreviewInertia.lastTs = now;
return true;
}
function endStandaloneDrag(node, ts = performance.now()) {
const d = node.__panoPreviewDrag;
if (!d?.active) return false;
d.active = false;
markStandaloneInteractionSettled(node);
const m = node.__panoPreviewInertia;
if (m) {
const speed = Math.hypot(m.vx || 0, m.vy || 0);
m.active = speed > STANDALONE_INERTIA_START_SPEED;
m.lastTs = Number(ts || performance.now());
}
return true;
}
function resolveStandaloneLocalPos(node, e, arg2, canvas, lastPos = null) {
const p = localPosFromEvent(node, e, arg2, canvas);
if (p) return p;
const cp = localPosFromClientEvent(node, e, canvas);
if (cp) return cp;
if (lastPos && Number.isFinite(Number(lastPos.x)) && Number.isFinite(Number(lastPos.y))) {
return { x: Number(lastPos.x), y: Number(lastPos.y) };
}
if (canvas && Array.isArray(canvas.graph_mouse) && canvas.graph_mouse.length >= 2) {
return {
x: Number(canvas.graph_mouse[0]) - Number(node?.pos?.[0] || 0),
y: Number(canvas.graph_mouse[1]) - Number(node?.pos?.[1] || 0),
};
}
return null;
}
function stopCanvasEvent(ev) {
ev.preventDefault();
ev.stopPropagation();
ev.stopImmediatePropagation?.();
}
function isResizeGripEventOnElement(ev, element, margin = 20) {
if (!ev || !element || !Number.isFinite(Number(ev.clientX)) || !Number.isFinite(Number(ev.clientY))) return false;
const rect = element.getBoundingClientRect?.();
if (!rect) return false;
const x = Number(ev.clientX) - Number(rect.left || 0);
const y = Number(ev.clientY) - Number(rect.top || 0);
return x >= (rect.width - margin) && y >= (rect.height - margin);
}
function stopCanvasEventUnlessResizeGrip(ev, element) {
if (isResizeGripEventOnElement(ev, element)) return false;
stopCanvasEvent(ev);
return true;
}
function isWheelEventInside(root, ev) {
if (!root || !ev) return false;
const path = typeof ev.composedPath === "function" ? ev.composedPath() : null;
if (Array.isArray(path) && path.includes(root)) return true;
const target = ev.target;
return !!(target && typeof root.contains === "function" && root.contains(target));
}
function readWheelDelta(ev) {
if (Number.isFinite(Number(ev?.deltaY))) return Number(ev.deltaY);
if (Number.isFinite(Number(ev?.wheelDelta))) return -Number(ev.wheelDelta);
if (Number.isFinite(Number(ev?.detail))) return Number(ev.detail) * 40;
return 0;
}
export function lockGraphViewportSnapshot() {
const ds = app?.canvas?.ds;
if (!ds) return null;
const offset = Array.isArray(ds.offset) ? [Number(ds.offset[0] || 0), Number(ds.offset[1] || 0)] : [0, 0];
return { scale: Number(ds.scale || 1), offset };
}
export function restoreGraphViewportSnapshot(snapshot) {
if (!snapshot) return;
const ds = app?.canvas?.ds;
if (!ds) return;
ds.scale = snapshot.scale;
if (Array.isArray(ds.offset)) {
ds.offset[0] = snapshot.offset[0];
ds.offset[1] = snapshot.offset[1];
}
}
function ensureNodeDefaultSize(node, minW = 320, minH = 260) {
initNodeSizeIfInvalid(node, minW, minH);
}
function ensureStandalonePreviewBootstrapSize(node, minW = 360, minH = 260) {
if (!node || node.__panoUserResized === true || node.__panoStandaloneBootSized === true) return;
const size = getNodeSize(node);
const w = Number(size?.[0] || 0);
const h = Number(size?.[1] || 0);
if (!Number.isFinite(w) || !Number.isFinite(h)) return;
if (w >= minW && h >= minH) return;
const next = [Math.max(minW, w), Math.max(minH, h)];
try {
if (typeof node.setSize === "function") node.setSize(next);
else node.size = next;
node.__panoStandaloneBootSized = true;
panoPreviewLog(node, "size.bootstrap", { from: [w, h], to: next });
} catch (err) {
panoPreviewLog(node, "size.bootstrap.error", { message: String(err?.message || err || "unknown") });
}
}
function createCoreManagedDomWidgetOptions(node, requestDraw = null, bootMinHeight = 0) {
return {
serialize: false,
hideOnZoom: false,
getValue() { return ""; },
setValue() { },
getMinHeight() {
if (node?.__panoBootMinHeightActive) return Math.max(0, Number(bootMinHeight || 0));
return 0;
},
// Node2 can collapse DOM widget height to 0 when only "auto" is returned.
// While bootstrapping, provide an explicit numeric height contract.
getHeight() {
if (node?.__panoBootMinHeightActive) return Math.max(0, Number(bootMinHeight || 0));
return "auto";
},
afterResize() {
node.__panoUserResized = true;
markPreviewResizing(node, 150);
requestDraw?.();
scheduleResizeSettleDraw(node, 180, requestDraw);
},
};
}
function scheduleResizeSettleDraw(node, delayMs = 180, drawCb = null) {
if (!node) return;
if (node.__panoResizeSettleTimer) {
clearTimeout(node.__panoResizeSettleTimer);
node.__panoResizeSettleTimer = null;
}
node.__panoResizeSettleTimer = setTimeout(() => {
node.__panoResizeSettleTimer = null;
drawCb?.();
node.setDirtyCanvas?.(true, true);
}, Math.max(0, Number(delayMs || 0)));
}
function suppressBuiltInPreviewImgs(node) {
if (!node) return;
if (Array.isArray(node.imgs) && node.imgs.length) node.imgs = [];
}
function ensureStandaloneView(node) {
if (!node.__panoStandaloneView) node.__panoStandaloneView = { yaw: 0, pitch: 0, fov: 100 };
return node.__panoStandaloneView;
}
function clearStandaloneUnavailableLabel(node) {
if (!node) return;
if (typeof node.__panoStandaloneUnavailableRestore === "function") {
try {
node.__panoStandaloneUnavailableRestore();
} catch {
// ignore
}
}
node.__panoStandaloneUnavailableRestore = null;
node.__panoStandaloneUnavailable = false;
}
function setStandaloneUnavailableLabel(node, text = "Node 2.0 preview unavailable") {
if (!node) return;
if (node.__panoStandaloneUnavailable) return;
const prevDrawForeground = node.onDrawForeground;
node.onDrawForeground = function (ctx) {
const out = prevDrawForeground ? prevDrawForeground.apply(this, arguments) : undefined;
if (!ctx || this.flags?.collapsed) return out;
const x = 8;
const y = 44;
const w = Math.max(120, Number(this.size?.[0] || 320) - 16);
const h = Math.max(70, Number(this.size?.[1] || 240) - 52);
ctx.save();
ctx.fillStyle = "rgba(20,20,24,0.94)";
ctx.strokeStyle = "rgba(78,78,86,0.95)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(236,236,242,0.92)";
ctx.font = "600 12px Plus Jakarta Sans, Geist, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(text), x + w * 0.5, y + h * 0.5);
ctx.restore();
return out;
};
node.__panoStandaloneUnavailableRestore = () => {
node.onDrawForeground = prevDrawForeground;
node.__panoStandaloneUnavailableRestore = null;
node.__panoStandaloneUnavailable = false;
};
node.__panoStandaloneUnavailable = true;
node.setDirtyCanvas?.(true, true);
}
function drawStandalonePanorama(node, ctx, rect, imageInputName = "erp_image", mesh = STANDALONE_MESH_LOW) {
ensureRenderCache(node, "standalone");
const uiScale = getNodeUiScale(ctx?.canvas, rect);
const view = ensureStandaloneView(node);
const basis = cameraBasis(Number(view.yaw || 0), Number(view.pitch || 0), 0);
const tanHalfY = Math.tan((Number(view.fov || 100) * DEG2RAD) * 0.5);
const preferredInputs = preferredImageInputsForNode(node, [imageInputName, "erp_image", "bg_erp"]);
const bgImg = getLinkedInputImageForPreview(
node,
preferredInputs,
() => node.__panoDomPreview?.requestDraw?.(),
);
const bgReady = !!(bgImg && bgImg.complete && (bgImg.naturalWidth || bgImg.width));
const src = String(bgImg?.src || "");
if (bgReady) {
ctx.fillStyle = "#070707";
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
drawErpBackground(node, ctx, rect, basis, tanHalfY, bgImg, mesh);
} else {
ctx.fillStyle = "#070707";
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
}
const overlay = setRenderLoading(node, !!bgImg && !bgReady, src);
if (overlay) drawLoadingOverlay(ctx, rect, uiScale);
}
function openStandaloneModal(node, title, imageInputName = "erp_image") {
if (typeof node?.__panoOpenEditor === "function") {
node.__panoOpenEditor();
return;
}
const _ = [title, imageInputName];
void _;
}
function attachPanoramaPreviewImpl(node, options = {}) {
const allowStandalone = options.__allowStandalone === true;
const standalonePreview = isStandalonePreviewNode(node);
if (standalonePreview && !allowStandalone) {
panoPreviewLog(node, "attach.skip", { reason: "standalone_preview_node", route: "blocked_legacy_path", nodeType: getNodeTypeName(node) });
return;
}
if (standalonePreview) {
ensureStandalonePreviewBootstrapSize(node);
}
node.__panoPreviewMode = String(options.mode || "stickers") === "cutout" ? "cutout" : "stickers";
executedRefreshMonitor.register(node);
const stickersMode = node.__panoPreviewMode === "stickers";
const noLegacyFallback = options.__noLegacyFallback === true;
const interactiveView = node.__panoPreviewMode !== "cutout";
node.__panoAttachOptions = { ...options, mode: node.__panoPreviewMode };
node.__panoPreviewButtonText = String(options.buttonText || (node.__panoPreviewMode === "cutout" ? "Open Cutout Editor" : "Open Stickers Editor"));
node.__panoOpenEditor = typeof options.onOpen === "function" ? options.onOpen : null;
const nodeType = String(node?.comfyClass || node?.type || node?.title || "");
if (!stickersMode) previewMonitor.unregister(node);
const forcedPath = String(options.__panoForcedPath || "").trim();
const pathInfo = resolveStickersPreviewPath(node.__panoPreviewMode, node);
if (stickersMode && (forcedPath === "dom" || forcedPath === "legacy_draw")) {
pathInfo.chosenPath = forcedPath;
pathInfo.reason = "controller_target_backend";
}
const chosenPath = stickersMode ? pathInfo.chosenPath : "dom";
const signature = pathInfo.signature || getFrontendSignature(node);
const generation = Number(node.__panoRebindGeneration || 0);
const activeBackend = getActivePreviewBackend(node);
panoPreviewLog(node, "attach.begin", {
nodeId: node?.id ?? null,
nodeType,
mode: node.__panoPreviewMode,
legacyFrontend: chosenPath === "legacy_draw",
activeBackend,
targetBackend: chosenPath,
chosenPath,
pathReason: pathInfo.reason,
signature,
generation,
frontendVersionRaw: pathInfo.frontendVersionRaw,
hasDomWidget: typeof node.addDOMWidget === "function",
size: getNodeSize(node),
});
node.__panoFrontendSig = signature;
node.__panoStickersPath = chosenPath;
if (stickersMode) previewMonitor.register(node);
if (stickersMode && chosenPath === "legacy_draw") {
if (noLegacyFallback) return;
teardownPreview(node, { keepMonitor: true });
attachLegacyStickersPreview(node);
return;
}
if (node.__panoDomPreview?.widget) return;
if (node.__panoLegacyPreviewHooked) teardownPreview(node, { keepMonitor: true });
if (typeof node.addDOMWidget !== "function") {
if (stickersMode && !noLegacyFallback) attachLegacyStickersPreview(node);
return;
}
const noPreview = options.noPreview === true;
const root = document.createElement("div");
ensurePreviewModeCss();
root.className = "pano-node-preview-dom";
root.classList.add(stickersMode ? "pano-node-preview--stickers" : "pano-node-preview--cutout");
if (noPreview) root.classList.add("pano-node-preview--no-preview");
root.setAttribute("data-capture-wheel", "true");
root.setAttribute("tabindex", "-1");
root.style.cssText = [
"width:100%",
noPreview ? "height:auto" : "height:100%",
"position:relative",
"display:block",
"min-height:0",
"padding:0",
"margin:0",
"overflow:hidden",
"contain:layout paint style",
"background:transparent",
].join(";");
if (noPreview) {
const widget = node.addDOMWidget(getAnimPreviewWidgetName(), "preview", root, createCoreManagedDomWidgetOptions(node, null));
suppressBuiltInPreviewImgs(node);
node.__panoDomPreview = { widget, root, requestDraw: () => { } };
node.__panoPreviewHooked = true;
node.__panoActiveBackend = "dom";
return;
}
const wrap = document.createElement("div");
wrap.style.cssText = [
"position:absolute",
"inset:0",
"border-radius:8px",
"overflow:hidden",
"border:1px solid rgba(63,63,70,1)",
"background:#070707",
].join(";");
const canvas = document.createElement("canvas");
canvas.style.cssText = [
"position:absolute",
"inset:0",
"width:100%",
"height:100%",
"object-position:center",
"display:block",
"touch-action:none",
"cursor:grab",
"user-select:none",
"-webkit-user-select:none",
].join(";");
const cutoutHint = document.createElement("div");
cutoutHint.style.cssText = [
"position:absolute",
"inset:0",
"display:none",
"align-items:center",
"justify-content:center",
"pointer-events:none",
"color:rgba(161,161,170,0.95)",
"font:600 15px 'Plus Jakarta Sans', Geist, sans-serif",
"text-align:center",
"padding:0 10px",
"z-index:2",
].join(";");
cutoutHint.textContent = "Open editor and add frame";
wrap.appendChild(canvas);
wrap.appendChild(cutoutHint);
root.appendChild(wrap);
initNodeSizeIfInvalid(node, 120, 120);
suppressBuiltInPreviewImgs(node);
let widget = null;
try {
widget = node.addDOMWidget(getAnimPreviewWidgetName(), "preview", root, createCoreManagedDomWidgetOptions(node, () => node.__panoDomPreview?.requestDraw?.()));
} catch {
if (stickersMode) {
attachLegacyStickersPreview(node);
}
return;
}
const state = {
raf: 0,
needsDraw: true,
dragging: false,
lastX: 0,
lastY: 0,
lastTs: 0,
};
const requestDraw = () => {
state.needsDraw = true;
if (!state.raf) state.raf = requestAnimationFrame(tick);
};
const resizeObserver = typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
markPreviewResizing(node, 150);
requestDraw();
})
: null;
resizeObserver?.observe(wrap);
const runDomProbeForStickers = () => {
if (!stickersMode || node.__panoDomProbePending) return;
node.__panoDomProbePending = true;
const maxAttempts = 3;
const probeOnce = (attempt) => {
requestAnimationFrame(() => {
const domOk = !!(root?.isConnected && wrap?.isConnected && canvas?.isConnected
&& Number(wrap.clientHeight || 0) > 0 && Number(canvas.clientHeight || 0) > 0);
if (!domOk && attempt < maxAttempts) {
probeOnce(attempt + 1);
return;
}
node.__panoDomProbePending = false;
panoPreviewLog(node, "probe.result", {
mode: node.__panoPreviewMode,
result: domOk ? "dom_ok" : "dom_fail",
wrapH: Number(wrap?.clientHeight || 0),
canvasH: Number(canvas?.clientHeight || 0),
attempt,
});
if (!domOk) {
if (noLegacyFallback) {
panoPreviewLog(node, "path.keep", {
route: "dom",
reason: "probe_dom_not_ready",
mode: node.__panoPreviewMode,
wrapH: Number(wrap?.clientHeight || 0),
canvasH: Number(canvas?.clientHeight || 0),
});
return;
}
panoPreviewLog(node, "path.switch", {
from: "dom",
to: "legacy_draw",
reason: "probe_dom_fail_fallback_legacy",
});
teardownPreview(node, { keepMonitor: true });
node.__panoStickersPath = "legacy_draw_auto";
attachLegacyStickersPreview(node);
node.setDirtyCanvas?.(true, true);
}
});
};
probeOnce(1);
};
const tick = (ts) => {
state.raf = 0;
const moving = interaction.stepInertia(ts);
if (state.needsDraw || moving) {
state.needsDraw = false;
if (!node.flags?.collapsed) drawCanvas(node, canvas, null, interaction);
node.setDirtyCanvas?.(true, false);
}
if (moving || state.needsDraw) state.raf = requestAnimationFrame(tick);
if (panoPreviewDebugEnabled()) {
if (!node.__panoDebugLastTs || (ts - node.__panoDebugLastTs) > 1200) {
node.__panoDebugLastTs = ts;
panoPreviewLog(node, "dom.snapshot", {
mode: node.__panoPreviewMode,
nodeSize: getNodeSize(node),
root: previewElementSnapshot(root),
wrap: previewElementSnapshot(wrap),
canvas: previewElementSnapshot(canvas),
rootParent: previewElementSnapshot(root.parentElement),
rootGrandParent: previewElementSnapshot(root.parentElement?.parentElement),
canvasIntrinsic: { width: Number(canvas.width || 0), height: Number(canvas.height || 0) },
});
}
}
};
const interaction = createPanoInteractionController({
getView: () => {
if (!node.__panoPreviewView) node.__panoPreviewView = { yaw: 0, pitch: 0, fov: 100 };
return node.__panoPreviewView;
},
setView: (v) => {
node.__panoPreviewView = v;
},
onInteraction: () => {
requestDraw();
},
});
canvas.addEventListener("pointerdown", (ev) => {
if (!stopCanvasEventUnlessResizeGrip(ev, wrap)) return;
if (!interactiveView) return;
if (ev.button !== 0) return;
root.focus?.();
canvas.setPointerCapture?.(ev.pointerId);
canvas.style.cursor = "grabbing";
interaction.startDrag(ev.clientX, ev.clientY, ev.pointerId);
});
canvas.addEventListener("pointermove", (ev) => {
if (!interactiveView || !interaction.state.drag.active) return;
stopCanvasEventUnlessResizeGrip(ev, wrap);
interaction.moveDrag(ev.clientX, ev.clientY, "pano");
});
const endDrag = (ev) => {
if (!interactiveView || !interaction.state.drag.active) return;
stopCanvasEventUnlessResizeGrip(ev, wrap);
canvas.releasePointerCapture?.(ev.pointerId);
canvas.style.cursor = "grab";
interaction.endDrag();
markStandaloneInteractionSettled(node);
requestDraw();
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("pointerleave", (ev) => {
if (!interaction.state.drag.active) return;
endDrag(ev);
});
const onPreviewWheel = (ev) => {
panoPreviewLog(node, "event", { kind: "wheel", via: "stickers", interactiveView });
if (!stopCanvasEventUnlessResizeGrip(ev, wrap)) return;
if (!interactiveView) return;
const graphSnapshot = lockGraphViewportSnapshot();
if (interaction.applyWheelEvent(ev)) {
requestDraw();
}
ev.preventDefault?.();
ev.stopPropagation?.();
ev.stopImmediatePropagation?.();
requestAnimationFrame(() => {
restoreGraphViewportSnapshot(graphSnapshot);
app?.canvas?.setDirty?.(true, true);
});
};
["wheel", "mousewheel", "DOMMouseScroll"].forEach((name) => {
wrap.addEventListener(name, onPreviewWheel, { passive: false, capture: true });
canvas.addEventListener(name, onPreviewWheel, { passive: false, capture: true });
});
["contextmenu", "mousedown", "mouseup", "mousemove", "click", "dblclick"].forEach((name) => {
canvas.addEventListener(name, (ev) => stopCanvasEventUnlessResizeGrip(ev, wrap));
wrap.addEventListener(name, (ev) => stopCanvasEventUnlessResizeGrip(ev, wrap));
});
const sw = getWidget(node, "state_json");
if (sw && !sw.__panoPreviewPatchedCb) {
sw.__panoPreviewPatchedCb = true;
const prevCb = sw.callback;
sw.callback = (v) => {
const out = prevCb ? prevCb(v) : undefined;
requestDraw();
return out;
};
}
const bgw = getWidget(node, "bg_color");
if (bgw && !bgw.__panoPreviewPatchedCb) {
bgw.__panoPreviewPatchedCb = true;
const prevCb = bgw.callback;
bgw.callback = (v) => {
const out = prevCb ? prevCb(v) : undefined;
requestDraw();
return out;
};
}
const onRemovedPrev = node.onRemoved;
const onResizePrev = node.onResize;
const onExecutedPrev = node.onExecuted;
node.onExecuted = function (output) {
syncOwnOutputSourceFromExecuted(node, output);
invalidatePreviewImageCaches(node);
suppressBuiltInPreviewImgs(node);
requestDraw();
const out = onExecutedPrev ? onExecutedPrev.apply(this, arguments) : undefined;
suppressBuiltInPreviewImgs(node);
return out;
};
const onConnectionsChangePrev = node.onConnectionsChange;
node.onConnectionsChange = function () {
invalidatePreviewImageCaches(node);
suppressBuiltInPreviewImgs(node);
requestDraw();
return onConnectionsChangePrev ? onConnectionsChangePrev.apply(this, arguments) : undefined;
};
node.onResize = function () {
const out = onResizePrev ? onResizePrev.apply(this, arguments) : undefined;
this.__panoUserResized = true;
markPreviewResizing(this, 150);
requestDraw();
this.setDirtyCanvas?.(true, false);
return out;
};
node.onRemoved = function () {
restoreDom();
previewMonitor.unregister(this);
executedRefreshMonitor.unregister(this);
return onRemovedPrev ? onRemovedPrev.apply(this, arguments) : undefined;
};
const restoreDom = () => {
if (state.raf) cancelAnimationFrame(state.raf);
resizeObserver?.disconnect?.();
if (node.__panoResizeSettleTimer) {
clearTimeout(node.__panoResizeSettleTimer);
node.__panoResizeSettleTimer = null;
}
node.__panoDomPreview = null;
node.__panoDomRestore = null;
node.onResize = onResizePrev;
node.onExecuted = onExecutedPrev;
node.onConnectionsChange = onConnectionsChangePrev;
node.onRemoved = onRemovedPrev;
};
node.__panoDomRestore = restoreDom;
node.__panoPreviewHooked = true;
node.__panoActiveBackend = "dom";
node.__panoDomPreview = { widget, root, canvas, requestDraw, state, emptyHintEl: cutoutHint };
runDomProbeForStickers();
requestDraw();
}
export function attachStickersPreviewNode2(node, options = {}) {
const controller = getPreviewController(node);
controller.attach({
...options,
mode: "stickers",
});
}
export function attachCutoutPreview(node, options = {}) {
const controller = getPreviewController(node);
controller.attach({
...options,
mode: "cutout",
});
}
function standaloneDomIsMounted(domPreview) {
const root = domPreview?.root;
const wrap = root?.querySelector?.("div");
const canvas = domPreview?.canvas;
if (!root || !canvas) return false;
if (!root.isConnected || !canvas.isConnected) return false;
const cw = Number(canvas.clientWidth || 0);
const ch = Number(canvas.clientHeight || 0);
if (cw > 0 && ch > 0) return true;
const rw = Number(root.clientWidth || 0);
const rh = Number(root.clientHeight || 0);
if (rw > 0 && rh > 0) return true;
if (wrap) {
const ww = Number(wrap.clientWidth || 0);
const wh = Number(wrap.clientHeight || 0);
if (ww > 0 && wh > 0) return true;
}
return false;
}
function tryAttachDomWithProbe(node, options = {}, mountKey = null) {
const allowLegacyFallback = !isStandalonePreviewNode(node) || isLegacyFrontendRuntime();
let domOk = false;
try {
domOk = attachStandalonePreviewDom(node, {
...options,
__noAutoFallback: true,
__skipRouteLog: true,
}) === true;
} catch {
domOk = false;
}
if (!domOk || !node.__panoDomPreview?.widget) {
if (!allowLegacyFallback) {
setStandaloneUnavailableLabel(node, "Preview mount failed");
panoPreviewLog(node, "route.attach", {
route: "unified_dom_error",
reason: "dom_attach_exception",
nodeType: getNodeTypeName(node),
});
return true;
}
panoPreviewLog(node, "route.attach", {
route: "unified_legacy_fallback",
reason: "dom_attach_exception",
nodeType: getNodeTypeName(node),
});
return false;
}
cancelDomMountProbe(node);
const probe = { cancelled: false, frame: 0, timeoutId: 0 };
node.__panoDomMountProbe = probe;
const fallback = (reason) => {
if (probe.cancelled) return;
probe.cancelled = true;
if (probe.timeoutId) {
clearTimeout(probe.timeoutId);
probe.timeoutId = 0;
}
node.__panoDomMountProbe = null;
if (allowLegacyFallback) {
teardownPreview(node, { keepMonitor: false, reason });
clearStandaloneUnavailableLabel(node);
attachStandalonePreviewLegacy(node, options);
if (mountKey) {
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
}
panoPreviewLog(node, "route.attach", {
route: "unified_legacy_fallback",
reason,
nodeType: getNodeTypeName(node),
});
return;
}
setStandaloneUnavailableLabel(node, "Preview mount failed");
node.__panoDomPreview?.requestDraw?.();
node.setDirtyCanvas?.(true, true);
if (mountKey) {
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
}
panoPreviewLog(node, "route.attach", {
route: "unified_dom_error",
reason,
nodeType: getNodeTypeName(node),
});
};
const confirmDom = () => {
if (probe.cancelled) return;
const domPreview = node.__panoDomPreview;
const rootW = Number(domPreview?.root?.clientWidth || 0);
const rootH = Number(domPreview?.root?.clientHeight || 0);
const wrapEl = domPreview?.root?.querySelector?.("div");
const wrapW = Number(wrapEl?.clientWidth || 0);
const wrapH = Number(wrapEl?.clientHeight || 0);
const canvasW = Number(domPreview?.canvas?.clientWidth || 0);
const canvasH = Number(domPreview?.canvas?.clientHeight || 0);
panoPreviewLog(node, "probe.frame", {
frame: probe.frame,
hasWidget: !!domPreview?.widget,
connected: !!(domPreview?.root?.isConnected && domPreview?.canvas?.isConnected),
rootW,
rootH,
wrapW,
wrapH,
canvasW,
canvasH,
});
if (!domPreview?.widget || !domPreview.root?.isConnected || !domPreview.canvas?.isConnected) {
fallback("dom_mount_detached");
return;
}
if (standaloneDomIsMounted(domPreview)) {
probe.cancelled = true;
if (probe.timeoutId) {
clearTimeout(probe.timeoutId);
probe.timeoutId = 0;
}
node.__panoDomMountProbe = null;
clearStandaloneUnavailableLabel(node);
panoPreviewLog(node, "route.attach", {
route: "unified_dom",
nodeType: getNodeTypeName(node),
});
return;
}
probe.frame += 1;
if (probe.frame >= STANDALONE_DOM_PROBE_FRAMES) {
fallback("dom_probe_size_zero");
return;
}
requestAnimationFrame(confirmDom);
};
probe.timeoutId = setTimeout(() => {
fallback("dom_probe_timeout");
}, STANDALONE_DOM_PROBE_TIMEOUT_MS);
requestAnimationFrame(confirmDom);
return true;
}
export function attachStandalonePreviewUnified(node, options = {}) {
ensureStandalonePreviewBootstrapSize(node);
const mountKey = `standalone_unified|${String(options.imageInputName || "erp_image")}|${String(options.buttonText || "Open Preview")}`;
if (node.__panoPreviewAttached === true && node.__panoPreviewMountKey === mountKey) return;
panoPreviewLog(node, "attach.unified.begin", {
nodeType: getNodeTypeName(node),
nodeId: node?.id ?? null,
mountKey,
size: getNodeSize(node),
sizeW: Number(getNodeSize(node)?.[0] || 0),
sizeH: Number(getNodeSize(node)?.[1] || 0),
hasDomWidget: typeof node?.addDOMWidget === "function",
});
teardownPreview(node, { keepMonitor: false, reason: "attach_standalone_unified_begin" });
clearStandaloneUnavailableLabel(node);
if (typeof node?.addDOMWidget === "function") {
const domPending = tryAttachDomWithProbe(node, options, mountKey);
if (domPending) {
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
return;
}
}
if (isStandalonePreviewNode(node) && !isLegacyFrontendRuntime()) {
setStandaloneUnavailableLabel(node, "Preview mount failed");
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
panoPreviewLog(node, "route.attach", {
route: "unified_dom_error",
reason: "dom_unavailable",
nodeType: getNodeTypeName(node),
});
return;
}
attachStandalonePreviewLegacy(node, options);
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
panoPreviewLog(node, "route.attach", {
route: "unified_legacy_fallback",
reason: "dom_unavailable",
nodeType: getNodeTypeName(node),
});
}
export function attachStandalonePreviewDom(node, options = {}) {
const mountKey = `standalone_dom|${String(options.imageInputName || "erp_image")}|${String(options.buttonText || "Open Preview")}`;
if (node.__panoPreviewAttached === true && node.__panoPreviewMountKey === mountKey && node.__panoDomPreview?.widget) return true;
teardownPreview(node, { keepMonitor: false, reason: "attach_standalone_dom_begin" });
clearStandaloneUnavailableLabel(node);
node.__panoPreviewMode = "standalone_dom";
node.__panoPreviewButtonText = String(options.buttonText || "Open Preview");
node.__panoOpenEditor = typeof options.onOpen === "function" ? options.onOpen : null;
executedRefreshMonitor.register(node);
if (options.__skipRouteLog !== true) {
panoPreviewLog(node, "route.attach", { route: "standalone_dom", nodeType: getNodeTypeName(node) });
}
const imageInputName = String(options.imageInputName || "erp_image");
ensurePreviewModeCss();
try {
if (typeof node.addDOMWidget !== "function") throw new Error("addDOMWidget unavailable");
if (node.__panoDomPreview?.widget) return true;
const root = document.createElement("div");
root.className = "pano-node-preview-dom pano-node-preview--stickers";
root.style.cssText = "width:100%;height:100%;position:relative;display:block;min-height:56px;overflow:hidden;";
root.setAttribute("data-capture-wheel", "true");
root.setAttribute("tabindex", "-1");
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = node.__panoPreviewButtonText;
btn.style.cssText = "height:34px;position:absolute;top:8px;left:8px;z-index:4;border:1px solid rgba(98,98,105,0.9);background:rgba(32,32,35,0.96);color:rgba(244,244,246,0.95);border-radius:8px;font:500 12px 'Plus Jakarta Sans', Geist, sans-serif;cursor:pointer;";
const wrap = document.createElement("div");
wrap.style.cssText = "position:absolute;inset:0;min-height:56px;border-radius:8px;overflow:hidden;border:1px solid rgba(63,63,70,1);background:#070707;";
const canvas = document.createElement("canvas");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;object-fit:cover;touch-action:none;cursor:grab;";
wrap.appendChild(canvas);
root.appendChild(wrap);
root.appendChild(btn);
btn.addEventListener("click", (ev) => {
stopCanvasEvent(ev);
openStandaloneModal(node, String(options.modalTitle || "TY360 Preview"), imageInputName);
});
node.__panoBootMinHeightActive = true;
if (node.__panoBootMinHeightTimer) {
clearTimeout(node.__panoBootMinHeightTimer);
node.__panoBootMinHeightTimer = null;
}
const widget = node.addDOMWidget(
getAnimPreviewWidgetName(),
"preview",
root,
createCoreManagedDomWidgetOptions(node, () => node.__panoDomPreview?.requestDraw?.(), 56),
);
if (widget) widget.serialize = false;
node.__panoBootMinHeightTimer = setTimeout(() => {
node.__panoBootMinHeightTimer = null;
if (!node?.__panoDomPreview?.widget) return;
if (!node.__panoBootMinHeightActive) return;
node.__panoBootMinHeightActive = false;
node.__panoDomPreview?.requestDraw?.();
node.setDirtyCanvas?.(true, true);
}, 1200);
suppressBuiltInPreviewImgs(node);
node.__panoBindSeq = Number(node.__panoBindSeq || 0) + 1;
const bindingSeq = node.__panoBindSeq;
panoPreviewLog(node, "bind", { nodeId: node?.id ?? null, bindingSeq, route: "standalone_dom" });
panoPreviewLog(node, "modal-parity", PANO_MODAL_PARITY_CONSTANTS);
const state = { raf: 0, needsDraw: true, dragging: false, pointerId: null };
const disposeCounts = { listeners: 0, raf: 0, widget: 0 };
const controller = createPanoInteractionController({
getView: () => ensureStandaloneView(node),
setView: (next) => {
const view = ensureStandaloneView(node);
view.yaw = wrapYaw(Number(next.yaw || 0));
view.pitch = clamp(Number(next.pitch || 0), -89.9, 89.9);
view.fov = clamp(Number(next.fov || 100), STANDALONE_FOV_MIN, STANDALONE_FOV_MAX);
},
getInvert: () => {
const ui = loadSharedUiSettings();
return {
x: ui.invert_view_x ? -1 : 1,
y: ui.invert_view_y ? -1 : 1,
};
},
onInteraction: () => {
markStandaloneInteractionSettled(node);
},
onDebug: (tag, payload) => {
panoPreviewLog(node, tag, payload);
},
});
node.__panoStandaloneController = controller;
let detachWindowPointerBridge = null;
const stopInputBubble = (ev) => {
ev.preventDefault?.();
ev.stopPropagation?.();
ev.stopImmediatePropagation?.();
};
const bindWindowPointerBridge = () => {
if (detachWindowPointerBridge) detachWindowPointerBridge();
const onWinMove = (ev) => {
if (!state.dragging) return;
panoPreviewLog(node, "event", { kind: "pointermove", via: "window" });
if (!isLeftButtonPressed(ev)) {
endDrag(ev, ev?.type || "pointermove_buttons_released");
return;
}
controller.moveDrag(ev.clientX, ev.clientY, "pano", performance.now());
requestDraw();
stopInputBubble(ev);
};
const onWinUp = (ev) => {
if (!state.dragging) return;
endDrag(ev, ev?.type || "pointerup");
stopInputBubble(ev);
};
const onWinCancel = (ev) => {
if (!state.dragging) return;
endDrag(ev, ev?.type || "pointercancel");
stopInputBubble(ev);
};
window.addEventListener("pointermove", onWinMove, true);
window.addEventListener("pointerup", onWinUp, true);
window.addEventListener("pointercancel", onWinCancel, true);
disposeCounts.listeners += 3;
detachWindowPointerBridge = () => {
window.removeEventListener("pointermove", onWinMove, true);
window.removeEventListener("pointerup", onWinUp, true);
window.removeEventListener("pointercancel", onWinCancel, true);
disposeCounts.listeners = Math.max(0, disposeCounts.listeners - 3);
detachWindowPointerBridge = null;
};
};
const requestDraw = () => {
state.needsDraw = true;
if (!state.raf) state.raf = requestAnimationFrame(tick);
};
const resizeObserver = typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => {
markPreviewResizing(node, 150);
requestDraw();
})
: null;
resizeObserver?.observe(wrap);
let tickCount = 0;
const tick = (ts = performance.now()) => {
state.raf = 0;
tickCount++;
if (tickCount <= 20) inspectNodeLayout(node, `tick.${tickCount}`);
const movingInertia = controller.stepInertia(ts);
const resizingNow = isPreviewResizing(node, ts);
const moving = movingInertia || resizingNow;
recordStandaloneFrame(node, ts);
if (!state.needsDraw && !moving && tickCount > 20) return;
state.needsDraw = false;
const dpr = 1;
const clientW = Number(canvas.clientWidth || 0);
const clientH = Number(canvas.clientHeight || 0);
if (node.__panoBootMinHeightActive && clientW > 0 && clientH > 0) {
node.__panoBootMinHeightActive = false;
}
const w = Math.max(64, Math.round(clientW * dpr));
const h = Math.max(64, Math.round(clientH * dpr));
if (tickCount <= 20 || w !== canvas.width || h !== canvas.height) {
inspectNodeLayout(node, "tick.resize", { w, h, cw: canvas.clientWidth, ch: canvas.clientHeight });
}
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = { x: 0, y: 0, w, h };
const mesh = STANDALONE_MESH_LOW;
drawStandalonePanorama(node, ctx, rect, imageInputName, mesh);
node.setDirtyCanvas?.(true, false);
if (state.needsDraw || moving) state.raf = requestAnimationFrame(tick);
};
const onDown = (ev) => {
if (!stopCanvasEventUnlessResizeGrip(ev, wrap)) return;
if (ev.button !== 0) return;
panoPreviewLog(node, "event", { kind: "pointerdown", via: "viewer" });
root.focus?.();
canvas.setPointerCapture?.(ev.pointerId);
state.pointerId = ev.pointerId;
state.dragging = true;
canvas.style.cursor = "grabbing";
controller.startDrag(ev.clientX, ev.clientY, ev.pointerId, performance.now());
panoPreviewLog(node, "drag", { phase: "start", hasCapture: canvas.hasPointerCapture?.(ev.pointerId) === true });
bindWindowPointerBridge();
requestDraw();
};
const onMove = (ev) => {
if (!state.dragging) return;
panoPreviewLog(node, "event", { kind: "pointermove", via: "viewer" });
stopCanvasEventUnlessResizeGrip(ev, wrap);
if (!isLeftButtonPressed(ev)) {
endDrag(ev);
return;
}
controller.moveDrag(ev.clientX, ev.clientY, "pano", performance.now());
requestDraw();
};
const endDrag = (ev, reason = "pointerup") => {
if (!state.dragging) return;
stopCanvasEventUnlessResizeGrip(ev, wrap);
state.dragging = false;
state.pointerId = null;
canvas.style.cursor = "grab";
controller.endDrag(performance.now());
panoPreviewLog(node, "event", { kind: reason, via: "viewer" });
panoPreviewLog(node, "drag", { phase: "end", reason, hasCapture: false });
detachWindowPointerBridge?.();
requestDraw();
};
const onUp = (ev) => endDrag(ev, ev?.type || "pointerup");
const onLostCapture = () => {
if (!state.dragging) return;
state.dragging = false;
state.pointerId = null;
canvas.style.cursor = "grab";
controller.endDrag(performance.now());
panoPreviewLog(node, "event", { kind: "lostpointercapture", via: "viewer" });
panoPreviewLog(node, "drag", { phase: "end", reason: "lostpointercapture", hasCapture: false });
detachWindowPointerBridge?.();
requestDraw();
};
const onWheel = (ev) => {
if (!stopCanvasEventUnlessResizeGrip(ev, wrap)) return;
panoPreviewLog(node, "event", { kind: "wheel", via: "viewer" });
const graphSnapshot = lockGraphViewportSnapshot();
const activeElement = document.activeElement;
const before = Number(ensureStandaloneView(node).fov || 100);
const changed = controller.applyWheelEvent(ev);
const after = Number(ensureStandaloneView(node).fov || 100);
panoPreviewLog(node, "wheel", {
focused: !!activeElement,
activeElementIsRoot: activeElement === root,
deltaY: Number(ev.deltaY || 0),
fovBefore: before,
fovAfter: after,
});
if (changed) {
requestDraw();
}
ev.preventDefault?.();
ev.stopPropagation?.();
ev.stopImmediatePropagation?.();
requestAnimationFrame(() => {
restoreGraphViewportSnapshot(graphSnapshot);
app?.canvas?.setDirty?.(true, true);
});
};
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointercancel", onUp);
canvas.addEventListener("lostpointercapture", onLostCapture);
canvas.addEventListener("wheel", onWheel, { passive: false, capture: true });
wrap.addEventListener("wheel", onWheel, { passive: false, capture: true });
disposeCounts.listeners += 7;
const blockedEvents = ["contextmenu", "click", "dblclick"];
blockedEvents.forEach((name) => {
wrap.addEventListener(name, stopInputBubble, { capture: true });
canvas.addEventListener(name, stopInputBubble, { capture: true });
disposeCounts.listeners += 2;
});
const onRemovedPrev = node.onRemoved;
const onResizePrev = node.onResize;
const onExecutedPrev = node.onExecuted;
const onConnectionsChangePrev = node.onConnectionsChange;
node.onExecuted = function (output) {
inspectNodeLayout(node, "onExecuted");
syncOwnOutputSourceFromExecuted(node, output);
invalidatePreviewImageCaches(node);
suppressBuiltInPreviewImgs(node);
requestDraw();
const out = onExecutedPrev ? onExecutedPrev.apply(this, arguments) : undefined;
suppressBuiltInPreviewImgs(node);
return out;
};
node.onConnectionsChange = function () {
inspectNodeLayout(node, "onConnectionsChange");
invalidatePreviewImageCaches(node);
suppressBuiltInPreviewImgs(node);
requestDraw();
return onConnectionsChangePrev ? onConnectionsChangePrev.apply(this, arguments) : undefined;
};
node.onResize = function () {
const out = onResizePrev ? onResizePrev.apply(this, arguments) : undefined;
this.__panoUserResized = true;
markPreviewResizing(this, 150);
requestDraw();
this.setDirtyCanvas?.(true, false);
return out;
};
node.onRemoved = function () {
node.__panoStandaloneModal?.dispose?.();
node.__panoStandaloneModal = null;
cancelDomMountProbe(node);
if (state.raf) {
cancelAnimationFrame(state.raf);
disposeCounts.raf += 1;
}
detachWindowPointerBridge?.();
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
canvas.removeEventListener("lostpointercapture", onLostCapture);
canvas.removeEventListener("wheel", onWheel, true);
wrap.removeEventListener("wheel", onWheel, true);
disposeCounts.listeners = Math.max(0, disposeCounts.listeners - 7);
blockedEvents.forEach((name) => {
wrap.removeEventListener(name, stopInputBubble, true);
canvas.removeEventListener(name, stopInputBubble, true);
disposeCounts.listeners = Math.max(0, disposeCounts.listeners - 2);
});
if (removeNodeWidget(node, widget)) disposeCounts.widget += 1;
resizeObserver?.disconnect?.();
if (node.__panoResizeSettleTimer) {
clearTimeout(node.__panoResizeSettleTimer);
node.__panoResizeSettleTimer = null;
}
if (node.__panoBootMinHeightTimer) {
clearTimeout(node.__panoBootMinHeightTimer);
node.__panoBootMinHeightTimer = null;
}
node.__panoBootMinHeightActive = false;
node.__panoDomPreview = null;
node.__panoDomRestore = null;
node.__panoStandaloneController = null;
node.__panoPreviewAttached = false;
node.__panoPreviewMountKey = null;
node.onResize = onResizePrev;
node.onExecuted = onExecutedPrev;
node.onConnectionsChange = onConnectionsChangePrev;
executedRefreshMonitor.unregister(node);
panoPreviewLog(node, "teardown", { route: "standalone_dom", bindingSeq, disposeCounts: { ...disposeCounts } });
node.onRemoved = onRemovedPrev;
return onRemovedPrev ? onRemovedPrev.apply(this, arguments) : undefined;
};
node.__panoDomRestore = () => {
node.__panoStandaloneModal?.dispose?.();
node.__panoStandaloneModal = null;
cancelDomMountProbe(node);
if (state.raf) {
cancelAnimationFrame(state.raf);
disposeCounts.raf += 1;
}
detachWindowPointerBridge?.();
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointercancel", onUp);
canvas.removeEventListener("lostpointercapture", onLostCapture);
canvas.removeEventListener("wheel", onWheel, true);
wrap.removeEventListener("wheel", onWheel, true);
disposeCounts.listeners = Math.max(0, disposeCounts.listeners - 7);
blockedEvents.forEach((name) => {
wrap.removeEventListener(name, stopInputBubble, true);
canvas.removeEventListener(name, stopInputBubble, true);
disposeCounts.listeners = Math.max(0, disposeCounts.listeners - 2);
});
if (removeNodeWidget(node, widget)) disposeCounts.widget += 1;
if (node.__panoBootMinHeightTimer) {
clearTimeout(node.__panoBootMinHeightTimer);
node.__panoBootMinHeightTimer = null;
}
node.__panoBootMinHeightActive = false;
node.__panoDomPreview = null;
node.__panoDomRestore = null;
node.__panoStandaloneController = null;
node.__panoPreviewAttached = false;
node.__panoPreviewMountKey = null;
node.onResize = onResizePrev;
node.onExecuted = onExecutedPrev;
node.onConnectionsChange = onConnectionsChangePrev;
node.onRemoved = onRemovedPrev;
panoPreviewLog(node, "teardown", { route: "standalone_dom", bindingSeq, disposeCounts: { ...disposeCounts } });
clearStandaloneUnavailableLabel(node);
};
node.__panoPreviewHooked = true;
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
node.__panoDomPreview = { widget, root, canvas, requestDraw, state };
requestDraw();
return true;
} catch {
if (options.__noAutoFallback === true) {
setStandaloneUnavailableLabel(node, "Node 2.0 preview unavailable");
return false;
}
clearStandaloneUnavailableLabel(node);
return false;
}
}
export function attachStandalonePreviewAuto(node, options = {}) {
attachStandalonePreviewUnified(node, options);
}
export function attachStandalonePreviewLegacy(node, options = {}) {
const mountKey = `standalone_legacy|${String(options.imageInputName || "erp_image")}|${String(options.buttonText || "Open Preview")}`;
if (node.__panoPreviewAttached === true && node.__panoPreviewMountKey === mountKey && node.__panoLegacyPreviewHooked) return;
teardownPreview(node, { keepMonitor: false, reason: "attach_standalone_legacy_begin" });
clearStandaloneUnavailableLabel(node);
node.__panoPreviewMode = "standalone_legacy";
node.__panoPreviewButtonText = String(options.buttonText || "Open Preview");
node.__panoOpenEditor = typeof options.onOpen === "function" ? options.onOpen : null;
executedRefreshMonitor.register(node);
panoPreviewLog(node, "route.attach", { route: "standalone_legacy", nodeType: getNodeTypeName(node) });
const imageInputName = String(options.imageInputName || "erp_image");
if (node.__panoLegacyPreviewHooked) return;
node.__panoLegacyPreviewHooked = true;
suppressBuiltInPreviewImgs(node);
const prevDrawForeground = node.onDrawForeground;
const prevMouseDown = node.onMouseDown;
const prevMouseMove = node.onMouseMove;
const prevMouseUp = node.onMouseUp;
const prevMouseWheel = node.onMouseWheel;
const prevResize = node.onResize;
const prevRemoved = node.onRemoved;
const prevExecuted = node.onExecuted;
const prevConnectionsChange = node.onConnectionsChange;
let detachWindowDragListeners = null;
const updateStandaloneLegacyDragFromEvent = (targetNode, e, localPos, canvasRef) => {
const d = targetNode.__panoPreviewDrag;
if (!d?.active) return false;
if (!isLeftButtonPressed(e)) {
endStandaloneDrag(targetNode, performance.now());
targetNode.setDirtyCanvas?.(true, false);
detachWindowDragListeners?.();
return true;
}
const p = resolveStandaloneLocalPos(targetNode, e, localPos, canvasRef || app?.canvas, { x: d.lastX, y: d.lastY });
if (!p) return true;
updateStandaloneDrag(targetNode, p.x, p.y, performance.now());
targetNode.setDirtyCanvas?.(true, false);
return true;
};
const endStandaloneLegacyDrag = (targetNode) => {
const ended = endStandaloneDrag(targetNode, performance.now());
if (!ended) return false;
targetNode.setDirtyCanvas?.(true, false);
detachWindowDragListeners?.();
return true;
};
const bindWindowDragListeners = (targetNode) => {
if (detachWindowDragListeners) detachWindowDragListeners();
const onWinMove = (ev) => {
if (!targetNode.__panoPreviewDrag?.active) return;
updateStandaloneLegacyDragFromEvent(targetNode, ev, null, app?.canvas);
ev.preventDefault?.();
};
const onWinUp = () => {
endStandaloneLegacyDrag(targetNode);
};
const onWinBlur = () => {
endStandaloneLegacyDrag(targetNode);
};
window.addEventListener("mousemove", onWinMove, true);
window.addEventListener("mouseup", onWinUp, true);
window.addEventListener("blur", onWinBlur, true);
detachWindowDragListeners = () => {
window.removeEventListener("mousemove", onWinMove, true);
window.removeEventListener("mouseup", onWinUp, true);
window.removeEventListener("blur", onWinBlur, true);
detachWindowDragListeners = null;
};
};
node.onDrawForeground = function (ctx) {
const r = prevDrawForeground ? prevDrawForeground.apply(this, arguments) : undefined;
if (!ctx || this.flags?.collapsed) return r;
const rect = getStandaloneLegacyPreviewRect(this);
const ts = performance.now();
const movingBase = stepStandaloneInertia(this, ts);
const resizingNow = isPreviewResizing(this, ts);
const moving = movingBase || resizingNow;
recordStandaloneFrame(this, ts);
drawNodeEditorButton(this, ctx);
if (rect) {
ctx.save();
ctx.beginPath();
ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 8);
ctx.clip();
const mesh = STANDALONE_MESH_LOW;
drawStandalonePanorama(this, ctx, rect, imageInputName, mesh);
ctx.restore();
}
if (moving) this.setDirtyCanvas?.(true, false);
return r;
};
node.onResize = function () {
const r = prevResize ? prevResize.apply(this, arguments) : undefined;
this.__panoUserResized = true;
markPreviewResizing(this, 150);
this.setDirtyCanvas?.(true, true);
scheduleResizeSettleDraw(this, 220, null);
return r;
};
node.onMouseDown = function (e, localPos, canvas) {
const p = resolveStandaloneLocalPos(this, e, localPos, canvas || app?.canvas, this.__panoLastLocalPos);
if (p) this.__panoLastLocalPos = { x: p.x, y: p.y };
const btn = getNodeEditorButtonRect(this);
const rect = getStandaloneLegacyPreviewRect(this);
if (p && pointInRect(p.x, p.y, btn)) {
if (e?.button === 0) openStandaloneModal(this, String(options.modalTitle || "TY360 Preview"), imageInputName);
return true;
}
if (p && rect && pointInRect(p.x, p.y, rect) && e?.button === 0) {
const now = performance.now();
beginStandaloneDrag(this, p.x, p.y, now);
bindWindowDragListeners(this);
return true;
}
return prevMouseDown ? prevMouseDown.apply(this, arguments) : undefined;
};
node.onMouseMove = function (e, localPos, canvas) {
const currentPos = resolveStandaloneLocalPos(this, e, localPos, canvas || app?.canvas, this.__panoLastLocalPos);
if (currentPos) this.__panoLastLocalPos = { x: currentPos.x, y: currentPos.y };
const d = this.__panoPreviewDrag;
if (!d?.active) return prevMouseMove ? prevMouseMove.apply(this, arguments) : undefined;
if (!isLeftButtonPressed(e)) {
endStandaloneLegacyDrag(this);
return true;
}
updateStandaloneLegacyDragFromEvent(this, e, currentPos, canvas || app?.canvas);
return true;
};
node.onMouseUp = function () {
if (endStandaloneLegacyDrag(this)) return true;
return prevMouseUp ? prevMouseUp.apply(this, arguments) : undefined;
};
node.onMouseWheel = function (e, arg2, canvas) {
const p = resolveStandaloneLocalPos(this, e, arg2, canvas || app?.canvas, this.__panoLastLocalPos);
if (p) this.__panoLastLocalPos = { x: p.x, y: p.y };
const rect = getNodePreviewRect(this);
if (p && rect && pointInRect(p.x, p.y, rect)) {
const v = ensureStandaloneView(this);
const graphSnapshot = lockGraphViewportSnapshot();
const delta = Math.sign(readWheelDelta(e));
if (delta !== 0) {
v.fov = clamp(Number(v.fov || 100) + delta * STANDALONE_WHEEL_STEP, STANDALONE_FOV_MIN, STANDALONE_FOV_MAX);
markStandaloneInteractionSettled(this);
this.setDirtyCanvas?.(true, false);
}
requestAnimationFrame(() => {
restoreGraphViewportSnapshot(graphSnapshot);
app?.canvas?.setDirty?.(true, true);
});
e?.preventDefault?.();
e?.stopPropagation?.();
e?.stopImmediatePropagation?.();
return true;
}
return prevMouseWheel ? prevMouseWheel.apply(this, arguments) : undefined;
};
node.onExecuted = function (output) {
syncOwnOutputSourceFromExecuted(this, output);
invalidatePreviewImageCaches(this);
suppressBuiltInPreviewImgs(this);
this.setDirtyCanvas?.(true, false);
const out = prevExecuted ? prevExecuted.apply(this, arguments) : undefined;
suppressBuiltInPreviewImgs(this);
return out;
};
node.onConnectionsChange = function () {
invalidatePreviewImageCaches(this);
suppressBuiltInPreviewImgs(this);
this.setDirtyCanvas?.(true, false);
return prevConnectionsChange ? prevConnectionsChange.apply(this, arguments) : undefined;
};
const restoreLegacy = () => {
detachWindowDragListeners?.();
if (node.__panoResizeSettleTimer) {
clearTimeout(node.__panoResizeSettleTimer);
node.__panoResizeSettleTimer = null;
}
node.__panoStandaloneModal?.dispose?.();
node.__panoStandaloneModal = null;
node.onDrawForeground = prevDrawForeground;
node.onMouseDown = prevMouseDown;
node.onMouseMove = prevMouseMove;
node.onMouseUp = prevMouseUp;
node.onMouseWheel = prevMouseWheel;
node.onResize = prevResize;
node.onExecuted = prevExecuted;
node.onConnectionsChange = prevConnectionsChange;
node.onRemoved = prevRemoved;
node.__panoLegacyPreviewHooked = false;
node.__panoPreviewHooked = false;
node.__panoPreviewDrag = null;
node.__panoLastLocalPos = null;
node.__panoPreviewAttached = false;
node.__panoPreviewMountKey = null;
};
node.__panoLegacyRestore = restoreLegacy;
node.onRemoved = function () {
restoreLegacy();
executedRefreshMonitor.unregister(node);
return prevRemoved ? prevRemoved.apply(this, arguments) : undefined;
};
node.__panoPreviewAttached = true;
node.__panoPreviewMountKey = mountKey;
}
export function attachPanoramaPreview(node, options = {}) {
const allowStandalone = options.__allowStandalone === true;
if (isStandalonePreviewNode(node) && !allowStandalone) {
panoPreviewLog(node, "attach.skip", { reason: "standalone_preview_node", route: "blocked_controller_attach", nodeType: getNodeTypeName(node) });
return;
}
const controller = getPreviewController(node);
controller.attach(options);
}