From a04108def5db540bd529ea5fd5a4e953b6b00a52 Mon Sep 17 00:00:00 2001
From: supart <617811605@qq.com>
Date: Sun, 8 Mar 2026 14:18:36 +0800
Subject: [PATCH] Initial TY360 Photo Edit release
---
.gitattributes | 10 +
.gitignore | 37 +
LICENSE | 21 +
README.md | 38 +
__init__.py | 9 +
comfyui_ty360_photo_edit/__init__.py | 1 +
comfyui_ty360_photo_edit/core/__init__.py | 1 +
comfyui_ty360_photo_edit/core/cutout.py | 48 +
comfyui_ty360_photo_edit/core/math.py | 245 +
comfyui_ty360_photo_edit/core/state.py | 231 +
comfyui_ty360_photo_edit/core/stickers.py | 281 +
comfyui_ty360_photo_edit/nodes.py | 318 ++
example_workflows/ty360-roundtrip-demo.json | 2455 +++++++++
pyproject.toml | 13 +
tests/test_math.py | 288 +
tests/test_nodes_preview.py | 131 +
tests/test_sample_erp.py | 85 +
tests/test_state.py | 87 +
web/pano_cutout_projection.js | 223 +
web/pano_editor.css | 991 ++++
web/pano_editor.js | 4778 +++++++++++++++++
web/pano_editor_core.js | 5327 +++++++++++++++++++
web/pano_gl_renderer.js | 808 +++
web/pano_gl_scene.js | 117 +
web/pano_gl_viewport.js | 122 +
web/pano_interaction_controller.js | 180 +
web/pano_math.js | 20 +
web/pano_node_preview.js | 50 +
web/pano_preview_hooks.js | 40 +
web/pano_preview_identity.js | 9 +
web/pano_preview_previewnode.js | 617 +++
web/pano_preview_render.js | 93 +
web/pano_preview_runtime.js | 3920 ++++++++++++++
33 files changed, 21594 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .gitignore
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 __init__.py
create mode 100644 comfyui_ty360_photo_edit/__init__.py
create mode 100644 comfyui_ty360_photo_edit/core/__init__.py
create mode 100644 comfyui_ty360_photo_edit/core/cutout.py
create mode 100644 comfyui_ty360_photo_edit/core/math.py
create mode 100644 comfyui_ty360_photo_edit/core/state.py
create mode 100644 comfyui_ty360_photo_edit/core/stickers.py
create mode 100644 comfyui_ty360_photo_edit/nodes.py
create mode 100644 example_workflows/ty360-roundtrip-demo.json
create mode 100644 pyproject.toml
create mode 100644 tests/test_math.py
create mode 100644 tests/test_nodes_preview.py
create mode 100644 tests/test_sample_erp.py
create mode 100644 tests/test_state.py
create mode 100644 web/pano_cutout_projection.js
create mode 100644 web/pano_editor.css
create mode 100644 web/pano_editor.js
create mode 100644 web/pano_editor_core.js
create mode 100644 web/pano_gl_renderer.js
create mode 100644 web/pano_gl_scene.js
create mode 100644 web/pano_gl_viewport.js
create mode 100644 web/pano_interaction_controller.js
create mode 100644 web/pano_math.js
create mode 100644 web/pano_node_preview.js
create mode 100644 web/pano_preview_hooks.js
create mode 100644 web/pano_preview_identity.js
create mode 100644 web/pano_preview_previewnode.js
create mode 100644 web/pano_preview_render.js
create mode 100644 web/pano_preview_runtime.js
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..129743d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+* text=auto
+
+*.py text eol=lf
+*.js text eol=lf
+*.css text eol=lf
+*.md text eol=lf
+*.toml text eol=lf
+*.json text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1fdd940
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+# Python bytecode and cache
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Test, type-check, and coverage caches
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+.coverage.*
+htmlcov/
+
+# Virtual environments
+.venv/
+.venv-demo/
+venv/
+env/
+
+# Editor and OS noise
+.vscode/
+.idea/
+.DS_Store
+Thumbs.db
+
+# Logs and temporary files
+*.log
+*.tmp
+*.swp
+*.swo
+
+# Project-specific backups and local overrides
+web/*.bak
+*.local
+demo/config_local.py
+models/
+.git.backup-*/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f1803aa
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 nomadoor
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cb100ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# ComfyUI TY360 Photo Edit
+
+TY360 Photo Edit is a focused ComfyUI node package for previewing, exporting, editing, and pasting back 360 equirectangular photos.
+
+## Installation
+
+- Copy `ComfyUI-TY360-Photo-Edit` into your ComfyUI `custom_nodes` directory.
+- Restart ComfyUI.
+
+## Nodes
+
+- `TY360 Preview`: interactive ERP preview inside ComfyUI.
+- `TY360 Roundtrip`: export a framed rectilinear cutout, roundtrip metadata, and the source ERP image.
+- `TY360 Pasteback`: paste an edited cutout back into the original ERP image at the saved camera pose.
+
+## Workflow
+
+```text
+ERP -> TY360 Roundtrip -> erp_image ----\
+ rect_image -----> edit chain -> TY360 Pasteback -> erp_image
+ roundtrip_state ----/
+```
+
+## Notes
+
+- `TY360 Pasteback` preserves the ERP output size to match the ERP input size.
+- `roundtrip_state` stores the camera pose and source ERP dimensions for exact pasteback.
+- Edited cutouts should keep the exported aspect ratio so pasteback coverage stays exact.
+
+## Example Workflow
+
+- [`example_workflows/ty360-roundtrip-demo.json`](./example_workflows/ty360-roundtrip-demo.json)
+
+The demo workflow ships with a placeholder image name in the `LoadImage` node. Replace it with your own 360 ERP photo after loading the workflow.
+
+## License
+
+MIT
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..1894fb6
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,9 @@
+try:
+ from .comfyui_ty360_photo_edit.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+except ImportError:
+ # Allow direct module loading in test runners without package context.
+ from comfyui_ty360_photo_edit.nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+
+WEB_DIRECTORY = "web"
+
+__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
diff --git a/comfyui_ty360_photo_edit/__init__.py b/comfyui_ty360_photo_edit/__init__.py
new file mode 100644
index 0000000..bc20a5a
--- /dev/null
+++ b/comfyui_ty360_photo_edit/__init__.py
@@ -0,0 +1 @@
+# TY360 Photo Edit package.
diff --git a/comfyui_ty360_photo_edit/core/__init__.py b/comfyui_ty360_photo_edit/core/__init__.py
new file mode 100644
index 0000000..fdccf7c
--- /dev/null
+++ b/comfyui_ty360_photo_edit/core/__init__.py
@@ -0,0 +1 @@
+# Core package for panorama math and rendering.
diff --git a/comfyui_ty360_photo_edit/core/cutout.py b/comfyui_ty360_photo_edit/core/cutout.py
new file mode 100644
index 0000000..808f7c9
--- /dev/null
+++ b/comfyui_ty360_photo_edit/core/cutout.py
@@ -0,0 +1,48 @@
+import math
+import numpy as np
+
+from .math import DEG2RAD, dir_to_lon_lat, lon_lat_to_erp, sample_erp_bilinear, yaw_pitch_to_dir, orthonormal_basis_from_forward
+
+
+def cutout_from_erp(
+ erp_rgb: np.ndarray,
+ yaw_deg: float,
+ pitch_deg: float,
+ h_fov_deg: float,
+ v_fov_deg: float,
+ roll_deg: float,
+ out_w: int,
+ out_h: int,
+) -> np.ndarray:
+ out_w = max(8, int(out_w))
+ out_h = max(8, int(out_h))
+
+ h_tan = math.tan(max(1e-3, h_fov_deg) * 0.5 * DEG2RAD)
+ v_tan = math.tan(max(1e-3, v_fov_deg) * 0.5 * DEG2RAD)
+
+ forward = yaw_pitch_to_dir(yaw_deg, pitch_deg)
+ right, up, fwd = orthonormal_basis_from_forward(forward)
+
+ xs = (np.arange(out_w, dtype=np.float32) + 0.5) / out_w * 2.0 - 1.0
+ ys = 1.0 - (np.arange(out_h, dtype=np.float32) + 0.5) / out_h * 2.0
+ xg, yg = np.meshgrid(xs, ys)
+
+ x = xg * h_tan
+ y = yg * v_tan
+
+ if abs(roll_deg) > 1e-6:
+ rr = roll_deg * DEG2RAD
+ cr = math.cos(rr)
+ sr = math.sin(rr)
+ xr = x * cr - y * sr
+ yr = x * sr + y * cr
+ x, y = xr, yr
+
+ dirs = fwd[None, None, :] + x[..., None] * right[None, None, :] + y[..., None] * up[None, None, :]
+ norm = np.linalg.norm(dirs, axis=-1, keepdims=True)
+ dirs = dirs / np.maximum(norm, 1e-8)
+
+ lon, lat = dir_to_lon_lat(dirs)
+ u, v = lon_lat_to_erp(lon, lat, erp_rgb.shape[1], erp_rgb.shape[0])
+
+ return sample_erp_bilinear(erp_rgb, u, v).astype(np.float32)
diff --git a/comfyui_ty360_photo_edit/core/math.py b/comfyui_ty360_photo_edit/core/math.py
new file mode 100644
index 0000000..5b1cfe3
--- /dev/null
+++ b/comfyui_ty360_photo_edit/core/math.py
@@ -0,0 +1,245 @@
+import math
+import numpy as np
+
+try:
+ import torch
+ import torch.nn.functional as F
+ HAS_TORCH = True
+except ImportError:
+ HAS_TORCH = False
+
+try:
+ import cv2
+ HAS_CV2 = True
+except ImportError:
+ HAS_CV2 = False
+
+
+DEG2RAD = math.pi / 180.0
+RAD2DEG = 180.0 / math.pi
+
+
+def clamp(v: float, lo: float, hi: float) -> float:
+ return max(lo, min(hi, v))
+
+
+def wrap_yaw_deg(yaw: float) -> float:
+ return (yaw + 180.0) % 360.0 - 180.0
+
+
+def yaw_pitch_to_dir(yaw_deg: float, pitch_deg: float) -> np.ndarray:
+ yaw = yaw_deg * DEG2RAD
+ pitch = pitch_deg * DEG2RAD
+ cp = math.cos(pitch)
+ return np.array([
+ cp * math.sin(yaw),
+ math.sin(pitch),
+ cp * math.cos(yaw),
+ ], dtype=np.float32)
+
+
+def dir_to_lon_lat(d: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
+ x = d[..., 0]
+ y = np.clip(d[..., 1], -1.0, 1.0)
+ z = d[..., 2]
+ lon = np.arctan2(x, z)
+ lat = np.arcsin(y)
+ return lon, lat
+
+
+def lon_lat_to_erp(lon: np.ndarray, lat: np.ndarray, w: int, h: int) -> tuple[np.ndarray, np.ndarray]:
+ u = ((lon / (2.0 * math.pi)) + 0.5) * w
+ v = (0.5 - (lat / math.pi)) * h
+ u = np.mod(u, w)
+ v = np.clip(v, 0.0, h - 1.0)
+ return u, v
+
+
+def sample_erp_bilinear(erp: np.ndarray, u: np.ndarray, v: np.ndarray) -> np.ndarray:
+ """Samples an Equirectangular image using bilinear interpolation with horizontal wrapping."""
+ h, w, c = erp.shape
+ # Normalize coordinates to ensure correct wrapping and clipping across all paths
+ u = np.mod(u, w)
+ v = np.clip(v, 0.0, h - 1.0)
+
+ if HAS_TORCH:
+ # Ensure contiguous and correct dtype for torch
+ # erp is (H, W, C) -> torch needs (B, C, H, W)
+ t_erp = torch.from_numpy(np.ascontiguousarray(erp)).to(torch.float32).permute(2, 0, 1)[None, ...]
+ # Pad horizontally for wrapping
+ t_erp_padded = torch.cat([t_erp, t_erp[:, :, :, :1]], dim=3)
+
+ # Grid sample coordinates in [-1, 1]
+ # x corresponds to u (width), y corresponds to v (height)
+ # padded width is w+1. index 0 maps to -1, index w maps to 1.
+ grid_u = (torch.from_numpy(np.ascontiguousarray(u)).to(torch.float32) / w) * 2.0 - 1.0
+
+ # Avoid division by zero if h=1
+ v_denom = float(h - 1) if h > 1 else 1.0
+ grid_v = (torch.from_numpy(np.ascontiguousarray(v)).to(torch.float32) / v_denom) * 2.0 - 1.0
+
+ grid = torch.stack([grid_u, grid_v], dim=-1)[None, ...]
+
+ # Note: we use align_corners=True to exactly match pixel center sampling
+ out = F.grid_sample(t_erp_padded, grid, mode='bilinear', padding_mode='border', align_corners=True)
+ return out[0].permute(1, 2, 0).cpu().numpy().astype(erp.dtype)
+
+ if HAS_CV2:
+ # Optimize by using cv2.remap. To handle horizontal wrapping (longitude),
+ # we pad the image by 1 pixel on the right side.
+ erp_padded = np.concatenate([erp, erp[:, :1, :]], axis=1)
+ # Using BORDER_REPLICATE for the vertical axis (latitude) to match np.clip.
+ # The horizontal axis wrapping is handled by the padding + u coordinates in [0, w].
+ return cv2.remap(
+ erp_padded,
+ u.astype(np.float32),
+ v.astype(np.float32),
+ interpolation=cv2.INTER_LINEAR,
+ borderMode=cv2.BORDER_REPLICATE,
+ )
+
+ # Manual NumPy fallback
+ x0 = np.floor(u).astype(np.int32)
+ y0 = np.floor(v).astype(np.int32)
+ x1 = (x0 + 1) % w
+ y1 = np.clip(y0 + 1, 0, h - 1)
+
+ fx = (u - x0)[..., None]
+ fy = (v - y0)[..., None]
+
+ c00 = erp[y0, x0]
+ c10 = erp[y0, x1]
+ c01 = erp[y1, x0]
+ c11 = erp[y1, x1]
+
+ c0 = c00 * (1.0 - fx) + c10 * fx
+ c1 = c01 * (1.0 - fx) + c11 * fx
+ return c0 * (1.0 - fy) + c1 * fy
+
+
+def round_to_multiple(x: float, multiple: int = 8, min_val: int = 8) -> int:
+ if multiple <= 0:
+ raise ValueError("multiple must be > 0")
+ return int(max(min_val, round(float(x) / float(multiple)) * multiple))
+
+
+def derive_rectilinear_aspect_ratio(hfov_deg: float, vfov_deg: float) -> float:
+ # Geometry source of truth: output aspect follows tangent ratio of FOV (rectilinear).
+ h_tan = math.tan(max(0.1, float(hfov_deg)) * 0.5 * DEG2RAD)
+ v_tan = math.tan(max(0.1, float(vfov_deg)) * 0.5 * DEG2RAD)
+ return max(0.05, min(20.0, h_tan / max(v_tan, 1e-6)))
+
+
+def calculate_output_dimensions(
+ hfov_deg: float,
+ vfov_deg: float,
+ long_side: int = 1024,
+ max_side: int = 4096,
+) -> tuple[int, int]:
+ max_side = max(int(max_side), 8)
+ long_side = int(np.clip(long_side, 8, max_side))
+ ratio = derive_rectilinear_aspect_ratio(hfov_deg, vfov_deg)
+ if ratio >= 1.0:
+ ow_val = round_to_multiple(long_side)
+ oh_val = round_to_multiple(long_side / ratio)
+ else:
+ oh_val = round_to_multiple(long_side)
+ ow_val = round_to_multiple(long_side * ratio)
+
+ return (
+ int(np.clip(ow_val, 8, max_side)),
+ int(np.clip(oh_val, 8, max_side)),
+ )
+
+
+def calculate_dimensions_from_megapixels(
+ megapixels: float,
+ hfov_deg: float,
+ vfov_deg: float,
+ multiple_of: int = 8,
+ max_side: int = 8192,
+) -> tuple[int, int]:
+ target_pixels = megapixels * 1_000_000.0
+
+ # Calculate aspect ratio (width / height)
+ # aspect = tan(hfov/2) / tan(vfov/2)
+ t_h = math.tan(max(0.01, float(hfov_deg)) * 0.5 * DEG2RAD)
+ t_v = math.tan(max(0.01, float(vfov_deg)) * 0.5 * DEG2RAD)
+ aspect = t_h / max(t_v, 1e-6)
+
+ # Solve W * H = target, W / H = aspect
+ # H^2 * aspect = target -> H = sqrt(target / aspect)
+ if aspect <= 1e-6:
+ aspect = 1.0
+
+ h_float = math.sqrt(target_pixels / aspect)
+ w_float = h_float * aspect
+
+ w_int = round_to_multiple(w_float, multiple_of)
+ h_int = round_to_multiple(h_float, multiple_of)
+
+ # Safety clamp
+ w_final = max(multiple_of, min(max_side, w_int))
+ h_final = max(multiple_of, min(max_side, h_int))
+
+ return w_final, h_final
+
+
+def calculate_dimensions_from_erp_density(
+ erp_w: int,
+ erp_h: int,
+ hfov_deg: float,
+ vfov_deg: float,
+ density_scale: float = 1.0,
+ multiple_of: int = 8,
+ max_width: int | None = None,
+ max_height: int | None = None,
+) -> tuple[int, int]:
+ erp_w = max(1, int(erp_w))
+ erp_h = max(1, int(erp_h))
+ scale = max(0.01, float(density_scale))
+ h_tan = math.tan(max(0.01, float(hfov_deg)) * 0.5 * DEG2RAD)
+ v_tan = math.tan(max(0.01, float(vfov_deg)) * 0.5 * DEG2RAD)
+
+ w_float = (float(erp_w) / math.pi) * h_tan * scale
+ h_float = (float(erp_h) * 2.0 / math.pi) * v_tan * scale
+
+ w_int = round_to_multiple(w_float, multiple_of)
+ h_int = round_to_multiple(h_float, multiple_of)
+
+ if max_width is not None:
+ w_int = min(w_int, max(multiple_of, int(max_width)))
+ if max_height is not None:
+ h_int = min(h_int, max(multiple_of, int(max_height)))
+
+ return max(multiple_of, w_int), max(multiple_of, h_int)
+
+
+def orthonormal_basis_from_forward(forward: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ f = forward.astype(np.float32)
+ f = f / (np.linalg.norm(f) + 1e-8)
+ world_up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
+ if abs(float(np.dot(f, world_up))) > 0.999:
+ world_up = np.array([0.0, 0.0, 1.0], dtype=np.float32)
+ right = np.cross(world_up, f)
+ right = right / (np.linalg.norm(right) + 1e-8)
+ up = np.cross(f, right)
+ up = up / (np.linalg.norm(up) + 1e-8)
+ return right, up, f
+
+def finite_float(value, default: float = 0.0) -> float:
+ try:
+ out = float(value)
+ except Exception:
+ return float(default)
+ if not np.isfinite(out):
+ return float(default)
+ return out
+
+
+def finite_int(value, default: int = 0) -> int:
+ try:
+ out = int(float(value))
+ except Exception:
+ return int(default)
+ return int(out)
diff --git a/comfyui_ty360_photo_edit/core/state.py b/comfyui_ty360_photo_edit/core/state.py
new file mode 100644
index 0000000..28ec665
--- /dev/null
+++ b/comfyui_ty360_photo_edit/core/state.py
@@ -0,0 +1,231 @@
+import json
+import math
+import uuid
+from copy import deepcopy
+
+DEFAULT_STATE = {
+ "version": 1,
+ "projection_model": "pinhole_rectilinear",
+ "alpha_mode": "straight",
+ "bg_color": "#00ff00",
+ "output_preset": 2048,
+ "assets": {},
+ "stickers": [],
+ "shots": [],
+ "active": {
+ "selected_sticker_id": None,
+ "selected_shot_id": None,
+ },
+}
+
+
+def make_id(prefix: str) -> str:
+ return f"{prefix}_{uuid.uuid4().hex[:8]}"
+
+
+def merge_state(state_in: str | None, internal_state: str | None, fallback_preset: int = 2048, fallback_bg: str = "#00ff00") -> dict:
+ state = deepcopy(DEFAULT_STATE)
+ state["output_preset"] = int(fallback_preset)
+ state["bg_color"] = fallback_bg
+
+ for src in (internal_state, state_in):
+ parsed = parse_state(src)
+ if parsed is None:
+ continue
+ state.update({k: v for k, v in parsed.items() if k in state})
+
+ state["version"] = 1
+ state["projection_model"] = "pinhole_rectilinear"
+ state["alpha_mode"] = "straight"
+ if "assets" not in state or not isinstance(state["assets"], dict):
+ state["assets"] = {}
+ if "stickers" not in state or not isinstance(state["stickers"], list):
+ state["stickers"] = []
+ if "shots" not in state or not isinstance(state["shots"], list):
+ state["shots"] = []
+ if "active" not in state or not isinstance(state["active"], dict):
+ state["active"] = deepcopy(DEFAULT_STATE["active"])
+ if "selected_sticker_id" not in state["active"]:
+ state["active"]["selected_sticker_id"] = None
+ if "selected_shot_id" not in state["active"]:
+ state["active"]["selected_shot_id"] = None
+ return state
+
+
+def parse_state(state_raw: str | None) -> dict | None:
+ if not state_raw:
+ return None
+ if isinstance(state_raw, str):
+ text = state_raw.strip()
+ if not text:
+ return None
+ try:
+ parsed = json.loads(text)
+ if isinstance(parsed, dict):
+ return parsed
+ except Exception:
+ return None
+ return None
+
+
+def dump_state(state: dict) -> str:
+ return json.dumps(state, ensure_ascii=True, separators=(",", ":"))
+
+
+def _wrap_yaw(yaw_deg: float) -> float:
+ return ((float(yaw_deg) + 180.0) % 360.0) - 180.0
+
+
+def parse_sticker_state(state_raw: str | None) -> dict | None:
+ if not isinstance(state_raw, str):
+ return None
+ text = state_raw.strip()
+ if not text:
+ return None
+ try:
+ parsed = json.loads(text)
+ except Exception:
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ if str(parsed.get("kind") or "") != "pano_sticker_state":
+ return None
+ version_value = parsed.get("version")
+ if isinstance(version_value, bool):
+ return None
+ if isinstance(version_value, int):
+ version = version_value
+ elif isinstance(version_value, str) and version_value.isdigit():
+ try:
+ version = int(version_value)
+ except ValueError:
+ return None
+ else:
+ return None
+ if version != 1:
+ return None
+ pose = parsed.get("pose")
+ if not isinstance(pose, dict):
+ return None
+
+ try:
+ yaw_deg = _wrap_yaw(float(pose["yaw_deg"]))
+ pitch_deg = max(-89.9, min(89.9, float(pose["pitch_deg"])))
+ roll_deg = float(pose["roll_deg"])
+ h_fov_deg = max(0.1, min(179.0, float(pose["hFOV_deg"])))
+ except Exception:
+ return None
+
+ values = [yaw_deg, pitch_deg, roll_deg, h_fov_deg]
+ if not all(math.isfinite(v) for v in values):
+ return None
+
+ out = {
+ "yaw_deg": yaw_deg,
+ "pitch_deg": pitch_deg,
+ "roll_deg": roll_deg,
+ "hFOV_deg": h_fov_deg,
+ }
+
+ source_aspect = parsed.get("source_aspect", None)
+ if source_aspect is not None:
+ try:
+ source_aspect_val = float(source_aspect)
+ except Exception:
+ source_aspect_val = None
+ if source_aspect_val is not None and math.isfinite(source_aspect_val) and source_aspect_val > 0.0:
+ out["source_aspect"] = source_aspect_val
+
+ return out
+
+
+def parse_roundtrip_state(state_raw: str | None) -> dict | None:
+ if not isinstance(state_raw, str):
+ return None
+ text = state_raw.strip()
+ if not text:
+ return None
+ try:
+ parsed = json.loads(text)
+ except Exception:
+ return None
+ if not isinstance(parsed, dict):
+ return None
+ if str(parsed.get("kind") or "") != "ty360_roundtrip_state":
+ return None
+
+ version_value = parsed.get("version")
+ if isinstance(version_value, bool):
+ return None
+ if isinstance(version_value, int):
+ version = version_value
+ elif isinstance(version_value, str) and version_value.isdigit():
+ try:
+ version = int(version_value)
+ except ValueError:
+ return None
+ else:
+ return None
+ if version != 1:
+ return None
+
+ pose = parsed.get("pose")
+ source_erp = parsed.get("source_erp")
+ rect = parsed.get("rect")
+ if not isinstance(pose, dict) or not isinstance(source_erp, dict) or not isinstance(rect, dict):
+ return None
+
+ try:
+ yaw_deg = _wrap_yaw(float(pose["yaw_deg"]))
+ pitch_deg = max(-89.9, min(89.9, float(pose["pitch_deg"])))
+ roll_deg = float(pose["roll_deg"])
+ h_fov_deg = max(0.1, min(179.0, float(pose["hFOV_deg"])))
+ source_erp_w = int(source_erp["width"])
+ source_erp_h = int(source_erp["height"])
+ rect_w = int(rect["width"])
+ rect_h = int(rect["height"])
+ except Exception:
+ return None
+ if min(source_erp_w, source_erp_h, rect_w, rect_h) <= 0:
+ return None
+
+ v_fov_raw = pose.get("vFOV_deg", None)
+ if v_fov_raw is None:
+ rect_aspect = float(rect_w) / float(rect_h)
+ v_fov_deg = math.degrees(2.0 * math.atan(math.tan(math.radians(h_fov_deg) * 0.5) / max(rect_aspect, 1e-6)))
+ else:
+ try:
+ v_fov_deg = float(v_fov_raw)
+ except Exception:
+ return None
+ v_fov_deg = max(0.1, min(179.0, v_fov_deg))
+
+ values = [yaw_deg, pitch_deg, roll_deg, h_fov_deg, v_fov_deg]
+ if not all(math.isfinite(v) for v in values):
+ return None
+
+ source_aspect = parsed.get("source_aspect", None)
+ if source_aspect is None:
+ source_aspect_val = float(rect_w) / float(rect_h)
+ else:
+ try:
+ source_aspect_val = float(source_aspect)
+ except Exception:
+ return None
+ if not math.isfinite(source_aspect_val) or source_aspect_val <= 0.0:
+ return None
+
+ return {
+ "yaw_deg": yaw_deg,
+ "pitch_deg": pitch_deg,
+ "roll_deg": roll_deg,
+ "hFOV_deg": h_fov_deg,
+ "vFOV_deg": v_fov_deg,
+ "source_erp_w": source_erp_w,
+ "source_erp_h": source_erp_h,
+ "rect_w": rect_w,
+ "rect_h": rect_h,
+ "source_aspect": source_aspect_val,
+ "projection_model": str(parsed.get("projection_model") or "pinhole_rectilinear"),
+ }
+
diff --git a/comfyui_ty360_photo_edit/core/stickers.py b/comfyui_ty360_photo_edit/core/stickers.py
new file mode 100644
index 0000000..02aec84
--- /dev/null
+++ b/comfyui_ty360_photo_edit/core/stickers.py
@@ -0,0 +1,281 @@
+import base64
+import io
+import math
+from functools import lru_cache
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+from .math import DEG2RAD, orthonormal_basis_from_forward, yaw_pitch_to_dir
+
+try:
+ import folder_paths
+except Exception: # pragma: no cover - optional in non-Comfy test environments
+ folder_paths = None
+
+
+def _hex_to_rgb01(hex_color: str) -> np.ndarray:
+ s = (hex_color or "#00ff00").strip().lstrip("#")
+ if len(s) != 6:
+ s = "00ff00"
+ r = int(s[0:2], 16)
+ g = int(s[2:4], 16)
+ b = int(s[4:6], 16)
+ return np.array([r, g, b], dtype=np.float32) / 255.0
+
+
+@lru_cache(maxsize=32)
+def _load_dataurl_cached(v: str) -> np.ndarray:
+ payload = v.split(",", 1)[1]
+ raw = base64.b64decode(payload)
+ img = Image.open(io.BytesIO(raw)).convert("RGBA")
+ arr = np.asarray(img, dtype=np.float32) / 255.0
+ arr.flags.writeable = False
+ return arr
+
+
+def _load_asset_rgba(asset_info: dict, base_dir: Path | None = None) -> np.ndarray | None:
+ if not isinstance(asset_info, dict):
+ return None
+ t = str(asset_info.get("type") or "").strip().lower()
+
+ try:
+ if t == "dataurl":
+ v = str(asset_info.get("value") or "")
+ if not v.startswith("data:image"):
+ return None
+ return _load_dataurl_cached(v)
+
+ if t == "path":
+ v = str(asset_info.get("value") or asset_info.get("path") or "").strip()
+ if not v:
+ return None
+ p = Path(v)
+ if base_dir is not None:
+ p = (base_dir / p).resolve()
+ try:
+ p.relative_to(base_dir.resolve())
+ except Exception:
+ return None
+ elif p.is_absolute():
+ return None
+ else:
+ p = p.resolve()
+ try:
+ p.relative_to(Path.cwd().resolve())
+ except Exception:
+ return None
+
+ if not p.exists() or not p.is_file():
+ return None
+ img = Image.open(p).convert("RGBA")
+ return np.asarray(img, dtype=np.float32) / 255.0
+
+ if t == "comfy_image" and folder_paths is not None:
+ filename = str(asset_info.get("filename") or "").strip()
+ if not filename:
+ return None
+ subfolder = str(asset_info.get("subfolder") or "").strip().strip("/\\")
+ storage = str(asset_info.get("storage") or "input").strip().lower()
+ if storage == "output":
+ base = Path(folder_paths.get_output_directory())
+ elif storage == "temp":
+ base = Path(folder_paths.get_temp_directory())
+ else:
+ base = Path(folder_paths.get_input_directory())
+ p = (base / subfolder / filename).resolve() if subfolder else (base / filename).resolve()
+ try:
+ p.relative_to(base.resolve())
+ except Exception:
+ return None
+ if not p.exists() or not p.is_file():
+ return None
+ img = Image.open(p).convert("RGBA")
+ return np.asarray(img, dtype=np.float32) / 255.0
+ except Exception:
+ return None
+ return None
+
+
+def _sample_rgba_bilinear(img: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray:
+ h, w, _ = img.shape
+ x = np.clip(x, 0.0, w - 1.0)
+ y = np.clip(y, 0.0, h - 1.0)
+
+ x0 = np.floor(x).astype(np.int32)
+ y0 = np.floor(y).astype(np.int32)
+ x1 = np.clip(x0 + 1, 0, w - 1)
+ y1 = np.clip(y0 + 1, 0, h - 1)
+
+ fx = (x - x0)[..., None]
+ fy = (y - y0)[..., None]
+
+ c00 = img[y0, x0]
+ c10 = img[y0, x1]
+ c01 = img[y1, x0]
+ c11 = img[y1, x1]
+
+ c0 = c00 * (1.0 - fx) + c10 * fx
+ c1 = c01 * (1.0 - fx) + c11 * fx
+ return c0 * (1.0 - fy) + c1 * fy
+
+
+def _alpha_over_straight(dst_rgb: np.ndarray, src_rgba: np.ndarray) -> np.ndarray:
+ src_a = src_rgba[..., 3:4]
+ src_rgb = src_rgba[..., :3]
+ return src_rgb * src_a + dst_rgb * (1.0 - src_a)
+
+
+def _iter_u_ranges(center_u: float, half_u: int, w: int):
+ start = int(math.floor(center_u - half_u))
+ end = int(math.ceil(center_u + half_u))
+ if start < 0:
+ yield (start + w, w)
+ yield (0, end)
+ elif end >= w:
+ yield (start, w)
+ yield (0, end - w)
+ else:
+ yield (start, end)
+
+
+def compose_stickers_to_erp(
+ state: dict,
+ output_w: int,
+ output_h: int,
+ bg_erp: np.ndarray | None = None,
+ base_dir: Path | None = None,
+ quality: str = "export",
+) -> np.ndarray:
+ if bg_erp is not None:
+ canvas = np.clip(bg_erp.astype(np.float32), 0.0, 1.0)
+ if canvas.shape[0] != output_h or canvas.shape[1] != output_w:
+ canvas = np.asarray(
+ Image.fromarray((canvas * 255.0).astype(np.uint8)).resize((output_w, output_h), Image.BILINEAR),
+ dtype=np.float32,
+ ) / 255.0
+ else:
+ bg = _hex_to_rgb01(state.get("bg_color", "#00ff00"))
+ canvas = np.ones((output_h, output_w, 3), dtype=np.float32) * bg[None, None, :]
+
+ stickers = state.get("stickers", [])
+ assets = state.get("assets", {})
+ stickers_sorted = sorted(stickers, key=lambda s: float(s.get("z_index", 0)))
+
+ for st in stickers_sorted:
+ if st.get("visible", True) is False:
+ continue
+ runtime_img = st.get("image_rgba")
+ if isinstance(runtime_img, np.ndarray):
+ img = np.clip(runtime_img.astype(np.float32), 0.0, 1.0)
+ if img.ndim != 3 or img.shape[0] <= 0 or img.shape[1] <= 0:
+ continue
+ if img.shape[-1] < 4:
+ if img.shape[-1] < 3:
+ continue
+ alpha = np.ones((img.shape[0], img.shape[1], 1), dtype=np.float32)
+ img = np.concatenate([img[..., :3], alpha], axis=-1)
+ elif img.shape[-1] > 4:
+ img = img[..., :4]
+ else:
+ asset_id = st.get("asset_id")
+ if asset_id not in assets:
+ continue
+ img = _load_asset_rgba(assets[asset_id], base_dir=base_dir)
+ if img is None:
+ continue
+ if img is None:
+ continue
+
+ yaw = float(st.get("yaw_deg", 0.0))
+ pitch = float(st.get("pitch_deg", 0.0))
+ h_fov = max(0.1, float(st.get("hFOV_deg", 20.0)))
+ v_fov = max(0.1, float(st.get("vFOV_deg", 20.0)))
+ rot = float(st.get("rot_deg", 0.0))
+ crop = st.get("crop", {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0})
+
+ x0 = float(crop.get("x0", 0.0))
+ y0 = float(crop.get("y0", 0.0))
+ x1 = float(crop.get("x1", 1.0))
+ y1 = float(crop.get("y1", 1.0))
+ cx0 = max(0.0, min(1.0, min(x0, x1)))
+ cy0 = max(0.0, min(1.0, min(y0, y1)))
+ cx1 = max(0.0, min(1.0, max(x0, x1)))
+ cy1 = max(0.0, min(1.0, max(y0, y1)))
+ if cx1 - cx0 < 1e-6 or cy1 - cy0 < 1e-6:
+ continue
+
+ cdir = yaw_pitch_to_dir(yaw, pitch)
+ right, up, fwd = orthonormal_basis_from_forward(cdir)
+
+ max_fov = max(h_fov, v_fov)
+ half_u = int(math.ceil(output_w * (max_fov / 360.0) * (1.5 if quality == "preview" else 1.2)))
+ half_v = int(math.ceil(output_h * (max_fov / 180.0) * (1.5 if quality == "preview" else 1.2)))
+
+ center_u = ((yaw / 360.0) + 0.5) * output_w
+ center_v = (0.5 - (pitch / 180.0)) * output_h
+
+ y_min = max(0, int(center_v - half_v))
+ y_max = min(output_h, int(center_v + half_v))
+ if y_max <= y_min:
+ continue
+
+ xs_lin = np.arange(output_w, dtype=np.float32) + 0.5
+ ys_lin = np.arange(y_min, y_max, dtype=np.float32) + 0.5
+
+ for ux0, ux1 in _iter_u_ranges(center_u, half_u, output_w):
+ ux0 = max(0, ux0)
+ ux1 = min(output_w, ux1)
+ if ux1 <= ux0:
+ continue
+
+ xs = xs_lin[ux0:ux1]
+ ys = ys_lin
+ xg, yg = np.meshgrid(xs, ys)
+
+ lon = (xg / output_w - 0.5) * (2.0 * math.pi)
+ lat = (0.5 - yg / output_h) * math.pi
+ dirs = np.stack([
+ np.cos(lat) * np.sin(lon),
+ np.sin(lat),
+ np.cos(lat) * np.cos(lon),
+ ], axis=-1).astype(np.float32)
+
+ z = np.sum(dirs * fwd[None, None, :], axis=-1)
+ front = z > 1e-6
+ if not np.any(front):
+ continue
+
+ local_x = np.sum(dirs * right[None, None, :], axis=-1) / np.maximum(z, 1e-6)
+ local_y = np.sum(dirs * up[None, None, :], axis=-1) / np.maximum(z, 1e-6)
+
+ rr = -rot * DEG2RAD
+ cr = math.cos(rr)
+ sr = math.sin(rr)
+ xr = local_x * cr - local_y * sr
+ yr = local_x * sr + local_y * cr
+
+ xn = xr / math.tan(h_fov * 0.5 * DEG2RAD)
+ yn = yr / math.tan(v_fov * 0.5 * DEG2RAD)
+
+ inside = front & (np.abs(xn) <= 1.0) & (np.abs(yn) <= 1.0)
+ if not np.any(inside):
+ continue
+
+ su = (xn * 0.5 + 0.5)
+ sv = (0.5 - yn * 0.5)
+ su = cx0 + (cx1 - cx0) * su
+ sv = cy0 + (cy1 - cy0) * sv
+
+ ih, iw, _ = img.shape
+ px = su * (iw - 1)
+ py = sv * (ih - 1)
+ rgba = _sample_rgba_bilinear(img, px, py)
+
+ patch = canvas[y_min:y_max, ux0:ux1, :]
+ blended = _alpha_over_straight(patch, rgba)
+ patch[inside] = blended[inside]
+ canvas[y_min:y_max, ux0:ux1, :] = patch
+
+ return np.clip(canvas, 0.0, 1.0).astype(np.float32)
diff --git a/comfyui_ty360_photo_edit/nodes.py b/comfyui_ty360_photo_edit/nodes.py
new file mode 100644
index 0000000..f4ef116
--- /dev/null
+++ b/comfyui_ty360_photo_edit/nodes.py
@@ -0,0 +1,318 @@
+import json
+import logging
+from pathlib import Path
+
+import numpy as np
+import torch
+from comfy_api.latest import io
+
+try:
+ import nodes
+except ImportError:
+ nodes = None
+
+from .core.cutout import cutout_from_erp
+from .core.math import calculate_dimensions_from_erp_density, calculate_dimensions_from_megapixels, finite_float
+from .core.state import merge_state, parse_roundtrip_state
+from .core.stickers import compose_stickers_to_erp
+
+
+def _save_input_preview(images, key="pano_input_images"):
+ if nodes is None or images is None:
+ return {}
+ try:
+ res = nodes.PreviewImage().save_images(images)
+ if "ui" in res and "images" in res["ui"]:
+ return {key: res["ui"]["images"]}
+ except Exception:
+ logging.getLogger(__name__).exception("Failed to save preview image for %s", key)
+ return {}
+
+
+def _single_image_to_numpy(image, warnings: list[str]) -> np.ndarray | None:
+ if image is None or not hasattr(image, "detach"):
+ return None
+ try:
+ arr = image.detach().cpu().numpy().astype(np.float32)
+ except Exception:
+ return None
+ if arr.ndim == 3:
+ arr = arr[None, ...]
+ if arr.ndim != 4 or arr.shape[0] <= 0:
+ return None
+ if arr.shape[0] > 1:
+ warnings.append("Multiple images received; using the first image only.")
+ img = arr[0]
+ if img.ndim != 3 or img.shape[0] <= 0 or img.shape[1] <= 0:
+ return None
+ if img.shape[-1] < 3:
+ img = np.repeat(img[..., :1], 3, axis=-1)
+ elif img.shape[-1] > 4:
+ img = img[..., :4]
+ return np.clip(img.astype(np.float32), 0.0, 1.0)
+
+
+def _first_image_tensor(image):
+ if image is None or not hasattr(image, "shape"):
+ return image
+ try:
+ if len(image.shape) >= 4 and int(image.shape[0]) > 1:
+ return image[:1]
+ except Exception:
+ return image
+ return image
+
+
+def _first_image_numpy_rgb(image, warnings: list[str]) -> np.ndarray | None:
+ img_np = _single_image_to_numpy(image, warnings)
+ if img_np is None or img_np.shape[-1] < 3:
+ return None
+ return np.clip(img_np[..., :3].astype(np.float32), 0.0, 1.0)
+
+
+def _build_roundtrip_state_json(shot: dict, source_w: int, source_h: int, frame_w: int, frame_h: int) -> str:
+ width = max(1, int(frame_w))
+ height = max(1, int(frame_h))
+ payload = {
+ "kind": "ty360_roundtrip_state",
+ "version": 1,
+ "projection_model": "pinhole_rectilinear",
+ "source_erp": {
+ "width": max(1, int(source_w)),
+ "height": max(1, int(source_h)),
+ },
+ "rect": {
+ "width": width,
+ "height": height,
+ },
+ "pose": {
+ "yaw_deg": finite_float(shot.get("yaw_deg", 0.0), 0.0),
+ "pitch_deg": finite_float(shot.get("pitch_deg", 0.0), 0.0),
+ "roll_deg": finite_float(shot.get("roll_deg", 0.0), 0.0),
+ "hFOV_deg": float(np.clip(finite_float(shot.get("hFOV_deg", 90.0), 90.0), 0.1, 179.0)),
+ "vFOV_deg": float(np.clip(finite_float(shot.get("vFOV_deg", 60.0), 60.0), 0.1, 179.0)),
+ },
+ "source_aspect": float(width) / float(height),
+ }
+ return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
+
+
+class TY360PreviewNode(io.ComfyNode):
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="TY360Preview",
+ display_name="TY360 Preview",
+ category="TY360",
+ inputs=[io.Image.Input("erp_image")],
+ outputs=[],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, erp_image):
+ ui_ret = {}
+ if erp_image is not None:
+ ui_ret = _save_input_preview(erp_image)
+ return io.NodeOutput(ui=ui_ret)
+
+
+class TY360RoundtripNode(io.ComfyNode):
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="TY360Roundtrip",
+ display_name="TY360 Roundtrip",
+ category="TY360",
+ inputs=[
+ io.Image.Input("erp_image"),
+ io.String.Input(
+ "state_json",
+ default="",
+ multiline=True,
+ dynamic_prompts=False,
+ ),
+ io.Combo.Input(
+ "resolution_mode",
+ options=["match_source_density", "manual_megapixels"],
+ default="match_source_density",
+ ),
+ io.Float.Input("density_scale", default=1.0, min=0.1, step=0.05),
+ io.Float.Input("output_megapixels", default=1.0, min=0.01, step=0.05),
+ ],
+ outputs=[
+ io.Image.Output("erp_image", display_name="erp_image"),
+ io.Image.Output("rect_image", display_name="rect_image"),
+ io.String.Output("roundtrip_state", display_name="roundtrip_state"),
+ ],
+ hidden=[io.Hidden.unique_id],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(
+ cls,
+ erp_image,
+ state_json,
+ resolution_mode="match_source_density",
+ density_scale=1.0,
+ output_megapixels=1.0,
+ ):
+ warnings = []
+ output_megapixels = max(0.01, finite_float(output_megapixels, 1.0))
+ density_scale = max(0.1, finite_float(density_scale, 1.0))
+ state = merge_state(state_in=None, internal_state=state_json)
+ shots = state.get("shots", []) if isinstance(state, dict) else []
+ shot = shots[0] if shots else {
+ "yaw_deg": 0.0,
+ "pitch_deg": 0.0,
+ "hFOV_deg": 90.0,
+ "vFOV_deg": 60.0,
+ "roll_deg": 0.0,
+ }
+
+ yaw = finite_float(shot.get("yaw_deg", 0.0), 0.0)
+ pitch = finite_float(shot.get("pitch_deg", 0.0), 0.0)
+ hfov = float(np.clip(finite_float(shot.get("hFOV_deg", 90.0), 90.0), 1.0, 179.0))
+ vfov = float(np.clip(finite_float(shot.get("vFOV_deg", 60.0), 60.0), 1.0, 179.0))
+ roll = finite_float(shot.get("roll_deg", 0.0), 0.0)
+
+ src = _first_image_numpy_rgb(erp_image, warnings)
+ if src is None:
+ src = np.zeros((512, 1024, 3), dtype=np.float32)
+ src_h, src_w = int(src.shape[0]), int(src.shape[1])
+
+ if str(resolution_mode or "").strip() == "manual_megapixels":
+ max_side = max(src_w, src_h)
+ out_w, out_h = calculate_dimensions_from_megapixels(
+ output_megapixels,
+ hfov,
+ vfov,
+ max_side=max_side,
+ )
+ else:
+ out_w, out_h = calculate_dimensions_from_erp_density(
+ src_w,
+ src_h,
+ hfov,
+ vfov,
+ density_scale=density_scale,
+ max_width=src_w,
+ max_height=src_h,
+ )
+
+ roundtrip_state_json = _build_roundtrip_state_json(shot, src_w, src_h, out_w, out_h)
+ rect_np = cutout_from_erp(src, yaw, pitch, hfov, vfov, roll, out_w, out_h)
+ if rect_np.ndim != 3 or rect_np.shape[-1] != 3:
+ rect_np = np.zeros((out_h, out_w, 3), dtype=np.float32)
+
+ rect_t = torch.from_numpy(rect_np)[None, ...]
+ erp_t = _first_image_tensor(erp_image)
+ if erp_t is None:
+ erp_t = torch.from_numpy(src)[None, ...]
+
+ ui_ret = _save_input_preview(erp_image) if erp_image is not None else {}
+ if warnings:
+ ui_ret["ty360_roundtrip_warnings"] = [str(w) for w in warnings]
+ return io.NodeOutput(erp_t, rect_t, roundtrip_state_json, ui=ui_ret)
+
+
+class TY360PastebackNode(io.ComfyNode):
+ @classmethod
+ def define_schema(cls):
+ return io.Schema(
+ node_id="TY360Pasteback",
+ display_name="TY360 Pasteback",
+ category="TY360",
+ inputs=[
+ io.Image.Input("erp_image"),
+ io.Image.Input("rect_image"),
+ io.String.Input(
+ "roundtrip_state",
+ default="",
+ multiline=False,
+ dynamic_prompts=False,
+ force_input=True,
+ ),
+ ],
+ outputs=[io.Image.Output("erp_image", display_name="erp_image")],
+ hidden=[io.Hidden.unique_id],
+ is_output_node=True,
+ )
+
+ @classmethod
+ def execute(cls, erp_image, rect_image, roundtrip_state):
+ parsed = parse_roundtrip_state(roundtrip_state)
+ if parsed is None:
+ raise ValueError("TY360Pasteback requires a valid roundtrip_state input.")
+
+ warnings = []
+ src = _first_image_numpy_rgb(erp_image, warnings)
+ if src is None:
+ raise ValueError("TY360Pasteback requires a valid ERP image input.")
+
+ src_h, src_w = int(src.shape[0]), int(src.shape[1])
+ if src_w != int(parsed["source_erp_w"]) or src_h != int(parsed["source_erp_h"]):
+ raise ValueError(
+ f"ERP size {src_w}x{src_h} does not match roundtrip_state source size "
+ f"{parsed['source_erp_w']}x{parsed['source_erp_h']}."
+ )
+
+ rect_np = _single_image_to_numpy(rect_image, warnings)
+ if rect_np is None:
+ raise ValueError("TY360Pasteback requires a valid rect_image input.")
+ rect_h = int(rect_np.shape[0])
+ rect_w = int(rect_np.shape[1])
+ actual_aspect = float(rect_w) / float(max(1, rect_h))
+ expected_aspect = float(parsed["source_aspect"])
+ if abs(actual_aspect - expected_aspect) > 1e-3:
+ raise ValueError(
+ f"Edited rect_image aspect ratio {actual_aspect:.6f} does not match "
+ f"roundtrip_state aspect {expected_aspect:.6f}."
+ )
+
+ if rect_np.shape[-1] == 3:
+ alpha = np.ones((rect_h, rect_w, 1), dtype=np.float32)
+ rect_rgba = np.concatenate([rect_np, alpha], axis=-1)
+ else:
+ rect_rgba = np.clip(rect_np[..., :4].astype(np.float32), 0.0, 1.0)
+
+ render_state = {
+ "bg_color": "#000000",
+ "assets": {},
+ "stickers": [{
+ "id": "ty360_roundtrip_rect",
+ "visible": True,
+ "z_index": 0,
+ "yaw_deg": float(parsed["yaw_deg"]),
+ "pitch_deg": float(parsed["pitch_deg"]),
+ "hFOV_deg": float(parsed["hFOV_deg"]),
+ "vFOV_deg": float(parsed["vFOV_deg"]),
+ "rot_deg": float(parsed["roll_deg"]),
+ "crop": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0},
+ "image_rgba": rect_rgba,
+ }],
+ }
+
+ out = compose_stickers_to_erp(
+ state=render_state,
+ output_w=src_w,
+ output_h=src_h,
+ bg_erp=src,
+ base_dir=Path.cwd(),
+ quality="export",
+ )
+ return io.NodeOutput(torch.from_numpy(out)[None, ...])
+
+
+NODE_CLASS_MAPPINGS = {
+ "TY360Preview": TY360PreviewNode,
+ "TY360Roundtrip": TY360RoundtripNode,
+ "TY360Pasteback": TY360PastebackNode,
+}
+
+NODE_DISPLAY_NAME_MAPPINGS = {
+ "TY360Preview": "TY360 Preview",
+ "TY360Roundtrip": "TY360 Roundtrip",
+ "TY360Pasteback": "TY360 Pasteback",
+}
diff --git a/example_workflows/ty360-roundtrip-demo.json b/example_workflows/ty360-roundtrip-demo.json
new file mode 100644
index 0000000..8f79e5f
--- /dev/null
+++ b/example_workflows/ty360-roundtrip-demo.json
@@ -0,0 +1,2455 @@
+{
+ "id": "92112d97-bb64-4b44-86f2-ea5691ef8f6e",
+ "revision": 0,
+ "last_node_id": 103,
+ "last_link_id": 199,
+ "nodes": [
+ {
+ "id": 76,
+ "type": "LoadImage",
+ "pos": [
+ -646.8806646355692,
+ 824.5102083654791
+ ],
+ "size": [
+ 320,
+ 405
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "image",
+ "name": "image",
+ "type": "COMBO",
+ "widget": {
+ "name": "image"
+ },
+ "link": null
+ },
+ {
+ "label": "upload",
+ "name": "upload",
+ "type": "IMAGEUPLOAD",
+ "widget": {
+ "name": "upload"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "IMAGE",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 190
+ ]
+ },
+ {
+ "label": "MASK",
+ "name": "MASK",
+ "type": "MASK",
+ "links": null
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "LoadImage",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "replace_with_your_360_image.jpg",
+ "image"
+ ]
+ },
+ {
+ "id": 100,
+ "type": "TY360Roundtrip",
+ "pos": [
+ -273.95193369896697,
+ 825.8298054203041
+ ],
+ "size": [
+ 409.6099999999999,
+ 428.19000000000005
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "erp_image",
+ "name": "erp_image",
+ "type": "IMAGE",
+ "link": 190
+ },
+ {
+ "label": "state_json",
+ "name": "state_json",
+ "type": "STRING",
+ "widget": {
+ "name": "state_json"
+ },
+ "link": null
+ },
+ {
+ "label": "resolution_mode",
+ "name": "resolution_mode",
+ "type": "COMBO",
+ "widget": {
+ "name": "resolution_mode"
+ },
+ "link": null
+ },
+ {
+ "label": "density_scale",
+ "name": "density_scale",
+ "type": "FLOAT",
+ "widget": {
+ "name": "density_scale"
+ },
+ "link": null
+ },
+ {
+ "label": "output_megapixels",
+ "name": "output_megapixels",
+ "type": "FLOAT",
+ "widget": {
+ "name": "output_megapixels"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "erp_image",
+ "name": "erp_image",
+ "type": "IMAGE",
+ "links": [
+ 192,
+ 196
+ ]
+ },
+ {
+ "label": "rect_image",
+ "name": "rect_image",
+ "type": "IMAGE",
+ "links": [
+ 195,
+ 199
+ ]
+ },
+ {
+ "label": "roundtrip_state",
+ "name": "roundtrip_state",
+ "type": "STRING",
+ "links": [
+ 197
+ ]
+ }
+ ],
+ "properties": {
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "input_ue_unconnectable": {}
+ },
+ "Node name for S&R": "TY360Roundtrip"
+ },
+ "widgets_values": [
+ "{\"version\":1,\"projection_model\":\"pinhole_rectilinear\",\"alpha_mode\":\"straight\",\"bg_color\":\"#00ff00\",\"output_preset\":2048,\"assets\":{},\"stickers\":[],\"shots\":[{\"id\":\"sh_f1ea7f5a\",\"yaw_deg\":-65.81665024510772,\"pitch_deg\":-9.198153293764943,\"hFOV_deg\":85.15915044160919,\"vFOV_deg\":85.15915044160919,\"roll_deg\":0,\"out_w\":1024,\"out_h\":1024,\"aspect_id\":\"1:1\"}],\"ui_settings\":{\"invert_view_x\":false,\"invert_view_y\":false,\"preview_quality\":\"balanced\"},\"active\":{\"selected_sticker_id\":null,\"selected_shot_id\":\"sh_f1ea7f5a\"}}",
+ "match_source_density",
+ 1,
+ 1,
+ null,
+ ""
+ ]
+ },
+ {
+ "id": 103,
+ "type": "PreviewImage",
+ "pos": [
+ 518.6451219952478,
+ 873.2224984864196
+ ],
+ "size": [
+ 333.5999999999999,
+ 331.9100000000002
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "images",
+ "name": "images",
+ "type": "IMAGE",
+ "link": 195
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.15.1",
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "input_ue_unconnectable": {}
+ },
+ "Node name for S&R": "PreviewImage"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 102,
+ "type": "PreviewImage",
+ "pos": [
+ 181.35244611921536,
+ 843.6959924203037
+ ],
+ "size": [
+ 313.93507999999997,
+ 378.0618199999999
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "images",
+ "name": "images",
+ "type": "IMAGE",
+ "link": 192
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.15.1",
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "input_ue_unconnectable": {}
+ },
+ "Node name for S&R": "PreviewImage"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 9,
+ "type": "SaveImage",
+ "pos": [
+ -180.530010154013,
+ 1515.1289652495823
+ ],
+ "size": [
+ 375,
+ 405
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "images",
+ "name": "images",
+ "type": "IMAGE",
+ "link": 198
+ },
+ {
+ "label": "filename_prefix",
+ "name": "filename_prefix",
+ "type": "STRING",
+ "widget": {
+ "name": "filename_prefix"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "SaveImage",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "Flux2-Klein"
+ ]
+ },
+ {
+ "id": 101,
+ "type": "TY360Pasteback",
+ "pos": [
+ -88.3057586978655,
+ 1333.7707483044044
+ ],
+ "size": [
+ 189.80859375,
+ 66
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "erp_image",
+ "name": "erp_image",
+ "type": "IMAGE",
+ "link": 196
+ },
+ {
+ "label": "rect_image",
+ "name": "rect_image",
+ "type": "IMAGE",
+ "link": 194
+ },
+ {
+ "label": "roundtrip_state",
+ "name": "roundtrip_state",
+ "type": "STRING",
+ "link": 197
+ }
+ ],
+ "outputs": [
+ {
+ "label": "erp_image",
+ "name": "erp_image",
+ "type": "IMAGE",
+ "links": [
+ 189,
+ 198
+ ]
+ }
+ ],
+ "properties": {
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "input_ue_unconnectable": {}
+ },
+ "Node name for S&R": "TY360Pasteback"
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 75,
+ "type": "7b34ab90-36f9-45ba-a665-71d418f0df18",
+ "pos": [
+ -640.5532821011079,
+ 1481.09517424958
+ ],
+ "size": [
+ 400,
+ 416
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "unet_name",
+ "name": "unet_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "unet_name"
+ },
+ "link": null
+ },
+ {
+ "label": "clip_name",
+ "name": "clip_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "clip_name"
+ },
+ "link": null
+ },
+ {
+ "label": "vae_name",
+ "name": "vae_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "vae_name"
+ },
+ "link": null
+ },
+ {
+ "label": "text",
+ "name": "text",
+ "type": "STRING",
+ "widget": {
+ "name": "text"
+ },
+ "link": null
+ },
+ {
+ "label": "image",
+ "name": "image",
+ "type": "IMAGE",
+ "link": 199
+ }
+ ],
+ "outputs": [
+ {
+ "label": "IMAGE",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 194
+ ]
+ }
+ ],
+ "properties": {
+ "proxyWidgets": [
+ [
+ "-1",
+ "text"
+ ],
+ [
+ "-1",
+ "unet_name"
+ ],
+ [
+ "-1",
+ "clip_name"
+ ],
+ [
+ "-1",
+ "vae_name"
+ ],
+ [
+ "73",
+ "noise_seed"
+ ],
+ [
+ "73",
+ "control_after_generate"
+ ],
+ [
+ "-1",
+ "$$canvas-image-preview"
+ ]
+ ],
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {
+ "text": true
+ },
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "Add a 100-inch TV on the TV cabinet",
+ "flux-2-klein-9b.safetensors",
+ "qwen_3_8b_fp8mixed.safetensors",
+ "flux2-vae.safetensors"
+ ]
+ },
+ {
+ "id": 99,
+ "type": "TY360Preview",
+ "pos": [
+ 249.04066830213392,
+ 1457.0552283044049
+ ],
+ "size": [
+ 594.78,
+ 528.8000000000002
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "erp_image",
+ "name": "erp_image",
+ "type": "IMAGE",
+ "link": 189
+ }
+ ],
+ "outputs": [],
+ "properties": {
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "input_ue_unconnectable": {}
+ },
+ "Node name for S&R": "TY360Preview"
+ },
+ "widgets_values": []
+ }
+ ],
+ "links": [
+ [
+ 189,
+ 101,
+ 0,
+ 99,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 190,
+ 76,
+ 0,
+ 100,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 192,
+ 100,
+ 0,
+ 102,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 194,
+ 75,
+ 0,
+ 101,
+ 1,
+ "IMAGE"
+ ],
+ [
+ 195,
+ 100,
+ 1,
+ 103,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 196,
+ 100,
+ 0,
+ 101,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 197,
+ 100,
+ 2,
+ 101,
+ 2,
+ "STRING"
+ ],
+ [
+ 198,
+ 101,
+ 0,
+ 9,
+ 0,
+ "IMAGE"
+ ],
+ [
+ 199,
+ 100,
+ 1,
+ 75,
+ 4,
+ "IMAGE"
+ ]
+ ],
+ "groups": [],
+ "definitions": {
+ "subgraphs": [
+ {
+ "id": "7b34ab90-36f9-45ba-a665-71d418f0df18",
+ "version": 1,
+ "state": {
+ "lastGroupId": 4,
+ "lastNodeId": 103,
+ "lastLinkId": 199,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "Image Edit (Flux.2 Klein 9B Distilled)",
+ "inputNode": {
+ "id": -10,
+ "bounding": [
+ -576.3333463986639,
+ 559.0277780034634,
+ 120,
+ 140
+ ]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [
+ 1373.6666536013363,
+ 549.0277780034634,
+ 120,
+ 60
+ ]
+ },
+ "inputs": [
+ {
+ "id": "ae713dbf-52cb-471a-807d-b12fa9acbb48",
+ "name": "unet_name",
+ "type": "COMBO",
+ "linkIds": [
+ 159
+ ],
+ "pos": [
+ -476.33334639866393,
+ 579.0277780034634
+ ]
+ },
+ {
+ "id": "f29f705e-e43a-4502-b231-a1eea93a1d69",
+ "name": "clip_name",
+ "type": "COMBO",
+ "linkIds": [
+ 160
+ ],
+ "pos": [
+ -476.33334639866393,
+ 599.0277780034634
+ ]
+ },
+ {
+ "id": "a954f490-386b-4367-9ea1-1b65913963a5",
+ "name": "vae_name",
+ "type": "COMBO",
+ "linkIds": [
+ 161
+ ],
+ "pos": [
+ -476.33334639866393,
+ 619.0277780034634
+ ]
+ },
+ {
+ "id": "7061147a-fb75-450d-8e97-c8be594a8e16",
+ "name": "text",
+ "type": "STRING",
+ "linkIds": [
+ 162
+ ],
+ "label": "prompt",
+ "pos": [
+ -476.33334639866393,
+ 639.0277780034634
+ ]
+ },
+ {
+ "id": "68629112-b7b0-41ce-8912-23adad00d3db",
+ "name": "image",
+ "type": "IMAGE",
+ "linkIds": [
+ 175
+ ],
+ "pos": [
+ -476.33334639866393,
+ 659.0277780034634
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "c5e7966d-07ed-4c9a-ad89-9d378a41ea7b",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "linkIds": [
+ 153
+ ],
+ "localized_name": "IMAGE",
+ "pos": [
+ 1393.6666536013363,
+ 569.0277780034634
+ ]
+ }
+ ],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 61,
+ "type": "KSamplerSelect",
+ "pos": [
+ 539.6666316494369,
+ 444.0278305652596
+ ],
+ "size": [
+ 270,
+ 58
+ ],
+ "flags": {},
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "sampler_name",
+ "localized_name": "采样器名称",
+ "name": "sampler_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "sampler_name"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "SAMPLER",
+ "localized_name": "采样器",
+ "name": "SAMPLER",
+ "type": "SAMPLER",
+ "links": [
+ 144
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "KSamplerSelect",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "euler"
+ ]
+ },
+ {
+ "id": 64,
+ "type": "SamplerCustomAdvanced",
+ "pos": [
+ 839.6667053615732,
+ 224.0277832440098
+ ],
+ "size": [
+ 212.3638671875,
+ 106
+ ],
+ "flags": {},
+ "order": 4,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "noise",
+ "localized_name": "噪波",
+ "name": "noise",
+ "type": "NOISE",
+ "link": 142
+ },
+ {
+ "label": "guider",
+ "localized_name": "引导器",
+ "name": "guider",
+ "type": "GUIDER",
+ "link": 143
+ },
+ {
+ "label": "sampler",
+ "localized_name": "采样器",
+ "name": "sampler",
+ "type": "SAMPLER",
+ "link": 144
+ },
+ {
+ "label": "sigmas",
+ "localized_name": "西格玛",
+ "name": "sigmas",
+ "type": "SIGMAS",
+ "link": 145
+ },
+ {
+ "label": "latent_image",
+ "localized_name": "Latent图像",
+ "name": "latent_image",
+ "type": "LATENT",
+ "link": 146
+ }
+ ],
+ "outputs": [
+ {
+ "label": "output",
+ "localized_name": "Latent",
+ "name": "output",
+ "type": "LATENT",
+ "links": [
+ 147
+ ]
+ },
+ {
+ "label": "denoised_output",
+ "localized_name": "降噪Latent",
+ "name": "denoised_output",
+ "type": "LATENT",
+ "links": []
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "SamplerCustomAdvanced",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 65,
+ "type": "VAEDecode",
+ "pos": [
+ 1093.6666007601261,
+ 154.02777277882814
+ ],
+ "size": [
+ 220,
+ 46
+ ],
+ "flags": {},
+ "order": 5,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "samples",
+ "localized_name": "Latent",
+ "name": "samples",
+ "type": "LATENT",
+ "link": 147
+ },
+ {
+ "label": "vae",
+ "localized_name": "vae",
+ "name": "vae",
+ "type": "VAE",
+ "link": 148
+ }
+ ],
+ "outputs": [
+ {
+ "label": "IMAGE",
+ "localized_name": "图像",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "slot_index": 0,
+ "links": [
+ 153
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "VAEDecode",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 73,
+ "type": "RandomNoise",
+ "pos": [
+ 539.6666316494369,
+ 184.0277700485666
+ ],
+ "size": [
+ 270,
+ 82
+ ],
+ "flags": {},
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "noise_seed",
+ "localized_name": "噪波随机种",
+ "name": "noise_seed",
+ "type": "INT",
+ "widget": {
+ "name": "noise_seed"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "NOISE",
+ "localized_name": "噪波",
+ "name": "NOISE",
+ "type": "NOISE",
+ "links": [
+ 142
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "RandomNoise",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ 326845392422588,
+ "randomize"
+ ]
+ },
+ {
+ "id": 70,
+ "type": "UNETLoader",
+ "pos": [
+ -386.3333318901398,
+ 203.8611174586574
+ ],
+ "size": [
+ 364.42708333333337,
+ 82
+ ],
+ "flags": {},
+ "order": 7,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "unet_name",
+ "localized_name": "UNet名称",
+ "name": "unet_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "unet_name"
+ },
+ "link": 159
+ },
+ {
+ "label": "weight_dtype",
+ "localized_name": "数据类型",
+ "name": "weight_dtype",
+ "type": "COMBO",
+ "widget": {
+ "name": "weight_dtype"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "MODEL",
+ "localized_name": "模型",
+ "name": "MODEL",
+ "type": "MODEL",
+ "links": [
+ 139
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "UNETLoader",
+ "models": [
+ {
+ "name": "flux-2-klein-9b-fp8.safetensors",
+ "url": "https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors",
+ "directory": "diffusion_models"
+ }
+ ],
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "flux-2-klein-9b.safetensors",
+ "default"
+ ]
+ },
+ {
+ "id": 71,
+ "type": "CLIPLoader",
+ "pos": [
+ -386.3333318901398,
+ 353.8611341117752
+ ],
+ "size": [
+ 364.42708333333337,
+ 106
+ ],
+ "flags": {},
+ "order": 8,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "clip_name",
+ "localized_name": "CLIP名称",
+ "name": "clip_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "clip_name"
+ },
+ "link": 160
+ },
+ {
+ "label": "type",
+ "localized_name": "类型",
+ "name": "type",
+ "type": "COMBO",
+ "widget": {
+ "name": "type"
+ },
+ "link": null
+ },
+ {
+ "label": "device",
+ "localized_name": "设备",
+ "name": "device",
+ "shape": 7,
+ "type": "COMBO",
+ "widget": {
+ "name": "device"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CLIP",
+ "localized_name": "CLIP",
+ "name": "CLIP",
+ "type": "CLIP",
+ "links": [
+ 151
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "CLIPLoader",
+ "models": [
+ {
+ "name": "qwen_3_8b_fp8mixed.safetensors",
+ "url": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors",
+ "directory": "text_encoders"
+ }
+ ],
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "qwen_3_8b_fp8mixed.safetensors",
+ "flux2",
+ "default"
+ ]
+ },
+ {
+ "id": 72,
+ "type": "VAELoader",
+ "pos": [
+ -386.3333318901398,
+ 523.8611624133522
+ ],
+ "size": [
+ 364.42708333333337,
+ 58
+ ],
+ "flags": {},
+ "order": 9,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "vae_name",
+ "localized_name": "vae名称",
+ "name": "vae_name",
+ "type": "COMBO",
+ "widget": {
+ "name": "vae_name"
+ },
+ "link": 161
+ }
+ ],
+ "outputs": [
+ {
+ "label": "VAE",
+ "localized_name": "VAE",
+ "name": "VAE",
+ "type": "VAE",
+ "links": [
+ 148,
+ 176
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "VAELoader",
+ "models": [
+ {
+ "name": "flux2-vae.safetensors",
+ "url": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors",
+ "directory": "vae"
+ }
+ ],
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "flux2-vae.safetensors"
+ ]
+ },
+ {
+ "id": 66,
+ "type": "EmptyFlux2LatentImage",
+ "pos": [
+ 548.3333740740953,
+ 725.1944715871892
+ ],
+ "size": [
+ 270,
+ 106
+ ],
+ "flags": {},
+ "order": 6,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "width",
+ "localized_name": "width",
+ "name": "width",
+ "type": "INT",
+ "widget": {
+ "name": "width"
+ },
+ "link": 172
+ },
+ {
+ "label": "height",
+ "localized_name": "height",
+ "name": "height",
+ "type": "INT",
+ "widget": {
+ "name": "height"
+ },
+ "link": 174
+ },
+ {
+ "label": "batch_size",
+ "localized_name": "batch_size",
+ "name": "batch_size",
+ "type": "INT",
+ "widget": {
+ "name": "batch_size"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "LATENT",
+ "localized_name": "Latent",
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 146
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "EmptyFlux2LatentImage",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ 1024,
+ 1024,
+ 1
+ ]
+ },
+ {
+ "id": 80,
+ "type": "ImageScaleToTotalPixels",
+ "pos": [
+ -391.6666683297289,
+ 715.194415255584
+ ],
+ "size": [
+ 270,
+ 106
+ ],
+ "flags": {},
+ "order": 12,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "image",
+ "localized_name": "图像",
+ "name": "image",
+ "type": "IMAGE",
+ "link": 175
+ },
+ {
+ "label": "upscale_method",
+ "localized_name": "缩放算法",
+ "name": "upscale_method",
+ "type": "COMBO",
+ "widget": {
+ "name": "upscale_method"
+ },
+ "link": null
+ },
+ {
+ "label": "megapixels",
+ "localized_name": "像素数量",
+ "name": "megapixels",
+ "type": "FLOAT",
+ "widget": {
+ "name": "megapixels"
+ },
+ "link": null
+ },
+ {
+ "label": "resolution_steps",
+ "localized_name": "分辨率步数",
+ "name": "resolution_steps",
+ "type": "INT",
+ "widget": {
+ "name": "resolution_steps"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "IMAGE",
+ "localized_name": "图像",
+ "name": "IMAGE",
+ "type": "IMAGE",
+ "links": [
+ 169,
+ 170
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "ImageScaleToTotalPixels",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "nearest-exact",
+ 1,
+ 1
+ ]
+ },
+ {
+ "id": 63,
+ "type": "CFGGuider",
+ "pos": [
+ 539.6666316494369,
+ 304.0277894319459
+ ],
+ "size": [
+ 270,
+ 98
+ ],
+ "flags": {},
+ "order": 3,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "model",
+ "localized_name": "模型",
+ "name": "model",
+ "type": "MODEL",
+ "link": 139
+ },
+ {
+ "label": "positive",
+ "localized_name": "正面条件",
+ "name": "positive",
+ "type": "CONDITIONING",
+ "link": 167
+ },
+ {
+ "label": "negative",
+ "localized_name": "负面条件",
+ "name": "negative",
+ "type": "CONDITIONING",
+ "link": 168
+ },
+ {
+ "label": "cfg",
+ "localized_name": "CFG",
+ "name": "cfg",
+ "type": "FLOAT",
+ "widget": {
+ "name": "cfg"
+ },
+ "link": null
+ }
+ ],
+ "outputs": [
+ {
+ "label": "GUIDER",
+ "localized_name": "引导器",
+ "name": "GUIDER",
+ "type": "GUIDER",
+ "links": [
+ 143
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "CFGGuider",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ 1
+ ]
+ },
+ {
+ "id": 62,
+ "type": "Flux2Scheduler",
+ "pos": [
+ 539.6666316494369,
+ 544.0277473869036
+ ],
+ "size": [
+ 270,
+ 106
+ ],
+ "flags": {},
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "steps",
+ "localized_name": "步数",
+ "name": "steps",
+ "type": "INT",
+ "widget": {
+ "name": "steps"
+ },
+ "link": null
+ },
+ {
+ "label": "width",
+ "localized_name": "宽度",
+ "name": "width",
+ "type": "INT",
+ "widget": {
+ "name": "width"
+ },
+ "link": 171
+ },
+ {
+ "label": "height",
+ "localized_name": "高度",
+ "name": "height",
+ "type": "INT",
+ "widget": {
+ "name": "height"
+ },
+ "link": 173
+ }
+ ],
+ "outputs": [
+ {
+ "label": "SIGMAS",
+ "localized_name": "Sigmas",
+ "name": "SIGMAS",
+ "type": "SIGMAS",
+ "links": [
+ 145
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "Flux2Scheduler",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ 4,
+ 1024,
+ 1024
+ ]
+ },
+ {
+ "id": 74,
+ "type": "CLIPTextEncode",
+ "pos": [
+ 43.666666014853874,
+ 204.02777159555063
+ ],
+ "size": [
+ 450,
+ 380
+ ],
+ "flags": {},
+ "order": 10,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "clip",
+ "localized_name": "clip",
+ "name": "clip",
+ "type": "CLIP",
+ "link": 151
+ },
+ {
+ "label": "text",
+ "localized_name": "文本",
+ "name": "text",
+ "type": "STRING",
+ "widget": {
+ "name": "text"
+ },
+ "link": 162
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CONDITIONING",
+ "localized_name": "条件",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "slot_index": 0,
+ "links": [
+ 165,
+ 177
+ ]
+ }
+ ],
+ "title": "CLIP Text Encode (Positive Prompt)",
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "CLIPTextEncode",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": [
+ "Add a 100-inch TV on the TV cabinet"
+ ],
+ "color": "#232",
+ "bgcolor": "#353"
+ },
+ {
+ "id": 82,
+ "type": "ConditioningZeroOut",
+ "pos": [
+ 280,
+ 630
+ ],
+ "size": [
+ 204.134765625,
+ 26
+ ],
+ "flags": {},
+ "order": 14,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "conditioning",
+ "localized_name": "条件",
+ "name": "conditioning",
+ "type": "CONDITIONING",
+ "link": 177
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CONDITIONING",
+ "localized_name": "条件",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 178
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.9.1",
+ "Node name for S&R": "ConditioningZeroOut",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 81,
+ "type": "GetImageSize",
+ "pos": [
+ 310,
+ 720
+ ],
+ "size": [
+ 187.5,
+ 66
+ ],
+ "flags": {},
+ "order": 13,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "image",
+ "localized_name": "图像",
+ "name": "image",
+ "type": "IMAGE",
+ "link": 170
+ }
+ ],
+ "outputs": [
+ {
+ "label": "width",
+ "localized_name": "宽度",
+ "name": "width",
+ "type": "INT",
+ "links": [
+ 171,
+ 172
+ ]
+ },
+ {
+ "label": "height",
+ "localized_name": "高度",
+ "name": "height",
+ "type": "INT",
+ "links": [
+ 173,
+ 174
+ ]
+ },
+ {
+ "label": "batch_size",
+ "localized_name": "批处理大小",
+ "name": "batch_size",
+ "type": "INT",
+ "links": null
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "GetImageSize",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 79,
+ "type": "6007e698-2ebd-4917-84d8-299b35d7b7ab",
+ "pos": [
+ 260,
+ 840
+ ],
+ "size": [
+ 240,
+ 86
+ ],
+ "flags": {},
+ "order": 11,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "conditioning",
+ "name": "conditioning",
+ "type": "CONDITIONING",
+ "link": 165
+ },
+ {
+ "label": "conditioning_1",
+ "name": "conditioning_1",
+ "type": "CONDITIONING",
+ "link": 178
+ },
+ {
+ "label": "pixels",
+ "name": "pixels",
+ "type": "IMAGE",
+ "link": 169
+ },
+ {
+ "label": "vae",
+ "name": "vae",
+ "type": "VAE",
+ "link": 176
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CONDITIONING",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 167
+ ]
+ },
+ {
+ "label": "CONDITIONING_1",
+ "name": "CONDITIONING_1",
+ "type": "CONDITIONING",
+ "links": [
+ 168
+ ]
+ }
+ ],
+ "properties": {
+ "proxyWidgets": [],
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ }
+ ],
+ "groups": [
+ {
+ "id": 1,
+ "title": "Models",
+ "bounding": [
+ -390,
+ 120,
+ 380,
+ 550
+ ],
+ "color": "#3f789e",
+ "font_size": 24,
+ "flags": {}
+ },
+ {
+ "id": 2,
+ "title": "Prompt",
+ "bounding": [
+ 30,
+ 120,
+ 470,
+ 550
+ ],
+ "color": "#3f789e",
+ "font_size": 24,
+ "flags": {}
+ },
+ {
+ "id": 3,
+ "title": "Sampler",
+ "bounding": [
+ 540,
+ 120,
+ 532.3638671875,
+ 550
+ ],
+ "color": "#3f789e",
+ "font_size": 24,
+ "flags": {}
+ }
+ ],
+ "links": [
+ {
+ "id": 139,
+ "origin_id": 70,
+ "origin_slot": 0,
+ "target_id": 63,
+ "target_slot": 0,
+ "type": "MODEL"
+ },
+ {
+ "id": 142,
+ "origin_id": 73,
+ "origin_slot": 0,
+ "target_id": 64,
+ "target_slot": 0,
+ "type": "NOISE"
+ },
+ {
+ "id": 143,
+ "origin_id": 63,
+ "origin_slot": 0,
+ "target_id": 64,
+ "target_slot": 1,
+ "type": "GUIDER"
+ },
+ {
+ "id": 144,
+ "origin_id": 61,
+ "origin_slot": 0,
+ "target_id": 64,
+ "target_slot": 2,
+ "type": "SAMPLER"
+ },
+ {
+ "id": 145,
+ "origin_id": 62,
+ "origin_slot": 0,
+ "target_id": 64,
+ "target_slot": 3,
+ "type": "SIGMAS"
+ },
+ {
+ "id": 146,
+ "origin_id": 66,
+ "origin_slot": 0,
+ "target_id": 64,
+ "target_slot": 4,
+ "type": "LATENT"
+ },
+ {
+ "id": 147,
+ "origin_id": 64,
+ "origin_slot": 0,
+ "target_id": 65,
+ "target_slot": 0,
+ "type": "LATENT"
+ },
+ {
+ "id": 148,
+ "origin_id": 72,
+ "origin_slot": 0,
+ "target_id": 65,
+ "target_slot": 1,
+ "type": "VAE"
+ },
+ {
+ "id": 151,
+ "origin_id": 71,
+ "origin_slot": 0,
+ "target_id": 74,
+ "target_slot": 0,
+ "type": "CLIP"
+ },
+ {
+ "id": 153,
+ "origin_id": 65,
+ "origin_slot": 0,
+ "target_id": -20,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 159,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 70,
+ "target_slot": 0,
+ "type": "COMBO"
+ },
+ {
+ "id": 160,
+ "origin_id": -10,
+ "origin_slot": 1,
+ "target_id": 71,
+ "target_slot": 0,
+ "type": "COMBO"
+ },
+ {
+ "id": 161,
+ "origin_id": -10,
+ "origin_slot": 2,
+ "target_id": 72,
+ "target_slot": 0,
+ "type": "COMBO"
+ },
+ {
+ "id": 162,
+ "origin_id": -10,
+ "origin_slot": 3,
+ "target_id": 74,
+ "target_slot": 1,
+ "type": "STRING"
+ },
+ {
+ "id": 165,
+ "origin_id": 74,
+ "origin_slot": 0,
+ "target_id": 79,
+ "target_slot": 0,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 167,
+ "origin_id": 79,
+ "origin_slot": 0,
+ "target_id": 63,
+ "target_slot": 1,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 168,
+ "origin_id": 79,
+ "origin_slot": 1,
+ "target_id": 63,
+ "target_slot": 2,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 169,
+ "origin_id": 80,
+ "origin_slot": 0,
+ "target_id": 79,
+ "target_slot": 2,
+ "type": "IMAGE"
+ },
+ {
+ "id": 170,
+ "origin_id": 80,
+ "origin_slot": 0,
+ "target_id": 81,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 171,
+ "origin_id": 81,
+ "origin_slot": 0,
+ "target_id": 62,
+ "target_slot": 1,
+ "type": "INT"
+ },
+ {
+ "id": 172,
+ "origin_id": 81,
+ "origin_slot": 0,
+ "target_id": 66,
+ "target_slot": 0,
+ "type": "INT"
+ },
+ {
+ "id": 173,
+ "origin_id": 81,
+ "origin_slot": 1,
+ "target_id": 62,
+ "target_slot": 2,
+ "type": "INT"
+ },
+ {
+ "id": 174,
+ "origin_id": 81,
+ "origin_slot": 1,
+ "target_id": 66,
+ "target_slot": 1,
+ "type": "INT"
+ },
+ {
+ "id": 175,
+ "origin_id": -10,
+ "origin_slot": 4,
+ "target_id": 80,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 176,
+ "origin_id": 72,
+ "origin_slot": 0,
+ "target_id": 79,
+ "target_slot": 3,
+ "type": "VAE"
+ },
+ {
+ "id": 177,
+ "origin_id": 74,
+ "origin_slot": 0,
+ "target_id": 82,
+ "target_slot": 0,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 178,
+ "origin_id": 82,
+ "origin_slot": 0,
+ "target_id": 79,
+ "target_slot": 1,
+ "type": "CONDITIONING"
+ }
+ ],
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ue_links": [],
+ "links_added_by_ue": []
+ }
+ },
+ {
+ "id": "6007e698-2ebd-4917-84d8-299b35d7b7ab",
+ "version": 1,
+ "state": {
+ "lastGroupId": 4,
+ "lastNodeId": 103,
+ "lastLinkId": 199,
+ "lastRerouteId": 0
+ },
+ "revision": 0,
+ "config": {},
+ "name": "Reference Conditioning",
+ "inputNode": {
+ "id": -10,
+ "bounding": [
+ -270,
+ 990,
+ 120,
+ 120
+ ]
+ },
+ "outputNode": {
+ "id": -20,
+ "bounding": [
+ 580,
+ 970,
+ 120,
+ 80
+ ]
+ },
+ "inputs": [
+ {
+ "id": "5c9a0f5e-8cee-4947-90bc-330de782043a",
+ "name": "conditioning",
+ "type": "CONDITIONING",
+ "linkIds": [
+ 165
+ ],
+ "label": "positive",
+ "pos": [
+ -170,
+ 1010
+ ]
+ },
+ {
+ "id": "61826d46-4c21-4ad6-801c-3e3fa94115e2",
+ "name": "conditioning_1",
+ "type": "CONDITIONING",
+ "linkIds": [
+ 166
+ ],
+ "label": "negative",
+ "pos": [
+ -170,
+ 1030
+ ]
+ },
+ {
+ "id": "345bf085-5939-47ff-9767-8f8f239a719c",
+ "name": "pixels",
+ "type": "IMAGE",
+ "linkIds": [
+ 167
+ ],
+ "pos": [
+ -170,
+ 1050
+ ]
+ },
+ {
+ "id": "f4594e34-e2f5-4f1e-b1fa-a1dc2aeb0a90",
+ "name": "vae",
+ "type": "VAE",
+ "linkIds": [
+ 168
+ ],
+ "pos": [
+ -170,
+ 1070
+ ]
+ }
+ ],
+ "outputs": [
+ {
+ "id": "b3357c0e-6428-4055-9cd3-3595f0896fa8",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "linkIds": [
+ 169
+ ],
+ "label": "positive",
+ "pos": [
+ 600,
+ 990
+ ]
+ },
+ {
+ "id": "01519713-2ed1-4694-a387-79f44e088e89",
+ "name": "CONDITIONING_1",
+ "type": "CONDITIONING",
+ "linkIds": [
+ 170
+ ],
+ "label": "negative",
+ "pos": [
+ 600,
+ 1010
+ ]
+ }
+ ],
+ "widgets": [],
+ "nodes": [
+ {
+ "id": 76,
+ "type": "ReferenceLatent",
+ "pos": [
+ 170,
+ 1050
+ ],
+ "size": [
+ 204.134765625,
+ 46
+ ],
+ "flags": {
+ "collapsed": false
+ },
+ "order": 0,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "conditioning",
+ "localized_name": "条件",
+ "name": "conditioning",
+ "type": "CONDITIONING",
+ "link": 166
+ },
+ {
+ "label": "latent",
+ "localized_name": "Latent",
+ "name": "latent",
+ "shape": 7,
+ "type": "LATENT",
+ "link": 163
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CONDITIONING",
+ "localized_name": "条件",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 170
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "ReferenceLatent",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 78,
+ "type": "VAEEncode",
+ "pos": [
+ -90,
+ 1150
+ ],
+ "size": [
+ 190,
+ 46
+ ],
+ "flags": {
+ "collapsed": false
+ },
+ "order": 2,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "pixels",
+ "localized_name": "像素",
+ "name": "pixels",
+ "type": "IMAGE",
+ "link": 167
+ },
+ {
+ "label": "vae",
+ "localized_name": "vae",
+ "name": "vae",
+ "type": "VAE",
+ "link": 168
+ }
+ ],
+ "outputs": [
+ {
+ "label": "LATENT",
+ "localized_name": "Latent",
+ "name": "LATENT",
+ "type": "LATENT",
+ "links": [
+ 163,
+ 164
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "VAEEncode",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ },
+ {
+ "id": 77,
+ "type": "ReferenceLatent",
+ "pos": [
+ 170,
+ 940
+ ],
+ "size": [
+ 210,
+ 46
+ ],
+ "flags": {
+ "collapsed": false
+ },
+ "order": 1,
+ "mode": 0,
+ "inputs": [
+ {
+ "label": "conditioning",
+ "localized_name": "条件",
+ "name": "conditioning",
+ "type": "CONDITIONING",
+ "link": 165
+ },
+ {
+ "label": "latent",
+ "localized_name": "Latent",
+ "name": "latent",
+ "shape": 7,
+ "type": "LATENT",
+ "link": 164
+ }
+ ],
+ "outputs": [
+ {
+ "label": "CONDITIONING",
+ "localized_name": "条件",
+ "name": "CONDITIONING",
+ "type": "CONDITIONING",
+ "links": [
+ 169
+ ]
+ }
+ ],
+ "properties": {
+ "cnr_id": "comfy-core",
+ "ver": "0.8.2",
+ "Node name for S&R": "ReferenceLatent",
+ "enableTabs": false,
+ "tabWidth": 65,
+ "tabXOffset": 10,
+ "hasSecondTab": false,
+ "secondTabText": "Send Back",
+ "secondTabOffset": 80,
+ "secondTabWidth": 65,
+ "ue_properties": {
+ "widget_ue_connectable": {},
+ "version": "7.7",
+ "input_ue_unconnectable": {}
+ }
+ },
+ "widgets_values": []
+ }
+ ],
+ "groups": [],
+ "links": [
+ {
+ "id": 163,
+ "origin_id": 78,
+ "origin_slot": 0,
+ "target_id": 76,
+ "target_slot": 1,
+ "type": "LATENT"
+ },
+ {
+ "id": 164,
+ "origin_id": 78,
+ "origin_slot": 0,
+ "target_id": 77,
+ "target_slot": 1,
+ "type": "LATENT"
+ },
+ {
+ "id": 165,
+ "origin_id": -10,
+ "origin_slot": 0,
+ "target_id": 77,
+ "target_slot": 0,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 166,
+ "origin_id": -10,
+ "origin_slot": 1,
+ "target_id": 76,
+ "target_slot": 0,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 167,
+ "origin_id": -10,
+ "origin_slot": 2,
+ "target_id": 78,
+ "target_slot": 0,
+ "type": "IMAGE"
+ },
+ {
+ "id": 168,
+ "origin_id": -10,
+ "origin_slot": 3,
+ "target_id": 78,
+ "target_slot": 1,
+ "type": "VAE"
+ },
+ {
+ "id": 169,
+ "origin_id": 77,
+ "origin_slot": 0,
+ "target_id": -20,
+ "target_slot": 0,
+ "type": "CONDITIONING"
+ },
+ {
+ "id": 170,
+ "origin_id": 76,
+ "origin_slot": 0,
+ "target_id": -20,
+ "target_slot": 1,
+ "type": "CONDITIONING"
+ }
+ ],
+ "extra": {
+ "workflowRendererVersion": "LG",
+ "ue_links": [],
+ "links_added_by_ue": []
+ }
+ }
+ ]
+ },
+ "config": {},
+ "extra": {
+ "ds": {
+ "scale": 0.5131581182307067,
+ "offset": [
+ 1793.6169706213052,
+ -471.0917473850951
+ ]
+ },
+ "frontendVersion": "1.39.19",
+ "workflowRendererVersion": "LG",
+ "VHS_latentpreview": false,
+ "VHS_latentpreviewrate": 0,
+ "VHS_MetadataImage": true,
+ "VHS_KeepIntermediate": true,
+ "ue_links": [],
+ "links_added_by_ue": []
+ },
+ "version": 0.4
+}
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..1cbe34e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,13 @@
+[project]
+name = "comfyui-ty360-photo-edit"
+description = "ComfyUI nodes for previewing, round-tripping, and pasteback editing 360 ERP photos."
+version = "0.1.0"
+license = {file = "LICENSE"}
+
+[tool.comfy]
+PublisherId = "ty360"
+DisplayName = "TY360 Photo Edit"
+Icon = ""
+includes = []
+# "requires-comfyui" = ">=1.0.0" # ComfyUI version compatibility
+
diff --git a/tests/test_math.py b/tests/test_math.py
new file mode 100644
index 0000000..cdb7518
--- /dev/null
+++ b/tests/test_math.py
@@ -0,0 +1,288 @@
+import numpy as np
+import pytest
+from comfyui_ty360_photo_edit.core.math import (
+ calculate_dimensions_from_erp_density,
+ calculate_output_dimensions,
+ calculate_dimensions_from_megapixels,
+ derive_rectilinear_aspect_ratio,
+ orthonormal_basis_from_forward,
+ round_to_multiple,
+ finite_float,
+ finite_int,
+ dir_to_lon_lat,
+ yaw_pitch_to_dir,
+)
+
+
+def is_orthonormal(right, up, forward, tol=1e-6):
+ # Check unit lengths
+ assert np.isclose(np.linalg.norm(right), 1.0, atol=tol)
+ assert np.isclose(np.linalg.norm(up), 1.0, atol=tol)
+ assert np.isclose(np.linalg.norm(forward), 1.0, atol=tol)
+
+ # Check orthogonality
+ assert np.isclose(np.dot(right, up), 0.0, atol=tol)
+ assert np.isclose(np.dot(right, forward), 0.0, atol=tol)
+ assert np.isclose(np.dot(up, forward), 0.0, atol=tol)
+
+ # Check right-handedness: right x up = forward
+ assert np.allclose(np.cross(right, up), forward, atol=tol)
+
+
+def test_orthonormal_basis_normal():
+ forward = np.array([1.0, 0.0, 0.0])
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+ is_orthonormal(right, up, f)
+
+
+def test_orthonormal_basis_singularity_up():
+ forward = np.array([0.0, 1.0, 0.0])
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+ is_orthonormal(right, up, f)
+
+
+def test_orthonormal_basis_singularity_down():
+ forward = np.array([0.0, -1.0, 0.0])
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+ is_orthonormal(right, up, f)
+
+
+def test_orthonormal_basis_near_singularity():
+ # Vector very close to [0, 1, 0]
+ # dot([0, 0.9995, 0.0316], [0, 1, 0]) = 0.9995 > 0.999
+ forward = np.array([0.0, 0.9995, 0.0316])
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+ is_orthonormal(right, up, f)
+
+
+def test_orthonormal_basis_zero_vector():
+ forward = np.array([0.0, 0.0, 0.0])
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+
+ # orthonormal_basis_from_forward yields zero vectors for zero input due to its
+ # epsilon-normalization logic (v / (norm(v) + 1e-8)).
+ # We assert this contract instead of calling is_orthonormal.
+ assert np.allclose(right, 0.0)
+ assert np.allclose(up, 0.0)
+ assert np.allclose(f, 0.0)
+
+
+def test_orthonormal_basis_random():
+ for _ in range(100):
+ forward = np.random.randn(3)
+ right, up, f = orthonormal_basis_from_forward(forward)
+ assert not np.isnan(right).any()
+ assert not np.isnan(up).any()
+ assert not np.isnan(f).any()
+ is_orthonormal(right, up, f)
+
+
+def test_round_to_multiple():
+ assert round_to_multiple(10, 8) == 8
+ assert round_to_multiple(12, 8) == 16
+ assert round_to_multiple(7, 8) == 8 # min_val
+ assert round_to_multiple(0, 8, min_val=0) == 0
+ assert round_to_multiple(100, 64) == 128
+
+
+def test_derive_rectilinear_aspect_ratio():
+ # 90 degrees horizontal and vertical should be 1.0 aspect ratio
+ assert np.isclose(derive_rectilinear_aspect_ratio(90, 90), 1.0)
+ # 90 degrees h, ~53.13 degrees v should be ~2.0 aspect ratio (tan(45)/tan(26.56))
+ # h_tan = tan(45) = 1.0
+ # v_tan = tan(26.565) = 0.5
+ # ratio = 2.0
+ assert np.isclose(derive_rectilinear_aspect_ratio(90, 53.13010235), 2.0, atol=1e-4)
+
+ # Extreme FOVs
+ assert derive_rectilinear_aspect_ratio(179, 1) > 10.0
+ assert derive_rectilinear_aspect_ratio(1, 179) < 0.1
+
+
+def test_calculate_output_dimensions():
+ # Square landscape
+ w, h = calculate_output_dimensions(90, 90, long_side=1024)
+ assert w == 1024
+ assert h == 1024
+
+ # Wide landscape (2:1)
+ w, h = calculate_output_dimensions(90, 53.1301, long_side=1024)
+ assert w == 1024
+ assert h == 512
+
+ # Tall portrait (1:2)
+ w, h = calculate_output_dimensions(53.1301, 90, long_side=1024)
+ assert w == 512
+ assert h == 1024
+
+ # Max side clipping
+ w, h = calculate_output_dimensions(90, 90, long_side=8192, max_side=4096)
+ assert w == 4096
+ assert h == 4096
+
+
+def test_round_to_multiple_guards():
+ with pytest.raises(ValueError, match="multiple must be > 0"):
+ round_to_multiple(10, 0)
+ with pytest.raises(ValueError, match="multiple must be > 0"):
+ round_to_multiple(10, -1)
+
+
+def test_calculate_output_dimensions_guards():
+ # max_side < 8 should be normalized to 8
+ w, h = calculate_output_dimensions(90, 90, long_side=1024, max_side=4)
+ assert w == 8
+ assert h == 8
+
+ # long_side < 8 should be clamped to 8
+ w, h = calculate_output_dimensions(90, 90, long_side=2, max_side=1024)
+ assert w == 8
+ assert h == 8
+
+
+def test_finite_float():
+ assert finite_float(1.0) == 1.0
+ assert finite_float("1.5") == 1.5
+ assert finite_float(float("inf")) == 0.0
+ assert finite_float(float("-inf")) == 0.0
+ assert finite_float(float("nan")) == 0.0
+ assert finite_float("invalid") == 0.0
+ assert finite_float("invalid", default=5.0) == 5.0
+ assert finite_float(float("inf"), default=5.0) == 5.0
+
+
+def test_finite_int():
+ assert finite_int(1) == 1
+ assert finite_int(1.5) == 1
+ assert finite_int("1") == 1
+ assert finite_int("1.5") == 1
+ assert finite_int("invalid") == 0
+ assert finite_int("invalid", default=5) == 5
+ assert finite_int(float("inf"), default=5) == 5
+ assert finite_int(float("nan"), default=5) == 5
+
+
+def test_dir_to_lon_lat_axes():
+ # Forward: yaw=0, pitch=0
+ d = np.array([0.0, 0.0, 1.0])
+ lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lon, 0.0)
+ assert np.isclose(lat, 0.0)
+
+ # Right: yaw=90, pitch=0
+ d = np.array([1.0, 0.0, 0.0])
+ lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lon, np.pi / 2)
+ assert np.isclose(lat, 0.0)
+
+ # Up: yaw=0, pitch=90
+ d = np.array([0.0, 1.0, 0.0])
+ lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lon, 0.0)
+ assert np.isclose(lat, np.pi / 2)
+
+ # Down: yaw=0, pitch=-90
+ d = np.array([0.0, -1.0, 0.0])
+ lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lon, 0.0)
+ assert np.isclose(lat, -np.pi / 2)
+
+
+def test_dir_to_lon_lat_roundtrip():
+ for yaw_deg in [-179, -90, 0, 90, 179]: # Avoid 180/-180 ambiguity
+ for pitch_deg in [-89, -45, 0, 45, 89]: # Avoid poles where lon is undefined
+ d = yaw_pitch_to_dir(yaw_deg, pitch_deg)
+ lon, lat = dir_to_lon_lat(d)
+
+ lon_deg = lon * 180.0 / np.pi
+ lat_deg = lat * 180.0 / np.pi
+
+ assert np.isclose(lat_deg, pitch_deg, atol=1e-5)
+ assert np.isclose(lon_deg, yaw_deg, atol=1e-5)
+
+
+def test_dir_to_lon_lat_clipping():
+ # y slightly outside [-1, 1]
+ d = np.array([0.0, 1.1, 0.0])
+ _lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lat, np.pi / 2)
+
+ d = np.array([0.0, -1.1, 0.0])
+ _lon, lat = dir_to_lon_lat(d)
+ assert np.isclose(lat, -np.pi / 2)
+
+
+def test_dir_to_lon_lat_vectorized():
+ ds = np.array([
+ [0.0, 0.0, 1.0],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ ])
+ lons, lats = dir_to_lon_lat(ds)
+ assert lons.shape == (3,)
+ assert lats.shape == (3,)
+ assert np.allclose(lons, [0.0, np.pi / 2, 0.0])
+ assert np.allclose(lats, [0.0, 0.0, np.pi / 2])
+
+
+def test_calculate_dimensions_from_megapixels():
+ # 1.0 MP, 90x90 FOV (1:1 aspect) -> sqrt(1,000,000) = 1000x1000
+ w, h = calculate_dimensions_from_megapixels(1.0, 90, 90, multiple_of=8)
+ assert w == 1000
+ assert h == 1000
+
+ # 1.0 MP, 90x53.13 FOV (2:1 aspect)
+ # H^2 = 1,000,000 / 2 = 500,000 -> H ~ 707.1
+ # W = H * 2 ~ 1414.2
+ # Round to multiple of 8
+ # 707.1 -> 704 or 712? 707.1 / 8 = 88.38 -> 88 * 8 = 704
+ # 1414.2 / 8 = 176.77 -> 177 * 8 = 1416
+ w, h = calculate_dimensions_from_megapixels(1.0, 90, 53.13010235, multiple_of=8)
+ # Allow small rounding diffs
+ assert 1400 < w < 1430
+ assert 690 < h < 720
+ assert w % 8 == 0
+ assert h % 8 == 0
+
+ # Max side clamping
+ # 100 MP -> 10000x10000 -> clamp to max_side
+ w, h = calculate_dimensions_from_megapixels(100.0, 90, 90, max_side=4096)
+ assert w == 4096
+ assert h == 4096
+
+
+def test_calculate_dimensions_from_erp_density():
+ w, h = calculate_dimensions_from_erp_density(15520, 7760, 90, 58.71550709)
+ assert 4936 <= w <= 4944
+ assert 2776 <= h <= 2784
+ assert w % 8 == 0
+ assert h % 8 == 0
+
+
+def test_calculate_dimensions_from_erp_density_clamps_to_source_axes():
+ w, h = calculate_dimensions_from_erp_density(
+ 2048,
+ 1024,
+ 170,
+ 120,
+ density_scale=2.0,
+ max_width=2048,
+ max_height=1024,
+ )
+ assert w == 2048
+ assert h == 1024
diff --git a/tests/test_nodes_preview.py b/tests/test_nodes_preview.py
new file mode 100644
index 0000000..67fb5ab
--- /dev/null
+++ b/tests/test_nodes_preview.py
@@ -0,0 +1,131 @@
+import sys
+import unittest
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+
+class TestNodesPreview(unittest.TestCase):
+ def setUp(self):
+ class _NodeOutput:
+ def __init__(self, *result, ui=None):
+ self.result = result
+ self.ui = ui or {}
+
+ class _Port:
+ def __init__(self, kind, name, optional=False, **kwargs):
+ self.kind = kind
+ self.name = name
+ self.optional = optional
+ self.kwargs = kwargs
+
+ class _PortFactory:
+ def __init__(self, kind):
+ self.kind = kind
+
+ def Input(self, name, optional=False, **kwargs):
+ return _Port(self.kind, name, optional=optional, **kwargs)
+
+ def Output(self, name, **kwargs):
+ return _Port(self.kind, name, optional=False, **kwargs)
+
+ class _Schema:
+ def __init__(self, **kwargs):
+ self.__dict__.update(kwargs)
+ self.inputs = list(kwargs.get("inputs", []))
+ self.outputs = list(kwargs.get("outputs", []))
+ self.hidden = list(kwargs.get("hidden", []))
+ self.is_output_node = bool(kwargs.get("is_output_node", False))
+
+ class _ComfyNode:
+ OUTPUT_NODE = False
+ RETURN_TYPES = ()
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ schema = cls.define_schema() if hasattr(cls, "define_schema") else None
+ cls._schema = schema
+ cls.OUTPUT_NODE = bool(getattr(schema, "is_output_node", False))
+ cls.RETURN_TYPES = tuple(getattr(port, "kind", "") for port in getattr(schema, "outputs", []))
+
+ def run(self, **kwargs):
+ out = self.execute(**kwargs)
+ if isinstance(out, _NodeOutput):
+ return {"ui": out.ui, "result": out.result}
+ return {"ui": {}, "result": out if isinstance(out, tuple) else (out,)}
+
+ io_module = SimpleNamespace(
+ ComfyNode=_ComfyNode,
+ Schema=_Schema,
+ NodeOutput=_NodeOutput,
+ Combo=_PortFactory("COMBO"),
+ String=_PortFactory("STRING"),
+ Image=_PortFactory("IMAGE"),
+ Float=_PortFactory("FLOAT"),
+ Int=_PortFactory("INT"),
+ Mask=_PortFactory("MASK"),
+ Hidden=SimpleNamespace(unique_id="UNIQUE_ID"),
+ )
+ comfy_api_module = ModuleType("comfy_api")
+ comfy_api_latest_module = ModuleType("comfy_api.latest")
+ comfy_api_latest_module.io = io_module
+
+ self.mock_torch = MagicMock()
+ self.mock_torch.from_numpy = MagicMock(return_value=MagicMock())
+ self.mock_nodes = MagicMock()
+ self.mock_preview_image = MagicMock()
+ self.mock_nodes.PreviewImage = MagicMock(return_value=self.mock_preview_image)
+ self.mock_preview_image.save_images.return_value = {
+ "ui": {"images": [{"filename": "preview.png", "type": "temp"}]}
+ }
+
+ modules_patch = {
+ "torch": self.mock_torch,
+ "torch.nn": MagicMock(),
+ "torch.nn.functional": MagicMock(),
+ "numpy": MagicMock(),
+ "PIL": MagicMock(),
+ "PIL.Image": MagicMock(),
+ "cv2": MagicMock(),
+ "nodes": self.mock_nodes,
+ "comfy_api": comfy_api_module,
+ "comfy_api.latest": comfy_api_latest_module,
+ }
+
+ self.patcher = patch.dict(sys.modules, modules_patch)
+ self.patcher.start()
+
+ import comfyui_ty360_photo_edit.nodes
+
+ self.nodes_module = comfyui_ty360_photo_edit.nodes
+
+ def tearDown(self):
+ self.patcher.stop()
+
+ def test_preview_node_saves_preview(self):
+ node = self.nodes_module.TY360PreviewNode()
+ dummy_erp = MagicMock()
+
+ res = node.run(erp_image=dummy_erp)
+
+ self.mock_preview_image.save_images.assert_called_once()
+ self.assertIn("pano_input_images", res["ui"])
+
+ def test_node_mappings_and_roundtrip_output_order(self):
+ self.assertIn("TY360Preview", self.nodes_module.NODE_CLASS_MAPPINGS)
+ self.assertIn("TY360Roundtrip", self.nodes_module.NODE_CLASS_MAPPINGS)
+ self.assertIn("TY360Pasteback", self.nodes_module.NODE_CLASS_MAPPINGS)
+ outputs = [port.name for port in self.nodes_module.TY360RoundtripNode._schema.outputs]
+ self.assertEqual(outputs, ["erp_image", "rect_image", "roundtrip_state"])
+
+ def test_web_registration_strings_use_ty360_names(self):
+ repo_root = Path(__file__).resolve().parent.parent
+ editor_js = (repo_root / "web" / "pano_editor.js").read_text(encoding="utf-8")
+ preview_identity_js = (repo_root / "web" / "pano_preview_identity.js").read_text(encoding="utf-8")
+ self.assertIn('installEditorButton(nodeType, nodeData, "TY360Roundtrip", "Open Roundtrip Editor")', editor_js)
+ self.assertIn('modalTitle: "TY360 Preview"', editor_js)
+ self.assertIn('name === "TY360Preview" || name === "TY360 Preview"', preview_identity_js)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_sample_erp.py b/tests/test_sample_erp.py
new file mode 100644
index 0000000..3513260
--- /dev/null
+++ b/tests/test_sample_erp.py
@@ -0,0 +1,85 @@
+import numpy as np
+import pytest
+from comfyui_ty360_photo_edit.core.math import sample_erp_bilinear
+
+def test_sample_erp_bilinear_simple():
+ # 2x2 ERP image, 3 channels
+ erp = np.array([
+ [[1, 0, 0], [0, 1, 0]],
+ [[0, 0, 1], [1, 1, 1]]
+ ], dtype=np.float32)
+ # erp[0, 0] = red
+ # erp[0, 1] = green
+ # erp[1, 0] = blue
+ # erp[1, 1] = white
+
+ # Sample exactly at the centers
+ # u=0.5, v=0.5 should be erp[0, 0] if we used nearest, but bilinear with 2x2...
+ # Wait, floor(0.5)=0, floor(0.5)=0. fx=0.5, fy=0.5.
+ # It will interpolate between all 4 pixels.
+ u = np.array([[0.0, 1.0], [0.0, 1.0]], dtype=np.float32)
+ v = np.array([[0.0, 0.0], [1.0, 1.0]], dtype=np.float32)
+
+ out = sample_erp_bilinear(erp, u, v)
+
+ # At (0,0), it should be exactly erp[0,0]
+ assert np.allclose(out[0, 0], erp[0, 0])
+ # At (0,1), it should be exactly erp[0,1]
+ assert np.allclose(out[0, 1], erp[0, 1])
+ # At (1,0), it should be exactly erp[1,0]
+ assert np.allclose(out[1, 0], erp[1, 0])
+ # At (1,1), it should be exactly erp[1,1]
+ assert np.allclose(out[1, 1], erp[1, 1])
+
+def test_sample_erp_bilinear_interpolation():
+ erp = np.array([
+ [[0, 0, 0], [1, 1, 1]],
+ [[0, 0, 0], [1, 1, 1]]
+ ], dtype=np.float32)
+
+ # Sample at u=0.5 (halfway between 0 and 1)
+ u = np.array([[0.5]], dtype=np.float32)
+ v = np.array([[0.0]], dtype=np.float32)
+
+ out = sample_erp_bilinear(erp, u, v)
+ assert np.allclose(out[0, 0], [0.5, 0.5, 0.5])
+
+def test_sample_erp_bilinear_wrapping():
+ # 2x1 image (width=2, height=1)
+ erp = np.array([
+ [[1, 0, 0], [0, 1, 0]]
+ ], dtype=np.float32)
+
+ # Sample at u=1.5 (halfway between index 1 and wrapping back to index 0)
+ u = np.array([[1.5]], dtype=np.float32)
+ v = np.array([[0.0]], dtype=np.float32)
+
+ out = sample_erp_bilinear(erp, u, v)
+ # Should be average of [0, 1, 0] and [1, 0, 0] -> [0.5, 0.5, 0]
+ assert np.allclose(out[0, 0], [0.5, 0.5, 0.0])
+
+def test_sample_erp_bilinear_height_1():
+ # 2x1 image (width=2, height=1)
+ erp = np.array([
+ [[1, 0, 0], [0, 1, 0]]
+ ], dtype=np.float32)
+
+ # Sample at center
+ u = np.array([[0.0, 1.0]], dtype=np.float32)
+ v = np.array([[0.0, 0.0]], dtype=np.float32)
+
+ out = sample_erp_bilinear(erp, u, v)
+ assert out.shape == (1, 2, 3)
+ assert np.allclose(out[0, 0], [1, 0, 0])
+ assert np.allclose(out[0, 1], [0, 1, 0])
+
+if __name__ == "__main__":
+ # Manual run if pytest is missing
+ try:
+ test_sample_erp_bilinear_simple()
+ test_sample_erp_bilinear_interpolation()
+ test_sample_erp_bilinear_wrapping()
+ test_sample_erp_bilinear_height_1()
+ print("All manual tests passed!")
+ except Exception as e:
+ print(f"Tests failed: {e}")
diff --git a/tests/test_state.py b/tests/test_state.py
new file mode 100644
index 0000000..cd5b931
--- /dev/null
+++ b/tests/test_state.py
@@ -0,0 +1,87 @@
+from comfyui_ty360_photo_edit.core.state import DEFAULT_STATE, merge_state, parse_roundtrip_state, parse_sticker_state
+
+
+def test_merge_state_handles_none_inputs():
+ state = merge_state(None, None)
+
+ assert state["version"] == 1
+ assert state["projection_model"] == "pinhole_rectilinear"
+ assert state["alpha_mode"] == "straight"
+ assert state["output_preset"] == DEFAULT_STATE["output_preset"]
+ assert state["bg_color"] == DEFAULT_STATE["bg_color"]
+ assert isinstance(state["assets"], dict)
+ assert isinstance(state["stickers"], list)
+ assert isinstance(state["shots"], list)
+ assert isinstance(state["active"], dict)
+ assert state["active"]["selected_sticker_id"] is None
+ assert state["active"]["selected_shot_id"] is None
+
+
+def test_merge_state_handles_empty_dict_inputs_and_fallbacks():
+ state = merge_state("{}", "{}", fallback_preset=1024, fallback_bg="#112233")
+
+ assert state["output_preset"] == 1024
+ assert state["bg_color"] == "#112233"
+ assert state["assets"] == {}
+ assert state["stickers"] == []
+ assert state["shots"] == []
+ assert state["active"]["selected_sticker_id"] is None
+ assert state["active"]["selected_shot_id"] is None
+
+
+def test_merge_state_normalizes_invalid_container_types():
+ internal = '{"assets":[],"stickers":{},"shots":"bad","active":[]}'
+ state = merge_state(None, internal)
+
+ assert state["assets"] == {}
+ assert state["stickers"] == []
+ assert state["shots"] == []
+ assert state["active"]["selected_sticker_id"] is None
+ assert state["active"]["selected_shot_id"] is None
+
+
+def test_merge_state_fills_missing_active_keys():
+ state = merge_state(None, '{"active":{"selected_sticker_id":"st_1"}}')
+
+ assert state["active"]["selected_sticker_id"] == "st_1"
+ assert state["active"]["selected_shot_id"] is None
+
+
+def test_parse_sticker_state_reads_canonical_format():
+ parsed = parse_sticker_state(
+ '{"kind":"pano_sticker_state","version":1,"pose":{"yaw_deg":190,"pitch_deg":100,"roll_deg":15,"hFOV_deg":200},"source_aspect":1.5}'
+ )
+
+ assert parsed is not None
+ assert parsed["yaw_deg"] == -170.0
+ assert parsed["pitch_deg"] == 89.9
+ assert parsed["roll_deg"] == 15.0
+ assert parsed["hFOV_deg"] == 179.0
+ assert parsed["source_aspect"] == 1.5
+
+
+def test_parse_sticker_state_rejects_invalid_kind_or_version():
+ assert parse_sticker_state('{"kind":"wrong","version":1,"pose":{"yaw_deg":0,"pitch_deg":0,"roll_deg":0,"hFOV_deg":30}}') is None
+ assert parse_sticker_state('{"kind":"pano_sticker_state","version":2,"pose":{"yaw_deg":0,"pitch_deg":0,"roll_deg":0,"hFOV_deg":30}}') is None
+
+
+def test_parse_roundtrip_state_reads_canonical_format():
+ parsed = parse_roundtrip_state(
+ '{"kind":"ty360_roundtrip_state","version":1,"projection_model":"pinhole_rectilinear","source_erp":{"width":15520,"height":7760},"rect":{"width":4944,"height":2784},"pose":{"yaw_deg":190,"pitch_deg":100,"roll_deg":15,"hFOV_deg":200,"vFOV_deg":80},"source_aspect":1.7758620689655173}'
+ )
+
+ assert parsed is not None
+ assert parsed["yaw_deg"] == -170.0
+ assert parsed["pitch_deg"] == 89.9
+ assert parsed["roll_deg"] == 15.0
+ assert parsed["hFOV_deg"] == 179.0
+ assert parsed["vFOV_deg"] == 80.0
+ assert parsed["source_erp_w"] == 15520
+ assert parsed["source_erp_h"] == 7760
+ assert parsed["rect_w"] == 4944
+ assert parsed["rect_h"] == 2784
+
+
+def test_parse_roundtrip_state_rejects_invalid_payload():
+ assert parse_roundtrip_state('{"kind":"wrong","version":1}') is None
+ assert parse_roundtrip_state('{"kind":"ty360_roundtrip_state","version":2}') is None
diff --git a/web/pano_cutout_projection.js b/web/pano_cutout_projection.js
new file mode 100644
index 0000000..b8e4f7b
--- /dev/null
+++ b/web/pano_cutout_projection.js
@@ -0,0 +1,223 @@
+import { clampSafe as clamp } from "./pano_math.js";
+
+const DEG2RAD = Math.PI / 180;
+
+function finiteOr(v, fallback = 0) {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : Number(fallback);
+}
+
+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 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 = Number(yawDeg || 0) * DEG2RAD;
+ const pitch = Number(pitchDeg || 0) * DEG2RAD;
+ const cp = Math.cos(pitch);
+ return vec3(cp * Math.sin(yaw), Math.sin(pitch), cp * Math.cos(yaw));
+}
+
+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 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 = Number(rollDeg || 0) * 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 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 false;
+ 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();
+ return true;
+}
+
+function getWrappedErpCanvas(owner, 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 (!owner.__panoSharedWrappedErpCache) owner.__panoSharedWrappedErpCache = { src: "", w: 0, h: 0, canvas: null };
+ const src = String(img.src || "");
+ const cached = owner.__panoSharedWrappedErpCache;
+ 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);
+ owner.__panoSharedWrappedErpCache = { src, w: iw, h: ih, canvas: cv };
+ return cv;
+}
+
+export function getCutoutShotParams(shot) {
+ const s = shot || {};
+ const yaw = finiteOr(s.yaw_deg, 0);
+ const pitch = finiteOr(s.pitch_deg, 0);
+ const roll = finiteOr((s.roll_deg ?? s.rot_deg), 0);
+ const hfov = clamp(finiteOr(s.hFOV_deg, 90), 1, 179);
+ const vfov = clamp(finiteOr(s.vFOV_deg, 60), 1, 179);
+ const aspectRaw = Math.tan((hfov * DEG2RAD) * 0.5) / Math.max(1e-6, Math.tan((vfov * DEG2RAD) * 0.5));
+ const aspect = clamp(aspectRaw, 0.1, 10.0);
+ return {
+ yaw,
+ pitch,
+ roll,
+ hfov,
+ vfov,
+ aspect,
+ };
+}
+
+export function drawCutoutProjectionPreview(ctx, owner, img, rect, shot, quality = "balanced") {
+ const setStats = (stats = {}) => {
+ owner.__panoCutoutProjectionStats = {
+ drawnTriCount: 0,
+ Nu: 0,
+ Nv: 0,
+ rectW: finiteOr(rect?.w, 0),
+ rectH: finiteOr(rect?.h, 0),
+ imageW: finiteOr(img?.naturalWidth || img?.width, 0),
+ imageH: finiteOr(img?.naturalHeight || img?.height, 0),
+ ...stats,
+ };
+ };
+ if (!ctx || !owner || !img || !rect || !shot) {
+ if (owner) setStats({ reason: "invalid_args" });
+ return false;
+ }
+ if (!img.complete || !(img.naturalWidth || img.width)) {
+ setStats({ reason: "image_not_ready" });
+ return false;
+ }
+ const rw = finiteOr(rect.w, 0);
+ const rh = finiteOr(rect.h, 0);
+ if (rw <= 1 || rh <= 1) {
+ setStats({ reason: "invalid_rect", rectW: rw, rectH: rh });
+ return false;
+ }
+ const iw = Number(img.naturalWidth || img.width || 0);
+ const ih = Number(img.naturalHeight || img.height || 0);
+ if (iw <= 1 || ih <= 1) {
+ setStats({ reason: "invalid_image", imageW: iw, imageH: ih });
+ return false;
+ }
+ const source = getWrappedErpCanvas(owner, img) || img;
+ const { yaw, pitch, roll, hfov, vfov } = getCutoutShotParams(shot);
+ const basis = cameraBasis(yaw, pitch, roll);
+ const tanX = Math.tan((hfov * DEG2RAD) * 0.5);
+ const tanY = Math.tan((vfov * DEG2RAD) * 0.5);
+ const Nu = quality === "high" ? 20 : (quality === "draft" ? 10 : 14);
+ const Nv = quality === "high" ? 14 : (quality === "draft" ? 7 : 10);
+ let drawnTriCount = 0;
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const nx = (u * 2 - 1) * tanX;
+ const ny = (1 - v * 2) * tanY;
+ const d = norm(add(add(basis.fwd, mul(basis.right, nx)), mul(basis.up, ny)));
+ const lon = Math.atan2(d.x, d.z);
+ const lat = Math.asin(clamp(d.y, -1, 1));
+ let su = (lon / (2 * Math.PI) + 0.5) * iw;
+ while (su < 0) su += iw;
+ while (su >= iw) su -= iw;
+ const sv = (0.5 - lat / Math.PI) * ih;
+ verts[j][i] = { x: rect.x + u * rect.w, y: rect.y + v * rect.h };
+ sample[j][i] = { x: su, y: sv };
+ }
+ }
+ 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];
+ 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;
+ });
+ }
+ if (drawImageTri(ctx, source, s00, s10, s11, p00, p10, p11)) drawnTriCount += 1;
+ if (drawImageTri(ctx, source, s00, s11, s01, p00, p11, p01)) drawnTriCount += 1;
+ }
+ }
+ setStats({
+ drawnTriCount,
+ Nu,
+ Nv,
+ rectW: rw,
+ rectH: rh,
+ imageW: iw,
+ imageH: ih,
+ reason: "ok",
+ });
+ return drawnTriCount > 0;
+}
diff --git a/web/pano_editor.css b/web/pano_editor.css
new file mode 100644
index 0000000..f79243e
--- /dev/null
+++ b/web/pano_editor.css
@@ -0,0 +1,991 @@
+
+.pano-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.62);
+ backdrop-filter: blur(12px);
+ z-index: 99999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.pano-modal {
+ width: min(1200px, 94vw);
+ height: min(760px, 92vh);
+ background: var(--pano-bg-panel);
+ border: 0;
+ border-radius: 12px;
+ box-shadow: 0 28px 80px rgba(0, 0, 0, 0.66);
+ color: var(--pano-text);
+ display: grid;
+ --pano-side-w: 300px;
+ grid-template-columns: minmax(0, 1fr) var(--pano-side-w);
+ gap: var(--pano-space-lg);
+ padding: var(--pano-space-lg);
+ box-sizing: border-box;
+ overflow: hidden;
+ position: relative;
+ font-family: "Plus Jakarta Sans", "Geist", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, sans-serif;
+ --pano-surface-0: #0a0a0a;
+ --pano-surface-1: #0f0f10;
+ --pano-surface-2: #1a1a1a;
+ --pano-surface-3: #3d3d3f;
+ --pano-surface-hover: #222224;
+ --pano-muted: #787878;
+ --pano-muted-strong: #a0a0a0;
+ --pano-space-xs: 4px;
+ --pano-space-sm: 6px;
+ --pano-space-md: 8px;
+ --pano-space-lg: 12px;
+ --pano-space-xl: 14px;
+ --pano-space-2xl: 24px;
+ --pano-bg-base: var(--pano-surface-0);
+ --pano-bg-panel: var(--pano-surface-0);
+ --pano-bg-control: var(--pano-surface-2);
+ --pano-border: rgba(255, 255, 255, 0.09);
+ --pano-text: #f5f5f5;
+ --pano-text-secondary: var(--pano-muted);
+ --pano-text-tertiary: #5e5e5e;
+ --pano-text-reversed: #ffffff;
+ --pano-accent: var(--pano-theme-accent, #0070f3);
+ --pano-accent-dimmed: color-mix(in srgb, var(--pano-accent) 34%, transparent);
+ --pano-accent-dark: var(--pano-theme-accent-dark, #0366d6);
+ --pano-accent-extra-dark: var(--pano-theme-accent-extra-dark, #0059bc);
+ --pano-float-pad: var(--pano-space-xs);
+ --pano-float-gap: var(--pano-space-sm);
+ --pano-float-radius: 10px;
+ --pano-float-bg: rgba(10, 10, 10, 0.74);
+}
+.pano-modal.pano-modal-fullscreen {
+ width: 100vw;
+ height: 100vh;
+ max-width: 100vw;
+ max-height: 100vh;
+ border-radius: 0;
+ padding: 0;
+ gap: 0;
+ grid-template-columns: minmax(0, 1fr);
+}
+.pano-modal.pano-modal-fullscreen .pano-stage {
+ border-radius: 0;
+}
+.pano-modal.pano-modal-fullscreen .pano-side,
+.pano-modal.pano-modal-fullscreen .pano-floating-top,
+.pano-modal.pano-modal-fullscreen .pano-floating-right,
+.pano-modal.pano-modal-fullscreen .pano-selection-menu,
+.pano-modal.pano-modal-fullscreen .pano-output-preview-toggle,
+.pano-modal.pano-modal-fullscreen .pano-tooltip {
+ display: none !important;
+}
+.pano-modal button {
+ cursor: pointer;
+}
+.pano-stage-wrap {
+ position: relative;
+ min-height: 0;
+ overflow: hidden;
+}
+.pano-stage {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ border-radius: 10px;
+ background: var(--pano-bg-base);
+ transition: filter 130ms ease, opacity 130ms ease;
+}
+.pano-stage-wrap.drop-active .pano-stage {
+ filter: blur(2px) brightness(0.76);
+}
+.pano-stage-drop-hint {
+ position: absolute;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ pointer-events: none;
+}
+.pano-stage-wrap.drop-active .pano-stage-drop-hint {
+ display: flex;
+}
+.pano-stage-drop-hint-text {
+ padding: 10px 14px;
+ border-radius: 10px;
+ background: rgba(10, 10, 10, 0.58);
+ color: var(--pano-text);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
+}
+.pano-floating-top {
+ position: absolute;
+ top: var(--pano-space-lg);
+ left: var(--pano-space-lg);
+ display: flex;
+ gap: var(--pano-float-gap);
+ z-index: 2;
+}
+.pano-view-toggle {
+ position: relative;
+ display: inline-flex;
+ background: var(--pano-surface-1);
+ border: 0;
+ border-radius: var(--pano-float-radius);
+ padding: var(--pano-float-pad);
+ gap: 0;
+ box-shadow: 0 8px 30px rgba(0,0,0,0.35);
+ overflow: hidden;
+}
+.pano-view-toggle::before {
+ content: "";
+ position: absolute;
+ top: var(--pano-float-pad);
+ bottom: var(--pano-float-pad);
+ left: var(--pano-float-pad);
+ width: calc(50% - var(--pano-float-pad));
+ border-radius: 8px;
+ background: var(--pano-surface-3);
+ transition: transform 170ms cubic-bezier(0.2, 0, 0, 1);
+ will-change: transform;
+ pointer-events: none;
+}
+.pano-view-toggle[data-selected="pano"]::before {
+ transform: translateX(0);
+}
+.pano-view-toggle[data-selected="unwrap"]::before {
+ transform: translateX(100%);
+}
+.pano-view-btn {
+ position: relative;
+ z-index: 1;
+ border-radius: 0;
+ width: 124px;
+ height: 40px;
+ padding: 0 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ color: var(--pano-muted);
+ transition: color 100ms ease;
+}
+.pano-view-btn svg {
+ width: 18px;
+ height: 18px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.pano-view-btn .label {
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+.pano-view-btn:active,
+.pano-view-btn:focus-visible {
+ background: transparent;
+ box-shadow: none;
+ outline: none;
+}
+.pano-view-toggle[data-selected="pano"] .pano-view-btn[data-view="pano"],
+.pano-view-toggle[data-selected="unwrap"] .pano-view-btn[data-view="unwrap"] {
+ color: var(--pano-text);
+}
+.pano-view-toggle[data-selected="pano"] .pano-view-btn[data-view="unwrap"]:hover,
+.pano-view-toggle[data-selected="unwrap"] .pano-view-btn[data-view="pano"]:hover {
+ color: var(--pano-muted-strong);
+}
+.pano-floating-bottom {
+ position: absolute;
+ left: 50%;
+ bottom: var(--pano-space-2xl);
+ transform: translateX(-50%);
+ display: flex;
+ gap: var(--pano-float-gap);
+ padding: var(--pano-float-pad);
+ border-radius: var(--pano-float-radius);
+ border: 0;
+ background: var(--pano-float-bg);
+ backdrop-filter: blur(12px);
+ z-index: 2;
+}
+.pano-floating-right {
+ position: absolute;
+ right: var(--pano-space-xl);
+ bottom: var(--pano-space-2xl);
+ display: flex;
+ align-items: center;
+ gap: var(--pano-float-gap);
+ padding: var(--pano-float-pad);
+ padding-left: 0.5rem;
+ min-height: 40px;
+ border-radius: var(--pano-float-radius);
+ border: 0;
+ background: var(--pano-float-bg);
+ backdrop-filter: blur(12px);
+ z-index: 2;
+ color: var(--pano-text-secondary);
+ font-size: 12px;
+ line-height: 1;
+}
+.pano-fov-value {
+ font-variant-numeric: tabular-nums;
+ min-width: 2rem;
+ text-align: right;
+ color: var(--pano-text);
+}
+.pano-selection-menu {
+ position: absolute;
+ display: none;
+ gap: var(--pano-float-gap);
+ padding: var(--pano-float-pad);
+ border-radius: var(--pano-float-radius);
+ border: 0;
+ background: var(--pano-float-bg);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.15);
+ z-index: 3;
+}
+.pano-output-preview-toggle {
+ position: absolute;
+ z-index: 3;
+ border-radius: 999px;
+ padding: 0;
+ background: rgba(10, 10, 10, 0.72);
+ color: rgba(250, 250, 250, 0.92);
+ box-shadow: 0 8px 22px rgba(0, 0, 0, 0.35);
+}
+.pano-output-preview-toggle.pano-btn-icon {
+ width: 24px;
+ min-width: 24px;
+ height: 24px;
+}
+.pano-output-preview-toggle svg {
+ width: 14px;
+ height: 14px;
+}
+.pano-cutout-menu {
+ position: relative;
+ display: inline-flex;
+}
+.pano-aspect-popover {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: calc(100% + 8px);
+ display: none;
+ flex-direction: column;
+ gap: 4px;
+ width: max-content;
+ padding: 8px;
+ border-radius: 10px;
+ background: rgba(10, 10, 10, 0.92);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 14px 38px rgba(0, 0, 0, 0.42);
+ z-index: 4;
+}
+.pano-aspect-popover.open {
+ display: flex;
+}
+.pano-tooltip {
+ position: absolute;
+ left: 0;
+ top: 0;
+ padding: 6px 8px;
+ border-radius: var(--pano-space-md);
+ background: color-mix(in srgb, var(--pano-surface-1) 92%, black);
+ color: var(--pano-text);
+ font-size: 11px;
+ line-height: 1;
+ pointer-events: none;
+ white-space: nowrap;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 100ms ease, visibility 100ms ease;
+ z-index: 4;
+}
+.pano-tooltip.show {
+ opacity: 1;
+ visibility: visible;
+}
+.pano-canvas-confirm {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(2px);
+ z-index: 5;
+}
+.pano-canvas-confirm-card {
+ width: min(340px, calc(100% - 32px));
+ background: #111;
+ border-radius: 10px;
+ padding: 14px;
+ box-shadow: 0 16px 44px rgba(0, 0, 0, 0.45);
+ display: grid;
+ gap: 10px;
+}
+.pano-canvas-confirm-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #f5f5f5;
+}
+.pano-canvas-confirm-text {
+ font-size: 12px;
+ color: #b0b0b0;
+ line-height: 1.4;
+}
+.pano-canvas-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+.pano-btn {
+ background: var(--pano-bg-control);
+ color: var(--pano-text-secondary);
+ border: 0;
+ border-radius: var(--pano-space-md);
+ height: 40px;
+ padding: 0 var(--pano-space-lg);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+ transition: background-color 120ms cubic-bezier(0.2, 0, 0, 1), color 120ms cubic-bezier(0.2, 0, 0, 1), box-shadow 120ms cubic-bezier(0.2, 0, 0, 1), opacity 120ms cubic-bezier(0.2, 0, 0, 1);
+}
+.pano-btn-icon {
+ width: 40px;
+ min-width: 40px;
+ height: 40px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+.pano-btn-texticon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--pano-space-md);
+ padding: 0 var(--pano-space-xl);
+}
+.pano-aspect-choice {
+ width: 100%;
+ min-width: 0;
+ height: auto;
+ padding: var(--pano-space-md);
+ font-size: 14px;
+ line-height: 1.1;
+ border-radius: var(--pano-space-xs);
+}
+.pano-btn-texticon .label {
+ font-size: 12px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+.pano-btn-icon svg,
+.pano-btn-texticon svg {
+ width: 18px;
+ height: 18px;
+}
+.pano-btn-icon svg path:not([fill]),
+.pano-btn-icon svg rect:not([fill]),
+.pano-btn-icon svg circle:not([fill]),
+.pano-btn-icon svg line:not([fill]),
+.pano-btn-icon svg polyline:not([fill]),
+.pano-btn-icon svg polygon:not([fill]),
+.pano-btn-texticon svg path:not([fill]),
+.pano-btn-texticon svg rect:not([fill]),
+.pano-btn-texticon svg circle:not([fill]),
+.pano-btn-texticon svg line:not([fill]),
+.pano-btn-texticon svg polyline:not([fill]),
+.pano-btn-texticon svg polygon:not([fill]) {
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.pano-btn:hover {
+ background: var(--pano-surface-hover);
+ color: var(--pano-text);
+}
+.pano-btn:disabled {
+ opacity: 0.46;
+ cursor: default;
+ pointer-events: none;
+}
+.pano-btn:focus-visible {
+ outline: none;
+ box-shadow: 0 0 0 1px var(--pano-accent);
+}
+.pano-btn.active:not(.pano-view-btn) {
+ background: var(--pano-surface-3);
+ color: var(--pano-text);
+ box-shadow: none;
+}
+.pano-btn-primary {
+ background: var(--pano-accent);
+ color: var(--pano-text-reversed);
+}
+.pano-btn-primary:hover,
+.pano-btn-primary:focus-visible {
+ background: var(--pano-accent-dark);
+}
+.pano-btn-primary:active {
+ background: var(--pano-accent-extra-dark);
+}
+.pano-btn-primary:disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+.pano-view-toggle .pano-view-btn:hover,
+.pano-view-toggle .pano-view-btn:active {
+ background: transparent !important;
+}
+.pano-side {
+ border-left: 0;
+ background: var(--pano-surface-0);
+ padding: 0 var(--pano-space-lg);
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ min-width: 0;
+ border-radius: 10px;
+}
+.pano-side-footer {
+ margin-top: auto;
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--pano-space-md);
+ height: auto;
+ align-items: center;
+ padding: var(--pano-space-lg) 0 var(--pano-space-xl);
+ border-top: 0;
+}
+.pano-side-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: 42px;
+ padding: 0;
+}
+.pano-side-title {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--pano-text);
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.pano-side-title .pano-side-title-icon {
+ width: 14px;
+ height: 14px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--pano-text-secondary);
+}
+.pano-side-title .pano-side-title-icon svg {
+ width: 14px;
+ height: 14px;
+ display: block;
+}
+.pano-side-actions { display: flex; gap: 6px; }
+.pano-divider {
+ height: 1px;
+ background: rgba(255,255,255,0.08);
+ display: block;
+ margin: 0 calc(var(--pano-space-lg) * -1);
+}
+.pano-param-note { color: var(--pano-text-secondary); font-size: 11px; }
+.pano-inspector {
+ display: grid;
+ gap: var(--pano-space-sm);
+ padding: var(--pano-space-lg) 0;
+ overflow: visible;
+}
+.pano-section-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: #d0d0d0;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: 0.01em;
+ padding: 0;
+ margin: 2px 0 8px;
+}
+.pano-section-title .meta {
+ color: var(--pano-text-tertiary);
+ font-weight: 500;
+ font-size: 11px;
+}
+.pano-state-actions {
+ display: flex;
+ justify-content: flex-end;
+ padding: 0 0 var(--pano-space-md);
+}
+.pano-params {
+ opacity: 1;
+ transition: opacity 0.16s ease, filter 0.16s ease;
+}
+.pano-params.disabled {
+ opacity: 0.42;
+ filter: saturate(0.7);
+}
+.pano-field {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr) 60px;
+ gap: var(--pano-space-md);
+ align-items: center;
+ height: 28px;
+ padding: 0;
+}
+.pano-field-wide {
+ display: grid;
+ grid-template-columns: 66px 1fr;
+ gap: var(--pano-space-md);
+ align-items: center;
+ min-width: 0;
+ height: 30px;
+ padding: 0;
+}
+.pano-field-wide > label {
+ height: 100%;
+ display: flex;
+ align-items: center;
+}
+.pano-target-row {
+ margin-top: 0;
+}
+.pano-picker {
+ position: relative;
+ min-width: 0;
+ width: 100%;
+}
+.pano-picker-trigger {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--pano-space-md);
+ background: var(--pano-bg-control);
+ border: 0;
+ color: var(--pano-text);
+ border-radius: var(--pano-space-md);
+ height: 30px;
+ padding: 0 var(--pano-space-md);
+}
+.pano-picker-trigger:disabled {
+ opacity: 0.62;
+}
+.pano-picker-label {
+ min-width: 0;
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left;
+}
+.pano-picker-caret {
+ color: var(--pano-text-tertiary);
+ font-size: 11px;
+}
+.pano-picker-pop {
+ position: absolute;
+ top: calc(100% + var(--pano-space-sm));
+ left: 0;
+ right: 0;
+ z-index: 6;
+ max-height: 240px;
+ overflow: auto;
+ background: var(--pano-surface-1);
+ border: 0;
+ border-radius: var(--pano-space-lg);
+ box-shadow: 0 18px 54px rgba(0, 0, 0, 0.5);
+ padding: var(--pano-space-sm);
+}
+.pano-aspect-row {
+ height: auto;
+ align-items: start;
+ margin-bottom: 4px;
+}
+.pano-cutout-aspect-inline {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ width: 100%;
+ align-items: stretch;
+}
+.pano-cutout-aspect-picker {
+ position: relative;
+ min-width: 0;
+}
+.pano-cutout-aspect-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+.pano-cutout-aspect-label svg {
+ width: 15px;
+ height: 15px;
+}
+.pano-cutout-aspect-pop {
+ left: 0;
+ right: auto;
+ top: calc(100% + 6px);
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ z-index: 12;
+}
+.pano-cutout-aspect-presets {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.pano-cutout-aspect-presets .pano-picker-item {
+ height: 28px;
+ text-align: left;
+}
+.pano-cutout-aspect-custom {
+ margin-top: 8px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 6px;
+ align-items: center;
+}
+.pano-cutout-aspect-custom input {
+ width: 100%;
+ min-width: 52px;
+ height: 30px;
+ background: var(--pano-bg-control);
+ color: var(--pano-text);
+ border: 0;
+ border-radius: var(--pano-space-md);
+ padding: 0 8px;
+ box-sizing: border-box;
+}
+.pano-cutout-aspect-custom .pano-btn {
+ height: 28px;
+ padding: 0 10px;
+ grid-column: 1 / -1;
+}
+.pano-cutout-aspect-trigger {
+ height: 32px;
+}
+.pano-cutout-aspect-rotate {
+ width: 32px;
+ min-width: 32px;
+ height: 32px;
+}
+.pano-picker-pop[hidden] {
+ display: none;
+}
+.pano-picker-item {
+ width: 100%;
+ min-width: 0;
+ text-align: left;
+ background: transparent;
+ border: 0;
+ color: var(--pano-muted);
+ border-radius: var(--pano-space-md);
+ height: 32px;
+ padding: 0 var(--pano-space-md);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 11px;
+ cursor: pointer;
+}
+.pano-picker-item:hover {
+ background: var(--pano-surface-2);
+ color: var(--pano-text);
+}
+.pano-picker-item.active {
+ background: var(--pano-surface-3);
+ color: #f5f5f5;
+ box-shadow: none;
+}
+.is-selected {
+ background: var(--pano-accent-dimmed);
+ box-shadow: inset 0 0 0 1px var(--pano-accent);
+}
+.pano-inline-tools {
+ display: flex;
+ justify-content: flex-end;
+}
+.pano-btn.subtle {
+ background: transparent;
+ border: 0;
+ color: var(--pano-muted);
+ padding: 0 var(--pano-space-md);
+ font-size: 10.5px;
+ height: 32px;
+}
+.pano-btn-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.pano-btn-copy svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.pano-btn.subtle:hover {
+ color: var(--pano-text);
+ background: color-mix(in srgb, var(--pano-bg-control) 55%, transparent);
+}
+.pano-btn.subtle:disabled {
+ opacity: 0.38;
+ pointer-events: none;
+}
+.pano-ui-settings {
+ margin: var(--pano-space-md) 0 0;
+ border-radius: var(--pano-space-md);
+ background: var(--pano-surface-1);
+ overflow: visible;
+}
+.pano-ui-settings > summary {
+ list-style: none;
+ cursor: pointer;
+ user-select: none;
+ height: 32px;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 11px;
+ font-weight: 600;
+ color: #f5f5f5;
+ border-radius: var(--pano-space-md);
+}
+.pano-ui-summary-label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--pano-space-sm);
+}
+.pano-ui-caret {
+ width: 14px;
+ height: 14px;
+ color: var(--pano-muted);
+ transition: transform 140ms cubic-bezier(0.2, 0, 0, 1), color 140ms cubic-bezier(0.2, 0, 0, 1);
+}
+.pano-ui-caret svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 1.6;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.pano-ui-settings[open] .pano-ui-caret {
+ transform: rotate(180deg);
+ color: var(--pano-muted-strong);
+}
+.pano-ui-settings > summary::-webkit-details-marker {
+ display: none;
+}
+.pano-ui-settings-body {
+ display: grid;
+ gap: var(--pano-space-md);
+ padding: 0 0 var(--pano-space-md);
+ overflow: visible;
+}
+.pano-ui-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--pano-space-md);
+}
+.pano-ui-row label {
+ font-size: 11px;
+ color: var(--pano-muted-strong);
+}
+.pano-ui-row input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--pano-accent);
+}
+.pano-segment {
+ position: relative;
+ display: inline-flex;
+ background: var(--pano-surface-1);
+ border-radius: var(--pano-space-md);
+ padding: 2px;
+ gap: 0;
+ overflow: hidden;
+}
+.pano-segment::before {
+ content: "";
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+ left: 2px;
+ width: calc(50% - 2px);
+ border-radius: calc(var(--pano-space-md) - 2px);
+ background: var(--pano-surface-3);
+ transition: transform 170ms cubic-bezier(0.2, 0, 0, 1);
+ pointer-events: none;
+}
+.pano-segment[data-selected="1"]::before {
+ transform: translateX(100%);
+}
+.pano-segment-btn {
+ position: relative;
+ z-index: 1;
+ min-width: 72px;
+ padding: 0 var(--pano-space-md);
+ height: 28px;
+ border: 0;
+ background: transparent;
+ color: var(--pano-muted);
+ font-size: 11px;
+ font-weight: 500;
+ cursor: pointer;
+ appearance: none;
+ -webkit-appearance: none;
+}
+.pano-segment-btn[aria-pressed="true"] {
+ color: #f5f5f5;
+}
+.pano-ui-picker {
+ width: 126px;
+ position: relative;
+}
+.pano-ui-picker .pano-picker-trigger {
+ height: 28px;
+ border-radius: calc(var(--pano-space-md) - 2px);
+ font-size: 11px;
+ color: var(--pano-muted);
+ background: var(--pano-surface-2);
+}
+.pano-ui-picker .pano-picker-trigger:hover {
+ background: var(--pano-surface-hover);
+ color: var(--pano-text);
+}
+.pano-field input {
+ box-sizing: border-box;
+ background: var(--pano-bg-control);
+ border: 0;
+ color: var(--pano-text);
+ border-radius: var(--pano-space-md);
+ height: 28px;
+ padding: 0 var(--pano-space-md);
+ width: 100%;
+ min-width: 0;
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+}
+.pano-field label,
+.pano-field-wide label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--pano-muted);
+}
+.pano-field input:focus-visible,
+.pano-picker-trigger:focus-visible {
+ outline: none;
+ box-shadow: inset 0 0 0 1px var(--pano-accent);
+}
+.pano-field input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ background: linear-gradient(to right, var(--pano-accent) 0%, var(--pano-accent) var(--v, 50%), rgba(255,255,255,0.16) var(--v, 50%));
+ height: 3px;
+ border-radius: 999px;
+ padding: 0;
+}
+.pano-field input[type="range"]::-webkit-slider-runnable-track {
+ height: 3px;
+ border-radius: 999px;
+ background: transparent;
+}
+.pano-field input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 11px;
+ height: 11px;
+ border-radius: 999px;
+ margin-top: -4px;
+ border: none;
+ background: #fff;
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.45);
+}
+.pano-field input[type="range"]:hover::-webkit-slider-thumb {
+ transform: scale(1.2);
+}
+.pano-field input[type="range"]:active::-webkit-slider-thumb {
+ transform: scale(1.2);
+}
+.pano-field input[type="range"]::-moz-range-track {
+ height: 3px;
+ border-radius: 999px;
+ background: rgba(255,255,255,0.1);
+}
+.pano-field input[type="range"]::-moz-range-progress {
+ height: 3px;
+ border-radius: 999px;
+ background: var(--pano-accent);
+}
+.pano-field input[type="range"]::-moz-range-thumb {
+ width: 11px;
+ height: 11px;
+ border-radius: 999px;
+ border: none;
+ background: #fff;
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.45);
+}
+.pano-field input[type="range"]:focus-visible {
+ outline: none;
+}
+.pano-field input[type="range"]:focus-visible::-webkit-slider-thumb {
+ box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5);
+}
+.pano-field input[type="range"]:focus-visible::-moz-range-thumb {
+ box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5);
+}
+.pano-field label,
+.pano-field input,
+.pano-picker-trigger,
+.pano-picker-item {
+ transition: opacity 100ms ease, color 100ms ease, background-color 100ms ease, border-color 100ms ease, box-shadow 100ms ease;
+}
+.pano-field input:disabled {
+ opacity: 0.62;
+}
+.pano-field input[type="number"] {
+ text-align: right;
+ padding: 0 6px;
+ -moz-appearance: textfield;
+ appearance: textfield;
+}
+.pano-field input[type="number"]::-webkit-outer-spin-button,
+.pano-field input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
diff --git a/web/pano_editor.js b/web/pano_editor.js
new file mode 100644
index 0000000..fc0f2f1
--- /dev/null
+++ b/web/pano_editor.js
@@ -0,0 +1,4778 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+import {
+ attachCutoutPreview,
+ attachPreviewNode,
+ attachStickersNodePreview,
+} from "./pano_node_preview.js";
+import { isPanoramaPreviewNodeName } from "./pano_preview_identity.js";
+import { drawCutoutProjectionPreview } from "./pano_cutout_projection.js";
+import { renderCutoutViewToContext2D, renderErpViewToContext2D, renderSceneToContext2D } from "./pano_gl_viewport.js";
+import { createPanoInteractionController } from "./pano_interaction_controller.js";
+import { clamp, wrapYaw, shortestYawDelta } from "./pano_math.js";
+import {
+ buildCutoutViewParamsFromShot,
+ buildPanoramaViewParamsFromEditor,
+ buildStickerSceneFromState,
+ buildStickerTexturesFromState,
+} from "./pano_gl_scene.js";
+
+const STATE_WIDGET = "state_json";
+const EXTERNAL_STICKER_ID = "sticker_image_1";
+const EXTERNAL_STICKER_SOURCE_KIND = "external_image";
+const EXTERNAL_STICKER_PREVIEW_KEY = "pano_sticker_input_images";
+const ENABLE_STICKERS_NODE_PREVIEW = false;
+const DEG2RAD = Math.PI / 180;
+const RAD2DEG = 180 / Math.PI;
+const ICON = {
+ // Source: @geist-ui/icons globe.js (v1.0.2)
+ globe: "",
+ pano: "",
+ unwrap: "",
+ undo: "",
+ redo: "",
+ add: "",
+ clear: "",
+ duplicate: "",
+ bring_front: "",
+ send_back: "",
+ aspect: "",
+ rotate_90: "",
+ back_initial: "",
+ delete: "",
+ reset: "",
+ eye: "",
+ eye_dashed: "",
+ fullscreen: "",
+ camera: "",
+ fullscreen_close: "",
+ close: "",
+ copy: "",
+ chevron: "",
+};
+
+function easeInOutCubic(t) {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+}
+function easeOutCubic(t) {
+ return 1 - Math.pow(1 - t, 3);
+}
+function easeInCubic(t) {
+ return t * t * t;
+}
+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 dirToYawPitch(d) {
+ return {
+ yaw: wrapYaw(Math.atan2(d.x, d.z) * RAD2DEG),
+ pitch: clamp(Math.asin(clamp(d.y, -1, 1)) * RAD2DEG, -90, 90),
+ };
+}
+function pointInPoly(pt, poly) {
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const xi = poly[i].x; const yi = poly[i].y;
+ const xj = poly[j].x; const yj = poly[j].y;
+ const intersect = ((yi > pt.y) !== (yj > pt.y))
+ && (pt.x < ((xj - xi) * (pt.y - yi)) / ((yj - yi) || 1e-6) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+function dist2(a, b) {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+}
+function lerp(a, b, t) {
+ return a + (b - a) * t;
+}
+function formatParamValue(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return "0";
+ return Number(n.toFixed(3)).toString();
+}
+function toPositiveFinite(v, fallback = 0) {
+ const n = Number(v);
+ return Number.isFinite(n) && n > 0 ? n : Number(fallback);
+}
+function ratioTextFromPair(w, h) {
+ const ww = toPositiveFinite(w, 1);
+ const hh = toPositiveFinite(h, 1);
+ if (ww <= 0 || hh <= 0) return "1:1";
+ const scale = 1000;
+ const wi = Math.max(1, Math.round(ww * scale));
+ const hi = Math.max(1, Math.round(hh * scale));
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
+ const g = gcd(wi, hi) || 1;
+ const rw = Math.max(1, Math.round(wi / g));
+ const rh = Math.max(1, Math.round(hi / g));
+ return `${rw}:${rh}`;
+}
+function getCutoutAspectLabel(item) {
+ if (!item || typeof item !== "object") return "1:1";
+ const stored = String(item.aspect_id || "").trim();
+ if (stored) return stored;
+ const ow = toPositiveFinite(item.out_w, 0);
+ const oh = toPositiveFinite(item.out_h, 0);
+ if (ow > 0 && oh > 0) return ratioTextFromPair(ow, oh);
+ const hf = clamp(Number(item.hFOV_deg || 90), 1, 179) * DEG2RAD;
+ const vf = clamp(Number(item.vFOV_deg || 60), 1, 179) * DEG2RAD;
+ const rw = Math.max(1e-6, Math.tan(hf * 0.5));
+ const rh = Math.max(1e-6, Math.tan(vf * 0.5));
+ return ratioTextFromPair(rw, rh);
+}
+function expandTri(d0, d1, d2, px = 1.1) {
+ 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 installCss() {
+ if (document.getElementById("pano-suite-style-link")) return;
+ const link = document.createElement("link");
+ link.id = "pano-suite-style-link";
+ link.rel = "stylesheet";
+ link.href = new URL("./pano_editor.css", import.meta.url).toString();
+ document.head.appendChild(link);
+}
+
+const SHARED_UI_SETTINGS_KEY = "pano_suite.ui_settings.v1";
+const NODE_GRID_VISIBILITY_KEY = "pano_suite.node_grid_visibility.v1";
+let sharedUiSettingsMemory = null;
+let nodeGridVisibilityMemory = null;
+let parseStateJsonCache = { text: null, parsed: null };
+
+function normalizeUiSettings(raw) {
+ const src = (raw && typeof raw === "object") ? raw : {};
+ const q = String(src.preview_quality || "balanced");
+ return {
+ invert_view_x: !!src.invert_view_x,
+ invert_view_y: !!src.invert_view_y,
+ preview_quality: (q === "draft" || q === "balanced" || q === "high") ? q : "balanced",
+ };
+}
+
+function loadSharedUiSettings() {
+ try {
+ const text = String(window?.localStorage?.getItem(SHARED_UI_SETTINGS_KEY) || "").trim();
+ if (!text) return sharedUiSettingsMemory ? normalizeUiSettings(sharedUiSettingsMemory) : null;
+ const parsed = JSON.parse(text);
+ const normalized = normalizeUiSettings(parsed);
+ sharedUiSettingsMemory = normalized;
+ return normalized;
+ } catch {
+ return sharedUiSettingsMemory ? normalizeUiSettings(sharedUiSettingsMemory) : null;
+ }
+}
+
+function saveSharedUiSettings(settings) {
+ const normalized = normalizeUiSettings(settings);
+ sharedUiSettingsMemory = normalized;
+ try {
+ window?.localStorage?.setItem(SHARED_UI_SETTINGS_KEY, JSON.stringify(normalized));
+ } catch {
+ // localStorage unavailable; memory fallback is used.
+ }
+ return normalized;
+}
+
+function loadNodeGridVisibilityMap() {
+ if (nodeGridVisibilityMemory && typeof nodeGridVisibilityMemory === "object") {
+ return nodeGridVisibilityMemory;
+ }
+ try {
+ const text = String(window?.localStorage?.getItem(NODE_GRID_VISIBILITY_KEY) || "").trim();
+ if (!text) {
+ nodeGridVisibilityMemory = {};
+ return nodeGridVisibilityMemory;
+ }
+ const parsed = JSON.parse(text);
+ nodeGridVisibilityMemory = parsed && typeof parsed === "object" ? parsed : {};
+ return nodeGridVisibilityMemory;
+ } catch {
+ nodeGridVisibilityMemory = {};
+ return nodeGridVisibilityMemory;
+ }
+}
+
+function getNodeGridVisibility(nodeId, fallback = true) {
+ const key = String(nodeId ?? "").trim();
+ if (!key) return !!fallback;
+ const map = loadNodeGridVisibilityMap();
+ const v = map[key];
+ return typeof v === "boolean" ? v : !!fallback;
+}
+
+function setNodeGridVisibility(nodeId, visible) {
+ const key = String(nodeId ?? "").trim();
+ if (!key) return;
+ const map = loadNodeGridVisibilityMap();
+ map[key] = !!visible;
+ nodeGridVisibilityMemory = map;
+ try {
+ window?.localStorage?.setItem(NODE_GRID_VISIBILITY_KEY, JSON.stringify(map));
+ } catch {
+ // localStorage unavailable; memory fallback is used.
+ }
+}
+
+function cloneAssetMap(raw) {
+ if (!raw || typeof raw !== "object") return {};
+ const out = {};
+ Object.entries(raw).forEach(([k, v]) => {
+ out[k] = (v && typeof v === "object") ? { ...v } : v;
+ });
+ return out;
+}
+
+function cloneStickerList(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((item) => {
+ if (!item || typeof item !== "object") return item;
+ const next = { ...item };
+ if (next.crop && typeof next.crop === "object") next.crop = { ...next.crop };
+ if (next.initial_pose && typeof next.initial_pose === "object") next.initial_pose = { ...next.initial_pose };
+ next.visible = next.visible !== false;
+ return next;
+ });
+}
+
+function cloneShotList(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((item) => ((item && typeof item === "object") ? { ...item } : item));
+}
+
+function parseState(text, preset = 2048, bg = "#00ff00") {
+ const sharedUi = loadSharedUiSettings();
+ const base = {
+ version: 1,
+ projection_model: "pinhole_rectilinear",
+ alpha_mode: "straight",
+ bg_color: bg,
+ output_preset: preset,
+ assets: {},
+ stickers: [],
+ shots: [],
+ ui_settings: {
+ invert_view_x: !!sharedUi?.invert_view_x,
+ invert_view_y: !!sharedUi?.invert_view_y,
+ preview_quality: String(sharedUi?.preview_quality || "balanced"),
+ },
+ active: { selected_sticker_id: null, selected_shot_id: null },
+ };
+ const textTrimmed = String(text || "").trim();
+ if (!textTrimmed) return base;
+ try {
+ let p = null;
+ if (parseStateJsonCache.text === textTrimmed) {
+ p = parseStateJsonCache.parsed;
+ } else {
+ p = JSON.parse(textTrimmed);
+ parseStateJsonCache = { text: textTrimmed, parsed: p };
+ }
+ if (!p || typeof p !== "object") return base;
+ const merged = {
+ ...base,
+ ...p,
+ version: 1,
+ projection_model: "pinhole_rectilinear",
+ alpha_mode: "straight",
+ assets: cloneAssetMap(p.assets),
+ stickers: cloneStickerList(p.stickers),
+ shots: cloneShotList(p.shots),
+ ui_settings: {
+ invert_view_x: !!(p.ui_settings && p.ui_settings.invert_view_x),
+ invert_view_y: !!(p.ui_settings && p.ui_settings.invert_view_y),
+ preview_quality: (() => {
+ const q = String(p.ui_settings?.preview_quality || "balanced");
+ return (q === "draft" || q === "balanced" || q === "high") ? q : "balanced";
+ })(),
+ },
+ active: p.active && typeof p.active === "object" ? { ...p.active } : { ...base.active },
+ };
+ if (sharedUi) {
+ merged.ui_settings = normalizeUiSettings({ ...merged.ui_settings, ...sharedUi });
+ }
+ return merged;
+ } catch {
+ parseStateJsonCache = { text: textTrimmed, parsed: null };
+ return base;
+ }
+}
+
+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 stickerCornerDirs(item) {
+ 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 { fwd, right, up } = cameraBasis(
+ Number(item.yaw_deg || 0),
+ Number(item.pitch_deg || 0),
+ Number(item.rot_deg || item.roll_deg || 0),
+ );
+ const mk = (x, y) => norm(add(add(fwd, mul(right, x * tx)), mul(up, y * ty)));
+ return [
+ mk(-1, 1),
+ mk(1, 1),
+ mk(1, -1),
+ mk(-1, -1),
+ ];
+}
+
+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;
+ 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);
+ };
+ node.__panoPreviewImageCache.set(key, { src, img });
+ return img;
+}
+
+function drawImageTriPreview(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;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(d0.x, d0.y);
+ ctx.lineTo(d1.x, d1.y);
+ ctx.lineTo(d2.x, d2.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 drawLatLonGrid(ctx, rect, viewBasis, tanHalfY) {
+ const drawLine = (pts, color, width = 1) => {
+ let open = false;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ for (const p of pts) {
+ if (!p) {
+ open = false;
+ continue;
+ }
+ if (!open) {
+ ctx.moveTo(p.x, p.y);
+ open = true;
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ }
+ ctx.stroke();
+ };
+ const lonVals = [];
+ for (let lon = -180; lon <= 180; lon += 15) lonVals.push(lon);
+ const latVals = [];
+ for (let lat = -75; lat <= 75; lat += 15) latVals.push(lat);
+ lonVals.forEach((lonDeg) => {
+ const pts = [];
+ for (let latDeg = -85; latDeg <= 85; latDeg += 4) {
+ const lat = latDeg * DEG2RAD;
+ const lon = lonDeg * DEG2RAD;
+ const d = vec3(Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon));
+ pts.push(projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ }
+ drawLine(pts, "rgba(61, 61, 66, 0.88)", lonDeg % 90 === 0 ? 1.3 : 1);
+ });
+ latVals.forEach((latDeg) => {
+ const pts = [];
+ for (let lonDeg = -180; lonDeg <= 180; lonDeg += 4) {
+ const lat = latDeg * DEG2RAD;
+ const lon = lonDeg * DEG2RAD;
+ const d = vec3(Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon));
+ pts.push(projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ }
+ drawLine(pts, latDeg === 0 ? "rgba(250, 250, 250, 0.86)" : "rgba(61, 61, 66, 0.88)", latDeg === 0 ? 1.5 : 1);
+ });
+}
+
+function drawStickerPreviewPano(ctx, node, rect, viewBasis, tanHalfY, state, item) {
+ 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 Nu = 12;
+ const Nv = 9;
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const uu = c0x + u * cw;
+ const vv = c0y + v * ch;
+ const x = (uu * 2 - 1) * tx;
+ const y = (1 - vv * 2) * ty;
+ const d = norm(add(add(basis.fwd, mul(basis.right, x)), mul(basis.up, y)));
+ verts[j][i] = projectDirToPreview(d, viewBasis, rect, tanHalfY);
+ sample[j][i] = { x: u, y: v };
+ }
+ }
+
+ const asset = state.assets?.[item.asset_id];
+ const img = getNodePreviewImage(node, item.asset_id, asset);
+ const iw = Math.max(1, Number(img?.naturalWidth || img?.width || 1));
+ const ih = Math.max(1, Number(img?.naturalHeight || img?.height || 1));
+ 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;
+ if (img && img.complete && (img.naturalWidth || 0) > 0) {
+ const s00 = { x: sample[j][i].x * iw, y: sample[j][i].y * ih };
+ const s10 = { x: sample[j][i + 1].x * iw, y: sample[j][i + 1].y * ih };
+ const s01 = { x: sample[j + 1][i].x * iw, y: sample[j + 1][i].y * ih };
+ const s11 = { x: sample[j + 1][i + 1].x * iw, y: sample[j + 1][i + 1].y * ih };
+ drawImageTriPreview(ctx, img, s00, s10, s11, p00, p10, p11);
+ drawImageTriPreview(ctx, img, s00, s11, s01, p00, p11, p01);
+ } else {
+ ctx.fillStyle = "rgba(0, 112, 243, 0.20)";
+ ctx.beginPath();
+ ctx.moveTo(p00.x, p00.y);
+ ctx.lineTo(p10.x, p10.y);
+ ctx.lineTo(p11.x, p11.y);
+ ctx.lineTo(p01.x, p01.y);
+ ctx.closePath();
+ ctx.fill();
+ }
+ }
+ }
+ const corners = stickerCornerDirs(item).map((d) => projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ if (corners.every((p) => !!p)) {
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.85)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(corners[0].x, corners[0].y);
+ ctx.lineTo(corners[1].x, corners[1].y);
+ ctx.lineTo(corners[2].x, corners[2].y);
+ ctx.lineTo(corners[3].x, corners[3].y);
+ ctx.closePath();
+ ctx.stroke();
+ }
+}
+
+function drawPanoramaNodePreview(node, ctx) {
+ const stateWidget = getWidget(node, STATE_WIDGET);
+ const raw = String(stateWidget?.value || "");
+ const bg = String(getWidget(node, "bg_color")?.value || "#00ff00");
+ const state = parseState(raw, 2048, bg);
+
+ const rect = getNodePreviewRect(node);
+ if (!rect) return;
+ if (!node.__panoPreviewView) {
+ const selectedId = state.active?.selected_sticker_id || null;
+ const selected = (state.stickers || []).find((s) => s.id === selectedId) || null;
+ node.__panoPreviewView = {
+ yaw: Number(selected?.yaw_deg || 0),
+ pitch: Number(selected?.pitch_deg || 0),
+ fov: 100,
+ };
+ }
+ applyNodePreviewInertia(node);
+ const viewYaw = Number(node.__panoPreviewView.yaw || 0);
+ const viewPitch = Number(node.__panoPreviewView.pitch || 0);
+ const viewBasis = cameraBasis(viewYaw, viewPitch, 0);
+ const tanHalfY = Math.tan((Number(node.__panoPreviewView.fov || 100) * DEG2RAD) * 0.5);
+
+ ctx.save();
+ ctx.fillStyle = "#0a0a0a";
+ 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();
+
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2);
+ drawLatLonGrid(ctx, rect, viewBasis, tanHalfY);
+
+ const stickers = [...(state.stickers || [])].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ stickers.forEach((item) => drawStickerPreviewPano(ctx, node, rect, viewBasis, tanHalfY, state, item));
+ const labels = [
+ { name: "Left", dir: yawPitchToDir(-90, 0) },
+ { name: "Front", dir: yawPitchToDir(0, 0) },
+ { name: "Right", dir: yawPitchToDir(90, 0) },
+ { name: "Back", dir: yawPitchToDir(180, 0) },
+ ];
+ ctx.fillStyle = "rgba(250, 250, 250, 0.48)";
+ ctx.font = "500 10px Geist, sans-serif";
+ ctx.textAlign = "center";
+ labels.forEach((l) => {
+ const p = projectDirToPreview(l.dir, viewBasis, rect, tanHalfY);
+ if (p) ctx.fillText(l.name, p.x, p.y + 20);
+ });
+
+ const fov = Number(node.__panoPreviewView?.fov || 100);
+ ctx.textAlign = "left";
+ ctx.font = "11px Geist, sans-serif";
+ ctx.fillStyle = "rgba(250, 250, 250, 0.88)";
+ ctx.fillText(`FOV ${fov.toFixed(1)}`, rect.x + 8, rect.y + rect.h - 10);
+
+ const rb = getNodePreviewResetButtonRect(rect);
+ ctx.fillStyle = "rgba(17, 17, 17, 0.92)";
+ ctx.strokeStyle = "rgba(82, 82, 91, 0.95)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(rb.x, rb.y, rb.w, rb.h, 7);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "rgba(250, 250, 250, 0.92)";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.font = "10px Geist, sans-serif";
+ ctx.fillText("Reset", rb.x + rb.w * 0.5, rb.y + rb.h * 0.5 + 0.5);
+ ctx.textBaseline = "alphabetic";
+ ctx.restore();
+
+}
+
+function applyNodePreviewInertia(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 getNodePreviewRect(node) {
+ const pad = 8;
+ const widgetsBottom = getNodeWidgetsBottom(node);
+ const btn = getNodeEditorButtonRect(node);
+ const top = btn ? (btn.y + btn.h + 2) : (widgetsBottom + 2);
+ const x = pad;
+ const w = Math.max(120, Number(node.size?.[0] || 0) - pad * 2);
+ const h = Math.max(84, Number(node.size?.[1] || 0) - top - pad);
+ if (h < 40 || w < 80) return null;
+ return { x, y: top, w, h };
+}
+
+function getNodeWidgetsBottom(node) {
+ const widgetTop = 32;
+ const widgets = Array.isArray(node.widgets) ? node.widgets : [];
+ let y = widgetTop;
+ widgets.forEach((w) => {
+ if (!w || w.hidden || w.type === "hidden") return;
+ let h = 22;
+ try {
+ const size = typeof w.computeSize === "function" ? w.computeSize(node.size?.[0] || 0) : null;
+ if (Array.isArray(size) && Number.isFinite(Number(size[1]))) h = Number(size[1]);
+ } catch {
+ h = 22;
+ }
+ y += h;
+ });
+ return y;
+}
+
+function getNodeEditorButtonRect(node) {
+ if (!node?.__panoCustomEditorButton) return null;
+ const pad = 8;
+ const y = getNodeWidgetsBottom(node) + 2;
+ const w = Math.max(120, Number(node.size?.[0] || 0) - pad * 2);
+ return { x: pad, y, w, h: 30 };
+}
+
+function getNodeAutoHeightWithEditorButton(node) {
+ const button = getNodeEditorButtonRect(node);
+ if (!button) return Math.ceil(getNodeWidgetsBottom(node) + 40);
+ const bottomPad = 8;
+ return Math.ceil(button.y + button.h + bottomPad);
+}
+
+function drawNodeEditorButton(node, ctx) {
+ const r = getNodeEditorButtonRect(node);
+ if (!r) return;
+ const hover = !!node.__panoEditorBtnHover;
+ 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.__panoEditorButtonText || "Open Editor"), r.x + r.w * 0.5, r.y + r.h * 0.5 + 0.5);
+ ctx.restore();
+}
+
+function pointInRect(x, y, r) {
+ return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.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 getWidget(node, name) { return node.widgets?.find((w) => w.name === name) || null; }
+function escapeHtml(text) {
+ return String(text ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """)
+ .replaceAll("'", "'");
+}
+function getEditorNodeTitle(node, type) {
+ const rawType = String(node?.comfyClass || node?.type || node?.title || "").trim();
+ const titleMap = {
+ TY360Roundtrip: "TY360 Roundtrip",
+ "TY360 Roundtrip": "TY360 Roundtrip",
+ TY360Preview: "TY360 Preview",
+ "TY360 Preview": "TY360 Preview",
+ TY360Pasteback: "TY360 Pasteback",
+ "TY360 Pasteback": "TY360 Pasteback",
+ };
+ if (titleMap[rawType]) return titleMap[rawType];
+ if (rawType) return rawType;
+ if (type === "cutout") return "TY360 Roundtrip";
+ return "TY360 Preview";
+}
+function hideWidget(node, widgetName) {
+ const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
+ widgets.forEach((w) => {
+ const n = String(w?.name || "");
+ if (!(n === widgetName || n.trim() === widgetName || n.toLowerCase().includes(String(widgetName).toLowerCase()))) return;
+ if (w.__panoHidden) return;
+ w.__panoHidden = true;
+ w.computeSize = () => [0, 0];
+ w.type = "hidden";
+ w.hidden = true;
+ w.options = { ...(w.options || {}), hidden: true };
+ if (w.inputEl?.style) w.inputEl.style.display = "none";
+ if (w.parentEl?.style) w.parentEl.style.display = "none";
+ });
+}
+
+function ensureActionButtonWidget(node, buttonText, callback) {
+ if (!node || typeof node.addWidget !== "function") return null;
+ const widgets = Array.isArray(node.widgets) ? node.widgets : [];
+ let widget = widgets.find((w) => String(w?.name || "") === String(buttonText));
+ if (widget) {
+ widget.callback = callback;
+ widget.hidden = false;
+ widget.__panoHidden = false;
+ widget.type = "button";
+ if (widget.inputEl?.style) widget.inputEl.style.display = "";
+ if (widget.parentEl?.style) widget.parentEl.style.display = "";
+ if (typeof widget.computeSize !== "function" || widget.computeSize() == null || widget.hidden) {
+ widget.computeSize = () => [Math.max(120, Number(node?.size?.[0] || 0) - 20), 30];
+ }
+ return widget;
+ }
+ widget = node.addWidget("button", buttonText, null, callback);
+ if (widget) {
+ widget.serialize = false;
+ }
+ return widget;
+}
+function uid(prefix) { return `${prefix}_${Math.random().toString(16).slice(2, 10)}`; }
+
+function parseOutputPresetValue(v, fallback = 2048) {
+ if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
+ const s = String(v ?? "").trim();
+ if (!s) return fallback;
+ const head = s.includes("x") ? s.split("x", 1)[0].trim() : s;
+ const n = Number(head);
+ return Number.isFinite(n) ? Math.round(n) : fallback;
+}
+
+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 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: Use strictly direct lookup.
+ // Iterating all outputs every frame causes massive lag when many nodes are present.
+ const raw = String(nodeId);
+ if (store instanceof Map) {
+ return store.get(nodeId) || store.get(raw) || store.get(Number(raw)) || null;
+ }
+ return store[nodeId] || store[raw] || 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 findLinkedInputImageSource(node, preferredInputNames = []) {
+ const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
+ if (!inputs.length) return { src: "", sourceType: "", inputName: "" };
+ const preferred = preferredInputNames
+ .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])];
+
+ 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);
+ const resolvedOriginSlot = Number(originSlot || 0);
+ if (!originNode) continue;
+
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ return { src, sourceType: "selfOutput", inputName: "fallback" };
+ }
+ }
+ }
+
+ return { src: "", sourceType: "", inputName: "" };
+}
+
+function getLinkedInputImage(node, preferredInputNames = [], onLoad = null) {
+ 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 { }
+ return;
+ }
+ const nextSrc = candidates[attempt];
+ cacheEntry.resolvedSrc = nextSrc;
+ img.src = nextSrc;
+ };
+
+ img.onload = () => {
+ onLoad?.();
+ node.setDirtyCanvas?.(true, true);
+ };
+ img.onerror = (ev) => {
+ if (attempt + 1 < candidates.length) {
+ tryLoadNext();
+ return;
+ }
+ try { node.__panoLinkedInputImageCache?.delete?.(key); } catch { }
+ };
+ tryLoadNext();
+ return img;
+}
+
+function showEditor(node, type, options = {}) {
+ const readOnly = options?.readOnly === true;
+ const hideSidebar = options?.hideSidebar ?? readOnly;
+ const previewMode = readOnly;
+ const nodeTitle = getEditorNodeTitle(node, type);
+ const sideTitleHtml = `${ICON.globe}${escapeHtml(nodeTitle)}`;
+ installCss();
+ const presetWidget = getWidget(node, "output_preset");
+ const bgWidget = getWidget(node, "bg_color");
+ const stateWidget = getWidget(node, STATE_WIDGET);
+
+ const state = parseState(
+ String(stateWidget?.value || ""),
+ parseOutputPresetValue(presetWidget?.value, 2048),
+ String(bgWidget?.value || "#00ff00"),
+ );
+ node.__panoLiveStateOverride = JSON.stringify(state);
+ node.__panoDomPreview?.requestDraw?.();
+ node.setDirtyCanvas?.(true, true);
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+
+ if (type === "cutout") {
+ state.shots = Array.isArray(state.shots) ? state.shots.slice(0, 1) : [];
+ if (!state.shots.length) {
+ state.active.selected_shot_id = null;
+ }
+ }
+ const overlay = document.createElement("div");
+ overlay.className = "pano-modal-overlay";
+ const root = document.createElement("div");
+ root.className = "pano-modal";
+ const bottomToolbarHtml = previewMode ? "" : `
+
+
+
+
+ ${type === "cutout" ? `` : ""}
+
+
+ `;
+ const fullscreenBtnHtml = previewMode
+ ? ``
+ : "";
+ root.innerHTML = `
+
+
+
+
Drag and drop image here
+
+
+
+
+
+
+
+ ${bottomToolbarHtml}
+
+ FOV
+ 100.0
+
+
+ ${fullscreenBtnHtml}
+
+
+
+
+
+
+ `;
+
+ overlay.appendChild(root);
+ document.body.appendChild(overlay);
+
+ const canvas = root.querySelector("canvas");
+ const stageWrap = root.querySelector(".pano-stage-wrap");
+ const ctx = canvas.getContext("2d");
+ const side = root.querySelector("[data-side]");
+ const viewBtns = root.querySelectorAll("[data-view]");
+ const viewToggle = root.querySelector(".pano-view-toggle");
+ const fovValueEl = root.querySelector("[data-fov-value]");
+ const selectionMenu = root.querySelector("[data-selection-menu]");
+ const outputPreviewToggleBtn = root.querySelector("[data-action='toggle-output-preview-size']");
+ const lookAtFrameBtn = root.querySelector("[data-action='look-at-frame']");
+ const fullscreenBtn = root.querySelector("[data-action='toggle-fullscreen']");
+ const tooltipEl = root.querySelector("[data-tooltip]");
+ if (type === "cutout") canvas.style.opacity = "0";
+ if (hideSidebar) {
+ side?.remove();
+ root.classList.add("pano-modal-readonly");
+ }
+ root.addEventListener("pointerdown", (ev) => {
+ hideTooltip();
+ if (ev.target.closest(".pano-picker")) return;
+ root.querySelectorAll(".pano-picker-pop").forEach((el) => {
+ el.hidden = true;
+ });
+ if (type === "cutout" && editor.cutoutAspectOpen && !ev.target.closest(".pano-aspect-popover") && !ev.target.closest("[data-action='aspect']")) {
+ editor.cutoutAspectOpen = false;
+ editor.menuMode = "";
+ editor.menuSize.measured = false;
+ updateSelectionMenu();
+ requestDraw();
+ }
+ });
+
+ const editor = {
+ mode: "pano",
+ selectedId: type === "stickers" ? state.active.selected_sticker_id : state.active.selected_shot_id,
+ viewYaw: 0,
+ viewPitch: 0,
+ viewFov: 100,
+ history: [],
+ historyIndex: -1,
+ interaction: null,
+ hqFrames: 0,
+ viewInertia: { vx: 0, vy: 0, active: false },
+ menuSize: { w: 220, h: 40, measured: false },
+ menuMode: "",
+ cutoutAspectOpen: false,
+ showGrid: getNodeGridVisibility(node?.id, true),
+ outputPreviewExpanded: false,
+ outputPreviewAnim: 0,
+ outputPreviewAnimFrom: 0,
+ outputPreviewAnimTo: 0,
+ outputPreviewAnimStartTs: 0,
+ outputPreviewAnimDurationMs: 180,
+ outputPreviewRect: null,
+ panelLastValues: null,
+ panelWasEnabled: false,
+ viewTween: null,
+ fullscreen: false,
+ fullscreenPrevShowGrid: null,
+ };
+ if (type === "stickers") {
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ }
+ const imageCache = new Map();
+ const runtime = {
+ dirty: true,
+ rafId: 0,
+ running: true,
+ lastTickTs: 0,
+ lastSizeCheckTs: 0,
+ pendingStableLayoutFrames: type === "cutout" ? 2 : 0,
+ hasPresentedFrame: type !== "cutout",
+ };
+ const tooltip = {
+ timer: 0,
+ target: null,
+ };
+ const dragCue = {
+ active: false,
+ depth: 0,
+ };
+
+ function dragHasImageFiles(e) {
+ const dt = e?.dataTransfer;
+ if (!dt) return false;
+ if (dt.items && dt.items.length) {
+ for (const item of dt.items) {
+ if (!item || item.kind !== "file") continue;
+ const t = String(item.type || "").toLowerCase();
+ if (!t || t.startsWith("image/")) return true;
+ }
+ return false;
+ }
+ if (dt.files && dt.files.length) {
+ return Array.from(dt.files).some((f) => isImageFile(f));
+ }
+ return false;
+ }
+
+ function setDropCue(on) {
+ const next = !!on;
+ if (dragCue.active === next) return;
+ dragCue.active = next;
+ stageWrap.classList.toggle("drop-active", next);
+ }
+
+ function startViewTween(targetYaw, targetPitch, targetFov = editor.viewFov, minMs = 140, maxMs = 620) {
+ const dyaw = shortestYawDelta(editor.viewYaw, targetYaw);
+ const dpitch = targetPitch - editor.viewPitch;
+ const dfov = targetFov - editor.viewFov;
+ const dist = Math.hypot(dyaw, dpitch) + Math.abs(dfov) * 0.6;
+ const durationMs = Math.round(clamp(minMs + dist * 2.2, minMs, maxMs));
+ editor.viewTween = {
+ active: true,
+ startTs: performance.now(),
+ durationMs,
+ startYaw: editor.viewYaw,
+ startPitch: editor.viewPitch,
+ startFov: editor.viewFov,
+ targetPitch,
+ targetFov,
+ deltaYaw: dyaw,
+ };
+ editor.viewInertia.active = false;
+ editor.viewInertia.vx = 0;
+ editor.viewInertia.vy = 0;
+ requestDraw();
+ }
+
+ // Coordinate sanity: front-facing sticker should have top edge above bottom edge.
+ const __sanity = stickerCornerOrderSanity();
+ void __sanity;
+
+ function getList() { return type === "stickers" ? state.stickers : state.shots; }
+ function getSelected() { return getList().find((s) => s.id === editor.selectedId) || null; }
+ function getNextStickerZIndex() {
+ const stickers = Array.isArray(state.stickers) ? state.stickers : [];
+ return stickers.reduce((acc, item) => {
+ const next = Number(item?.z_index);
+ return Math.max(acc, Number.isFinite(next) ? next : 0);
+ }, -1) + 1;
+ }
+ function isExternalSticker(item) {
+ if (!item || typeof item !== "object") return false;
+ return String(item.id || "") === EXTERNAL_STICKER_ID
+ || String(item.source_kind || "") === EXTERNAL_STICKER_SOURCE_KIND;
+ }
+ function isStickerHidden(item) {
+ return !!(item && typeof item === "object" && item.visible === false);
+ }
+ function getStickerDisplayAlpha(item) {
+ if (isExternalSticker(item) && isStickerHidden(item)) return 0.2;
+ return 1;
+ }
+ function restoreSelectedToInitialPose() {
+ if (readOnly || type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected || !isExternalSticker(selected)) return;
+ const initial = selected.initial_pose;
+ if (!initial || typeof initial !== "object") return;
+ selected.yaw_deg = Number(initial.yaw_deg ?? selected.yaw_deg ?? 0);
+ selected.pitch_deg = Number(initial.pitch_deg ?? selected.pitch_deg ?? 0);
+ selected.hFOV_deg = Number(initial.hFOV_deg ?? selected.hFOV_deg ?? 30);
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ requestDraw();
+ });
+ if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ selected.vFOV_deg = computeStickerVFov(
+ Number(initial.hFOV_deg ?? selected.hFOV_deg ?? 30),
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ } else {
+ selected.vFOV_deg = Number(initial.vFOV_deg ?? selected.vFOV_deg ?? 30);
+ }
+ selected.rot_deg = Number(initial.rot_deg ?? selected.rot_deg ?? 0);
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ }
+ function getRestorePoseForSticker(item) {
+ if (!item || !isExternalSticker(item)) return null;
+ const initial = item.initial_pose;
+ if (!initial || typeof initial !== "object") return null;
+ const pose = {
+ yaw_deg: Number(initial.yaw_deg ?? item.yaw_deg ?? 0),
+ pitch_deg: Number(initial.pitch_deg ?? item.pitch_deg ?? 0),
+ hFOV_deg: Number(initial.hFOV_deg ?? item.hFOV_deg ?? 30),
+ vFOV_deg: Number(initial.vFOV_deg ?? item.vFOV_deg ?? 30),
+ rot_deg: Number(initial.rot_deg ?? item.rot_deg ?? 0),
+ };
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ requestDraw();
+ });
+ if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ pose.vFOV_deg = computeStickerVFov(
+ pose.hFOV_deg,
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ }
+ return pose;
+ }
+ function canRestoreSelectedToInitial() {
+ const selected = getSelected();
+ if (!selected || !isExternalSticker(selected)) return false;
+ const restorePose = getRestorePoseForSticker(selected);
+ if (!restorePose) return false;
+ const close = (a, b) => Math.abs(Number(a || 0) - Number(b || 0)) <= 1e-4;
+ return !(
+ close(selected.yaw_deg, restorePose.yaw_deg)
+ && close(selected.pitch_deg, restorePose.pitch_deg)
+ && close(selected.hFOV_deg, restorePose.hFOV_deg)
+ && close(selected.vFOV_deg, restorePose.vFOV_deg)
+ && close(selected.rot_deg, restorePose.rot_deg)
+ );
+ }
+ function getNodeUiList(key) {
+ const outputs = lookupNodeOutputEntry(node?.id);
+ if (Array.isArray(outputs?.ui?.[key])) return outputs.ui[key];
+ if (Array.isArray(outputs?.[key])) return outputs[key];
+ return [];
+ }
+ function getNodeUiValue(key) {
+ const outputs = lookupNodeOutputEntry(node?.id);
+ if (outputs?.ui && Object.prototype.hasOwnProperty.call(outputs.ui, key)) return outputs.ui[key];
+ if (outputs && Object.prototype.hasOwnProperty.call(outputs, key)) return outputs[key];
+ return null;
+ }
+ function normalizeInputPoseValue(value, debugValue = null) {
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
+ if (Array.isArray(value) && value.length > 0) {
+ const first = value[0];
+ if (first && typeof first === "object" && !Array.isArray(first)) return first;
+ }
+ if (Array.isArray(debugValue) && debugValue.length > 0) {
+ const parsed = debugValue[0]?.parsed_state;
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return {
+ yaw_deg: Number(parsed.yaw_deg || 0),
+ pitch_deg: Number(parsed.pitch_deg || 0),
+ hFOV_deg: Number(parsed.hFOV_deg || 30),
+ rot_deg: Number(parsed.roll_deg || 0),
+ };
+ }
+ }
+ return null;
+ }
+ function getStickerUiImage(key, onLoad = null) {
+ const list = getNodeUiList(key);
+ const first = Array.isArray(list) && list.length ? list[0] : null;
+ const src = imageSourceFromCandidate(first);
+ if (!src) return null;
+ const cacheKey = `__ui__${key}`;
+ const cached = imageCache.get(cacheKey);
+ if (cached && cached.__panoSrc === src) return cached;
+ const img = new Image();
+ img.__panoSrc = src;
+ img.onload = () => {
+ if (typeof onLoad === "function") onLoad(img);
+ else requestDraw();
+ };
+ img.src = src;
+ imageCache.set(cacheKey, img);
+ return img;
+ }
+ function hashStringSimple(text) {
+ const s = String(text || "");
+ let h = 2166136261;
+ for (let i = 0; i < s.length; i += 1) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return String(h >>> 0);
+ }
+ function computeStickerVFov(hFovDeg, width, height) {
+ const w = Math.max(1, Number(width || 1));
+ const h = Math.max(1, Number(height || 1));
+ const hf = clamp(Number(hFovDeg || 30), 0.1, 179) * DEG2RAD;
+ const vf = 2 * Math.atan(Math.tan(hf * 0.5) * (h / w));
+ return clamp(vf * RAD2DEG, 0.1, 179);
+ }
+ function parseLinkedStickerState(raw) {
+ const text = String(raw || "").trim();
+ if (!text) return null;
+ try {
+ const parsed = JSON.parse(text);
+ if (!parsed || typeof parsed !== "object") return null;
+ if (String(parsed.kind || "") !== "pano_sticker_state") return null;
+ const versionValue = parsed.version;
+ let version = null;
+ if (typeof versionValue === "number" && Number.isInteger(versionValue)) {
+ version = versionValue;
+ } else if (typeof versionValue === "string" && /^\d+$/.test(versionValue)) {
+ version = Number.parseInt(versionValue, 10);
+ }
+ if (version !== 1) return null;
+ const pose = parsed.pose;
+ if (!pose || typeof pose !== "object") return null;
+ const yawRaw = Number(pose.yaw_deg);
+ const pitchRaw = Number(pose.pitch_deg);
+ const rollRaw = Number(pose.roll_deg);
+ const hRaw = Number(pose.hFOV_deg);
+ if (![yawRaw, pitchRaw, rollRaw, hRaw].every((v) => Number.isFinite(v))) return null;
+ let yaw = ((yawRaw + 180) % 360 + 360) % 360 - 180;
+ if (Object.is(yaw, -0)) yaw = 0;
+ const out = {
+ yaw_deg: yaw,
+ pitch_deg: clamp(pitchRaw, -89.9, 89.9),
+ roll_deg: rollRaw,
+ hFOV_deg: clamp(hRaw, 0.1, 179),
+ };
+ const sourceAspect = Number(parsed.source_aspect);
+ if (Number.isFinite(sourceAspect) && sourceAspect > 0) out.source_aspect = sourceAspect;
+ return out;
+ } catch {
+ return null;
+ }
+ }
+ function buildCanonicalCutoutStickerState(item) {
+ const widthRaw = Number(item?.out_w);
+ const heightRaw = Number(item?.out_h);
+ const width = Math.max(1, Number.isFinite(widthRaw) ? widthRaw : 1024);
+ const height = Math.max(1, Number.isFinite(heightRaw) ? heightRaw : 1024);
+ const yawRaw = Number(item?.yaw_deg);
+ const pitchRaw = Number(item?.pitch_deg);
+ const rollRaw = Number(item?.roll_deg ?? item?.rot_deg);
+ const hFovRaw = Number(item?.hFOV_deg);
+ return {
+ kind: "pano_sticker_state",
+ version: 1,
+ pose: {
+ yaw_deg: wrapYaw(Number.isFinite(yawRaw) ? yawRaw : 0),
+ pitch_deg: clamp(Number.isFinite(pitchRaw) ? pitchRaw : 0, -89.9, 89.9),
+ roll_deg: Number.isFinite(rollRaw) ? rollRaw : 0,
+ hFOV_deg: clamp(Number.isFinite(hFovRaw) ? hFovRaw : 90, 0.1, 179),
+ },
+ source_aspect: width / height,
+ };
+ }
+ function buildCanonicalSelectedStickerState(item) {
+ if (!item || typeof item !== "object") return buildCanonicalCutoutStickerState(null);
+ const yawRaw = Number(item?.yaw_deg);
+ const pitchRaw = Number(item?.pitch_deg);
+ const rollRaw = Number(item?.roll_deg ?? item?.rot_deg);
+ const hFovRaw = Number(item?.hFOV_deg);
+ const vFovRaw = Number(item?.vFOV_deg);
+ let sourceAspect = 1;
+ if (Number.isFinite(hFovRaw) && Number.isFinite(vFovRaw)) {
+ const hf = clamp(hFovRaw, 0.1, 179) * DEG2RAD;
+ const vf = clamp(vFovRaw, 0.1, 179) * DEG2RAD;
+ const tanV = Math.tan(vf * 0.5);
+ if (Math.abs(tanV) > 1e-6) {
+ const ratio = Math.tan(hf * 0.5) / tanV;
+ if (Number.isFinite(ratio) && ratio > 0) sourceAspect = ratio;
+ }
+ }
+ if (item?.asset_id && state?.assets?.[item.asset_id]) {
+ const asset = state.assets[item.asset_id];
+ const width = Number(asset?.w || 0);
+ const height = Number(asset?.h || 0);
+ if (width > 0 && height > 0) sourceAspect = width / height;
+ }
+ return {
+ kind: "pano_sticker_state",
+ version: 1,
+ pose: {
+ yaw_deg: wrapYaw(Number.isFinite(yawRaw) ? yawRaw : 0),
+ pitch_deg: clamp(Number.isFinite(pitchRaw) ? pitchRaw : 0, -89.9, 89.9),
+ roll_deg: Number.isFinite(rollRaw) ? rollRaw : 0,
+ hFOV_deg: clamp(Number.isFinite(hFovRaw) ? hFovRaw : 30, 0.1, 179),
+ },
+ source_aspect: sourceAspect,
+ };
+ }
+ function getLinkedStringInputValue(inputName) {
+ const input = Array.isArray(node?.inputs)
+ ? node.inputs.find((entry) => String(entry?.name || "") === String(inputName))
+ : null;
+ const linkId = input?.link;
+ if (linkId != null) {
+ const linkInfo = getGraphLinkById(node.graph, linkId);
+ const { originId, originSlot } = resolveOriginFromLinkInfo(linkInfo);
+ const outputs = lookupNodeOutputEntry(originId);
+ const groups = [
+ outputs?.output,
+ outputs?.result,
+ outputs?.data?.output,
+ outputs?.data?.result,
+ outputs?.ui?.output,
+ outputs?.ui?.result,
+ ];
+ for (const group of groups) {
+ if (!Array.isArray(group)) continue;
+ const idx = Number(originSlot || 0);
+ const val = group[idx];
+ if (typeof val === "string" && val.trim()) return val;
+ }
+ }
+ return String(getWidget(node, inputName)?.value || "");
+ }
+ function buildExternalInitialPose(inputPose, stateRaw, previewImg) {
+ const parsed = (inputPose && typeof inputPose === "object")
+ ? {
+ yaw_deg: Number(inputPose.yaw_deg || 0),
+ pitch_deg: Number(inputPose.pitch_deg || 0),
+ roll_deg: Number(inputPose.rot_deg ?? inputPose.roll_deg ?? 0),
+ hFOV_deg: Number(inputPose.hFOV_deg || 30),
+ }
+ : parseLinkedStickerState(stateRaw);
+ if (parsed) {
+ const width = Number(previewImg?.naturalWidth || previewImg?.width || parsed.source_aspect || 1);
+ const height = Number(previewImg?.naturalHeight || previewImg?.height || 1);
+ return {
+ yaw_deg: Number(parsed.yaw_deg || 0),
+ pitch_deg: Number(parsed.pitch_deg || 0),
+ hFOV_deg: Number(parsed.hFOV_deg || 30),
+ vFOV_deg: computeStickerVFov(parsed.hFOV_deg, width, height),
+ rot_deg: Number(parsed.roll_deg || 0),
+ };
+ }
+ const width = Number(previewImg?.naturalWidth || previewImg?.width || 1);
+ const height = Number(previewImg?.naturalHeight || previewImg?.height || 1);
+ return {
+ yaw_deg: Number(editor.viewYaw || 0),
+ pitch_deg: Number(editor.viewPitch || 0),
+ hFOV_deg: 30,
+ vFOV_deg: computeStickerVFov(30, width, height),
+ rot_deg: 0,
+ };
+ }
+ function reconcileExternalStickerFromInputs(reason = "sync") {
+ if (type !== "stickers" || readOnly) return;
+ const input = Array.isArray(node?.inputs)
+ ? node.inputs.find((entry) => String(entry?.name || "") === "sticker_image")
+ : null;
+ const linkId = input?.link ?? null;
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ node.__panoExternalStickerSync?.("image-loaded");
+ });
+ const inputPose = normalizeInputPoseValue(getNodeUiValue("pano_sticker_input_pose"), null);
+ const stateRaw = getLinkedStringInputValue("sticker_state");
+ const stateHash = inputPose && typeof inputPose === "object"
+ ? hashStringSimple(JSON.stringify(inputPose))
+ : hashStringSimple(stateRaw);
+ const stickers = Array.isArray(state.stickers) ? state.stickers : (state.stickers = []);
+ const existingIndex = stickers.findIndex((item) => String(item?.id || "") === EXTERNAL_STICKER_ID);
+ if (linkId == null) {
+ if (existingIndex >= 0) {
+ stickers.splice(existingIndex, 1);
+ if (editor.selectedId === EXTERNAL_STICKER_ID) {
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ }
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ }
+ return;
+ }
+ const maxZ = stickers.reduce((acc, item) => Math.max(acc, Number(item?.z_index || 0)), -1);
+ let target = existingIndex >= 0 ? stickers[existingIndex] : null;
+ const sourceChanged = !target
+ || Number(target.source_link_id ?? -1) !== Number(linkId)
+ || String(target.source_state_hash || "") !== stateHash;
+ if (!target) {
+ target = {
+ id: EXTERNAL_STICKER_ID,
+ source_kind: EXTERNAL_STICKER_SOURCE_KIND,
+ };
+ stickers.push(target);
+ }
+ target.id = EXTERNAL_STICKER_ID;
+ target.source_kind = EXTERNAL_STICKER_SOURCE_KIND;
+ target.source_link_id = Number(linkId);
+ target.source_state_hash = stateHash;
+ target.visible = target.visible !== false;
+ let stateChanged = false;
+ if (sourceChanged) {
+ const pose = buildExternalInitialPose(inputPose, stateRaw, previewImg);
+ Object.assign(target, pose, {
+ initial_pose: { ...pose },
+ visible: true,
+ z_index: maxZ + 1,
+ });
+ stateChanged = true;
+ } else if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ const nextVFov = computeStickerVFov(
+ Number(target.hFOV_deg || 30),
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ if (Math.abs(Number(target.vFOV_deg || 0) - nextVFov) > 1e-6) {
+ target.vFOV_deg = nextVFov;
+ stateChanged = true;
+ }
+ }
+ if (stateChanged) {
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ }
+ requestDraw();
+ void reason;
+ }
+ function applyInitialCutoutFocus() {
+ if (type !== "cutout") return;
+ const shots = getList();
+ if (!Array.isArray(shots) || shots.length === 0) return;
+ const preferredId = String(state.active?.selected_shot_id || editor.selectedId || "");
+ const target = shots.find((s) => String(s?.id || "") === preferredId) || shots[0];
+ if (!target) return;
+ editor.selectedId = target.id || null;
+ state.active.selected_shot_id = editor.selectedId;
+ editor.viewYaw = wrapYaw(Number(target.yaw_deg || 0));
+ editor.viewPitch = clamp(Number(target.pitch_deg || 0), -89.9, 89.9);
+ }
+ function syncLookAtFrameButtonState() {
+ if (!lookAtFrameBtn) return;
+ const enabled = type === "cutout" && getList().length > 0;
+ lookAtFrameBtn.disabled = !enabled;
+ lookAtFrameBtn.setAttribute("aria-disabled", enabled ? "false" : "true");
+ }
+
+ function stickerCornerOrderSanity() {
+ const test = {
+ yaw_deg: 0, pitch_deg: 0, hFOV_deg: 20, vFOV_deg: 20, rot_deg: 0,
+ };
+ const dirs = stickerCornersDir(test);
+ if (!dirs || dirs.length !== 4) return false;
+ // top-left y should be >= bottom-left y in world-up axis
+ return dirs[0].y >= dirs[3].y;
+ }
+
+ function cameraBasis() {
+ const fwd = yawPitchToDir(editor.viewYaw, editor.viewPitch);
+ let upWorld = vec3(0, 1, 0);
+ if (Math.abs(dot(fwd, upWorld)) > 0.999) upWorld = vec3(0, 0, 1);
+ const right = norm(cross(upWorld, fwd));
+ const up = norm(cross(fwd, right));
+ return { right, up, fwd };
+ }
+
+ function projectDir(dir) {
+ const { right, up, fwd } = cameraBasis();
+ const cx = dot(dir, right);
+ const cy = dot(dir, up);
+ const cz = dot(dir, fwd);
+ if (cz <= 1e-5) return null;
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / w));
+ const sx = (w / 2) / Math.tan(hfov / 2);
+ const sy = (h / 2) / Math.tan(vfov / 2);
+ return {
+ x: w / 2 + (cx / cz) * sx,
+ y: h / 2 - (cy / cz) * sy,
+ z: cz,
+ };
+ }
+
+ function screenToWorldDir(x, y) {
+ const { right, up, fwd } = cameraBasis();
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / w));
+ const nx = ((x - w / 2) / (w / 2)) * Math.tan(hfov / 2);
+ const ny = ((h / 2 - y) / (h / 2)) * Math.tan(vfov / 2);
+ const world = add(add(mul(right, nx), mul(up, ny)), fwd);
+ return norm(world);
+ }
+
+ function getUnwrapRect() {
+ const w = canvas.width;
+ const h = canvas.height;
+ const targetAR = 2.0; // ERP 2:1
+ const canvasAR = w / Math.max(h, 1);
+ if (canvasAR >= targetAR) {
+ const rh = h;
+ const rw = rh * targetAR;
+ const rx = (w - rw) * 0.5;
+ return { x: rx, y: 0, w: rw, h: rh };
+ }
+ const rw = w;
+ const rh = rw / targetAR;
+ const ry = (h - rh) * 0.5;
+ return { x: 0, y: ry, w: rw, h: rh };
+ }
+
+ function getStickerImage(stickerOrAssetId) {
+ if (stickerOrAssetId && typeof stickerOrAssetId === "object"
+ && (isExternalSticker(stickerOrAssetId) || stickerOrAssetId.external === true)) {
+ return getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ node.__panoExternalStickerSync?.("image-loaded");
+ });
+ }
+ const assetId = (stickerOrAssetId && typeof stickerOrAssetId === "object")
+ ? String(stickerOrAssetId.asset_id || stickerOrAssetId.assetId || "")
+ : String(stickerOrAssetId || "");
+ if (!assetId) return null;
+ const cached = imageCache.get(assetId);
+ if (cached) return cached;
+ const asset = state.assets?.[assetId];
+ const src = stickerAssetToPreviewSrc(asset);
+ if (!src) return null;
+ const img = new Image();
+ img.onload = () => requestDraw();
+ img.src = src;
+ imageCache.set(assetId, img);
+ return img;
+ }
+
+ function buildEditorStickerScene() {
+ return buildStickerSceneFromState(state, {
+ selectedId: editor.selectedId || null,
+ hoveredId: null,
+ includeHidden: true,
+ });
+ }
+
+ function buildEditorStickerTextures(scene) {
+ return buildStickerTexturesFromState(
+ state,
+ (assetId, asset, item) => getStickerImage(item || assetId),
+ { scene },
+ );
+ }
+
+ async function uploadStickerAssetFile(file, fallbackName = "sticker.png") {
+ const body = new FormData();
+ body.append("image", file);
+ body.append("type", "input");
+ body.append("subfolder", "ty360_photo_edit");
+ const resp = await api.fetchApi("/upload/image", { method: "POST", body });
+ if (!resp || resp.status !== 200) {
+ throw new Error(`upload failed (${resp?.status || "no-response"})`);
+ }
+ const data = await resp.json();
+ const filename = String(data?.name || "").trim();
+ if (!filename) {
+ throw new Error("upload response missing filename");
+ }
+ return {
+ type: "comfy_image",
+ filename,
+ subfolder: String(data?.subfolder || "ty360_photo_edit"),
+ storage: String(data?.type || "input"),
+ name: String(file?.name || fallbackName),
+ };
+ }
+
+ function getConnectedErpImage() {
+ const inputNames = Array.isArray(node?.inputs)
+ ? node.inputs.map((i) => String(i?.name || ""))
+ : [];
+ const hasEpr = inputNames.includes("erp_image");
+ const hasBg = inputNames.includes("bg_erp");
+ let preferred = [];
+ if (readOnly && (hasEpr || hasBg)) {
+ preferred = hasEpr ? ["erp_image", "bg_erp"] : ["bg_erp", "erp_image"];
+ } else {
+ preferred = type === "stickers" ? ["bg_erp", "erp_image"] : ["erp_image", "bg_erp"];
+ }
+ const img = getLinkedInputImage(node, preferred, () => requestDraw());
+ return img;
+ }
+
+ function getWrappedErpCanvas(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 drawErpBackgroundUnwrap(rect) {
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) return;
+ if (renderErpViewToContext2D({
+ owner: node,
+ cacheKey: "modal_unwrap_bg",
+ ctx,
+ rect,
+ img,
+ mode: "unwrap",
+ backgroundOpacity: 0.94,
+ })) {
+ return;
+ }
+ ctx.save();
+ ctx.globalAlpha = 0.94;
+ ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h);
+ ctx.restore();
+ }
+
+ function drawErpBackgroundPano() {
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) return;
+ if (renderErpViewToContext2D({
+ owner: node,
+ cacheKey: "modal_pano_bg",
+ ctx,
+ rect: { x: 0, y: 0, w: canvas.width, h: canvas.height },
+ img,
+ mode: "panorama",
+ yawDeg: Number(editor.viewYaw || 0),
+ pitchDeg: Number(editor.viewPitch || 0),
+ fovDeg: Number(editor.viewFov || 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(img);
+ if (!wrapped) return;
+ const q = String(state.ui_settings?.preview_quality || "balanced");
+ const cacheKey = [
+ String(img.src || ""),
+ iw,
+ ih,
+ canvas.width,
+ canvas.height,
+ q,
+ Number(editor.viewYaw || 0).toFixed(4),
+ Number(editor.viewPitch || 0).toFixed(4),
+ Number(editor.viewFov || 100).toFixed(4),
+ ].join("|");
+ const cached = node.__panoPanoBackgroundCache;
+ if (cached?.canvas && cached.key === cacheKey) {
+ ctx.drawImage(cached.canvas, 0, 0);
+ return;
+ }
+ const bgCanvas = document.createElement("canvas");
+ bgCanvas.width = canvas.width;
+ bgCanvas.height = canvas.height;
+ const bgCtx = bgCanvas.getContext("2d");
+ if (!bgCtx) return;
+ const Nu = q === "high" ? 44 : (q === "draft" ? 24 : 32);
+ const Nv = q === "high" ? 28 : (q === "draft" ? 14 : 20);
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const x = (canvas.width * i) / Nu;
+ const y = (canvas.height * j) / Nv;
+ const d = screenToWorldDir(x, y);
+ const ll = dirToLonLat(d);
+ let u = (ll.lon / (2 * Math.PI) + 0.5) * iw;
+ while (u < 0) u += iw;
+ while (u >= iw) u -= iw;
+ const v = (0.5 - ll.lat / Math.PI) * ih;
+ verts[j][i] = { x, y };
+ sample[j][i] = { x: u, y: v };
+ }
+ }
+
+ bgCtx.save();
+ bgCtx.globalAlpha = 0.94;
+ 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;
+ });
+ }
+ drawImageTriTo(bgCtx, wrapped, s00, s10, s11, p00, p10, p11);
+ drawImageTriTo(bgCtx, wrapped, s00, s11, s01, p00, p11, p01);
+ }
+ }
+ bgCtx.restore();
+ node.__panoPanoBackgroundCache = { key: cacheKey, canvas: bgCanvas };
+ ctx.drawImage(bgCanvas, 0, 0);
+ }
+
+ function pruneUnusedAssets() {
+ if (type !== "stickers") return;
+ const used = new Set(
+ (state.stickers || [])
+ .map((s) => String(s?.asset_id || ""))
+ .filter((id) => !!id),
+ );
+ Object.keys(state.assets || {}).forEach((id) => {
+ if (!used.has(id)) {
+ delete state.assets[id];
+ imageCache.delete(id);
+ }
+ });
+ }
+
+ function dirToLonLat(d) {
+ return {
+ lon: Math.atan2(d.x, d.z),
+ lat: Math.asin(clamp(d.y, -1, 1)),
+ };
+ }
+
+ function projectDirUnwrap(d, refX = null) {
+ const { lon, lat } = dirToLonLat(d);
+ const r = getUnwrapRect();
+ let x = r.x + ((lon / (2 * Math.PI)) + 0.5) * r.w;
+ const y = r.y + (0.5 - (lat / Math.PI)) * r.h;
+ if (refX !== null) {
+ while (x - refX > r.w / 2) x -= r.w;
+ while (x - refX < -r.w / 2) x += r.w;
+ }
+ return { x, y, z: 1 };
+ }
+
+ function getStickerFrame(item) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ let upWorld = vec3(0, 1, 0);
+ if (Math.abs(dot(centerDir, upWorld)) > 0.999) upWorld = vec3(0, 0, 1);
+ const right = norm(cross(upWorld, centerDir));
+ const up = norm(cross(centerDir, right));
+
+ const tanX = Math.tan(clamp(Number(item.hFOV_deg || 20), 0.1, 179) * 0.5 * DEG2RAD);
+ const tanY = Math.tan(clamp(Number(item.vFOV_deg || 20), 0.1, 179) * 0.5 * DEG2RAD);
+ const rot = Number(item.rot_deg || item.roll_deg || 0) * DEG2RAD;
+ const cr = Math.cos(rot);
+ const sr = Math.sin(rot);
+ return {
+ centerDir,
+ right,
+ up,
+ tanX,
+ tanY,
+ cr,
+ sr,
+ };
+ }
+
+ function stickerDirFromFrame(frame, x, y) {
+ const xr = x * frame.cr - y * frame.sr;
+ const yr = x * frame.sr + y * frame.cr;
+ return norm(add(add(frame.centerDir, mul(frame.right, xr)), mul(frame.up, yr)));
+ }
+
+ function stickerCornersDir(item) {
+ const frame = getStickerFrame(item);
+
+ // Corner order is fixed to: top-left, top-right, bottom-right, bottom-left
+ const cornersLocal = [
+ { u: -1, v: 1 },
+ { u: 1, v: 1 },
+ { u: 1, v: -1 },
+ { u: -1, v: -1 },
+ ];
+
+ return cornersLocal.map(({ u, v }) => {
+ return stickerDirFromFrame(frame, u * frame.tanX, v * frame.tanY);
+ });
+ }
+
+ function stickerSampleDir(item, u, v) {
+ const frame = getStickerFrame(item);
+
+ const x = (u * 2 - 1) * frame.tanX;
+ const y = (1 - v * 2) * frame.tanY;
+ return stickerDirFromFrame(frame, x, y);
+ }
+
+ function drawImageTriTo(targetCtx, img, s0, s1, s2, d0, d1, d2) {
+ const denom = (s0.x * (s1.y - s2.y)) + (s1.x * (s2.y - s0.y)) + (s2.x * (s0.y - s1.y));
+ if (Math.abs(denom) < 1e-6) return;
+
+ const a = ((d0.x * (s1.y - s2.y)) + (d1.x * (s2.y - s0.y)) + (d2.x * (s0.y - s1.y))) / denom;
+ const b = ((d0.x * (s2.x - s1.x)) + (d1.x * (s0.x - s2.x)) + (d2.x * (s1.x - s0.x))) / denom;
+ const c = ((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))) / denom;
+ const d = ((d0.y * (s1.y - s2.y)) + (d1.y * (s2.y - s0.y)) + (d2.y * (s0.y - s1.y))) / denom;
+ const e = ((d0.y * (s2.x - s1.x)) + (d1.y * (s0.x - s2.x)) + (d2.y * (s1.x - s0.x))) / denom;
+ const f = ((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))) / denom;
+
+ const [e0, e1, e2] = expandTri(d0, d1, d2, 0.45);
+ targetCtx.save();
+ targetCtx.beginPath();
+ targetCtx.moveTo(e0.x, e0.y);
+ targetCtx.lineTo(e1.x, e1.y);
+ targetCtx.lineTo(e2.x, e2.y);
+ targetCtx.closePath();
+ targetCtx.clip();
+ targetCtx.setTransform(a, d, b, e, c, f);
+ targetCtx.drawImage(img, 0, 0);
+ targetCtx.restore();
+ }
+
+ function drawImageTri(img, s0, s1, s2, d0, d1, d2) {
+ drawImageTriTo(ctx, img, s0, s1, s2, d0, d1, d2);
+ }
+
+ function getMeshDivisions() {
+ const q = String(state.ui_settings?.preview_quality || "balanced");
+ if (q === "draft") {
+ if (editor.hqFrames && editor.hqFrames > 0) return [28, 20];
+ if (editor.interaction) return [12, 9];
+ return [20, 14];
+ }
+ if (q === "high") {
+ if (editor.hqFrames && editor.hqFrames > 0) return [48, 36];
+ if (editor.interaction) return [20, 14];
+ return [36, 26];
+ }
+ if (editor.hqFrames && editor.hqFrames > 0) return [40, 30];
+ if (editor.interaction) return [16, 12];
+ return [28, 20];
+ }
+
+ function drawGridUnwrap(skipBackground = false) {
+ const w = canvas.width;
+ const h = canvas.height;
+ const r = getUnwrapRect();
+ ctx.globalAlpha = 1;
+ ctx.lineWidth = 1;
+ if (!skipBackground) {
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(r.x, r.y, r.w, r.h);
+ }
+ if (!skipBackground) drawErpBackgroundUnwrap(r);
+
+ if (editor.showGrid && !editor.fullscreen) {
+ ctx.strokeStyle = "#3f3f46";
+ for (let i = 0; i <= 16; i += 1) {
+ const x = r.x + (r.w * i) / 16;
+ ctx.beginPath(); ctx.moveTo(x, r.y); ctx.lineTo(x, r.y + r.h); ctx.stroke();
+ }
+ for (let i = 0; i <= 8; i += 1) {
+ const y = r.y + (r.h * i) / 8;
+ ctx.beginPath(); ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); ctx.stroke();
+ }
+
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.86)";
+ ctx.lineWidth = 1.2;
+ ctx.beginPath(); ctx.moveTo(r.x, r.y + r.h / 2); ctx.lineTo(r.x + r.w, r.y + r.h / 2); ctx.stroke();
+
+ ctx.fillStyle = "rgba(250, 250, 250, 0.42)";
+ ctx.font = "500 11px Geist, sans-serif";
+ ctx.textAlign = "center";
+ const ly = r.y + r.h * 0.57;
+ ctx.fillText("Left", r.x + r.w * 0.25, ly);
+ ctx.fillText("Front", r.x + r.w * 0.50, ly);
+ ctx.fillText("Right", r.x + r.w * 0.75, ly);
+ ctx.fillText("Back", r.x + 38, ly);
+ ctx.fillText("Back", r.x + r.w - 38, ly);
+ }
+ }
+
+ function drawLineOnSphere(pointsDir, color, width = 1) {
+ let started = false;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ for (const d of pointsDir) {
+ const p = projectDir(d);
+ 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 drawGridPano(skipBackground = false) {
+ const w = canvas.width;
+ const h = canvas.height;
+ if (!skipBackground) {
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(0, 0, w, h);
+ }
+ if (!skipBackground) drawErpBackgroundPano();
+
+ if (editor.showGrid && !editor.fullscreen) {
+ for (let lon = -180; lon <= 180; lon += 15) {
+ const pts = [];
+ for (let lat = -89; lat <= 89; lat += 4) pts.push(yawPitchToDir(lon, lat));
+ drawLineOnSphere(pts, "#3f3f46", lon % 90 === 0 ? 1.3 : 1);
+ }
+ for (let lat = -75; lat <= 75; lat += 15) {
+ const pts = [];
+ for (let lon = -180; lon <= 180; lon += 4) pts.push(yawPitchToDir(lon, lat));
+ drawLineOnSphere(pts, lat === 0 ? "rgba(250, 250, 250, 0.86)" : "#3f3f46", lat === 0 ? 1.5 : 1);
+ }
+
+ const labels = [
+ { name: "Left", dir: yawPitchToDir(-90, 0) },
+ { name: "Front", dir: yawPitchToDir(0, 0) },
+ { name: "Right", dir: yawPitchToDir(90, 0) },
+ { name: "Back", dir: yawPitchToDir(180, 0) },
+ ];
+ ctx.fillStyle = "rgba(250, 250, 250, 0.42)";
+ ctx.font = "500 11px Geist, sans-serif";
+ ctx.textAlign = "center";
+ labels.forEach((l) => {
+ const p = projectDir(l.dir);
+ if (p) ctx.fillText(l.name, p.x, p.y + 24);
+ });
+ }
+ }
+
+ function renderModalStickerScene() {
+ try {
+ if (type !== "stickers") return false;
+ if (editor.mode !== "pano" && editor.mode !== "unwrap") return false;
+ const scene = buildEditorStickerScene();
+ const textures = buildEditorStickerTextures(scene);
+ if (!Array.isArray(scene?.stickers) || scene.stickers.length === 0 || textures.length === 0) return false;
+ const view = editor.mode === "unwrap"
+ ? { mode: "unwrap" }
+ : buildPanoramaViewParamsFromEditor(editor);
+ return renderSceneToContext2D({
+ owner: node,
+ cacheKey: editor.mode === "unwrap" ? "modal_unwrap_scene" : "modal_pano_scene",
+ ctx,
+ rect: editor.mode === "unwrap" ? getUnwrapRect() : { x: 0, y: 0, w: canvas.width, h: canvas.height },
+ backgroundSource: null,
+ backgroundRevision: "",
+ textures,
+ scene,
+ view,
+ });
+ } catch (err) {
+ void err;
+ return false;
+ }
+ }
+
+ function objectGeom(item) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const center = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : projectDir(centerDir);
+ if (!center) return { visible: false };
+ const projectDirForShape = (d, refX = null) => {
+ if (editor.mode === "unwrap") return projectDirUnwrap(d, refX);
+ const { right, up, fwd } = cameraBasis();
+ const cx = dot(d, right);
+ const cy = dot(d, up);
+ const cz = dot(d, fwd);
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / Math.max(w, 1)));
+ const sx = (w / 2) / Math.tan(hfov / 2);
+ const sy = (h / 2) / Math.tan(vfov / 2);
+ const z = Math.max(cz, 1e-4);
+ const guard = Math.max(w, h) * 2.0;
+ return {
+ x: clamp(w / 2 + (cx / z) * sx, -guard, w + guard),
+ y: clamp(h / 2 - (cy / z) * sy, -guard, h + guard),
+ z,
+ };
+ };
+ const frame = getStickerFrame(item);
+ const cornersDir = stickerCornersDir(item);
+ const corners = cornersDir.map((d) => projectDirForShape(d, center.x));
+ const rotateStemBaseDir = stickerDirFromFrame(frame, 0, frame.tanY);
+ const rotateHandleDir = stickerDirFromFrame(frame, 0, frame.tanY + Math.max(frame.tanY * 0.43, 0.053));
+ const rotateStemBase = projectDirForShape(rotateStemBaseDir, center.x);
+ const rotateHandleHint = projectDirForShape(rotateHandleDir, rotateStemBase?.x ?? center.x);
+ const handleDx = (rotateHandleHint?.x ?? rotateStemBase.x) - rotateStemBase.x;
+ const handleDy = (rotateHandleHint?.y ?? rotateStemBase.y) - rotateStemBase.y;
+ const handleLen = Math.hypot(handleDx, handleDy) || 1;
+ const rotateHandle = {
+ x: rotateStemBase.x + (handleDx / handleLen) * 30,
+ y: rotateStemBase.y + (handleDy / handleLen) * 30,
+ };
+ const topEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, 0, frame.tanY), center.x);
+ const rightEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, frame.tanX, 0), center.x);
+ const bottomEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, 0, -frame.tanY), center.x);
+ const leftEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, -frame.tanX, 0), center.x);
+ const edgeMidpoints = [
+ {
+ edge: "top",
+ x: topEdgeCenter.x,
+ y: topEdgeCenter.y,
+ a: { x: corners[0].x, y: corners[0].y },
+ b: { x: corners[1].x, y: corners[1].y },
+ },
+ {
+ edge: "right",
+ x: rightEdgeCenter.x,
+ y: rightEdgeCenter.y,
+ a: { x: corners[1].x, y: corners[1].y },
+ b: { x: corners[2].x, y: corners[2].y },
+ },
+ {
+ edge: "bottom",
+ x: bottomEdgeCenter.x,
+ y: bottomEdgeCenter.y,
+ a: { x: corners[2].x, y: corners[2].y },
+ b: { x: corners[3].x, y: corners[3].y },
+ },
+ {
+ edge: "left",
+ x: leftEdgeCenter.x,
+ y: leftEdgeCenter.y,
+ a: { x: corners[3].x, y: corners[3].y },
+ b: { x: corners[0].x, y: corners[0].y },
+ },
+ ];
+ return {
+ center: { x: center.x, y: center.y },
+ corners: corners.map((c) => ({ x: c.x, y: c.y })),
+ edgeMidpoints,
+ rotateStemBase: { x: rotateStemBase.x, y: rotateStemBase.y },
+ rotateHandle,
+ topEdge: { a: 0, b: 1 },
+ visible: true,
+ };
+ }
+
+ function drawStickerMeshMapped(item, img, dstRect, srcRect, alpha = 1) {
+ const dx0 = clamp(Math.min(Number(dstRect.x0 ?? 0), Number(dstRect.x1 ?? 1)), 0, 1);
+ const dy0 = clamp(Math.min(Number(dstRect.y0 ?? 0), Number(dstRect.y1 ?? 1)), 0, 1);
+ const dx1 = clamp(Math.max(Number(dstRect.x0 ?? 0), Number(dstRect.x1 ?? 1)), 0, 1);
+ const dy1 = clamp(Math.max(Number(dstRect.y0 ?? 0), Number(dstRect.y1 ?? 1)), 0, 1);
+ const sx0 = clamp(Math.min(Number(srcRect.x0 ?? 0), Number(srcRect.x1 ?? 1)), 0, 1);
+ const sy0 = clamp(Math.min(Number(srcRect.y0 ?? 0), Number(srcRect.y1 ?? 1)), 0, 1);
+ const sx1 = clamp(Math.max(Number(srcRect.x0 ?? 0), Number(srcRect.x1 ?? 1)), 0, 1);
+ const sy1 = clamp(Math.max(Number(srcRect.y0 ?? 0), Number(srcRect.y1 ?? 1)), 0, 1);
+
+ const iw = img.naturalWidth || img.width;
+ const ih = img.naturalHeight || img.height;
+ const [Nu, Nv] = getMeshDivisions();
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const centerProj = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : null;
+
+ const verts = [];
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const wu = dx0 + (dx1 - dx0) * u;
+ const wv = dy0 + (dy1 - dy0) * v;
+ const su = (sx0 + (sx1 - sx0) * u) * iw;
+ const sv = (sy0 + (sy1 - sy0) * v) * ih;
+ const d = stickerSampleDir(item, wu, wv);
+ const p = editor.mode === "unwrap" ? projectDirUnwrap(d, centerProj?.x ?? null) : projectDir(d);
+ verts.push({ p, s: { x: su, y: sv } });
+ }
+ }
+
+ const W = canvas.width;
+ let drawnTriangles = 0;
+ for (let j = 0; j < Nv; j += 1) {
+ for (let i = 0; i < Nu; i += 1) {
+ const idx = (jj, ii) => jj * (Nu + 1) + ii;
+ const v00 = verts[idx(j, i)];
+ const v10 = verts[idx(j, i + 1)];
+ const v11 = verts[idx(j + 1, i + 1)];
+ const v01 = verts[idx(j + 1, i)];
+ if (!v00.p || !v10.p || !v11.p || !v01.p) continue;
+
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ drawImageTri(img, v00.s, v10.s, v11.s, v00.p, v10.p, v11.p);
+ drawImageTri(img, v00.s, v11.s, v01.s, v00.p, v11.p, v01.p);
+ ctx.globalAlpha = prevAlpha;
+ drawnTriangles += 2;
+
+ if (editor.mode === "unwrap") {
+ const p00p = { x: v00.p.x + W, y: v00.p.y };
+ const p10p = { x: v10.p.x + W, y: v10.p.y };
+ const p11p = { x: v11.p.x + W, y: v11.p.y };
+ const p01p = { x: v01.p.x + W, y: v01.p.y };
+ const p00m = { x: v00.p.x - W, y: v00.p.y };
+ const p10m = { x: v10.p.x - W, y: v10.p.y };
+ const p11m = { x: v11.p.x - W, y: v11.p.y };
+ const p01m = { x: v01.p.x - W, y: v01.p.y };
+ const prevAlpha2 = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ drawImageTri(img, v00.s, v10.s, v11.s, p00p, p10p, p11p);
+ drawImageTri(img, v00.s, v11.s, v01.s, p00p, p11p, p01p);
+ drawImageTri(img, v00.s, v10.s, v11.s, p00m, p10m, p11m);
+ drawImageTri(img, v00.s, v11.s, v01.s, p00m, p11m, p01m);
+ ctx.globalAlpha = prevAlpha2;
+ drawnTriangles += 4;
+ }
+ }
+ }
+ return drawnTriangles > 0;
+ }
+
+ function drawStickerMesh(item, img) {
+ return drawStickerMeshMapped(item, img, { x0: 0, y0: 0, x1: 1, y1: 1 }, { x0: 0, y0: 0, x1: 1, y1: 1 }, 1);
+ }
+
+ function sampleStickerEdge(item, edge, steps, refX = null) {
+ const out = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const t = i / steps;
+ let u = 0;
+ let v = 0;
+ if (edge === 0) { u = t; v = 0; } // top
+ else if (edge === 1) { u = 1; v = t; } // right
+ else if (edge === 2) { u = 1 - t; v = 1; } // bottom
+ else { u = 0; v = 1 - t; } // left
+
+ const d = stickerSampleDir(item, u, v);
+ const p = editor.mode === "unwrap" ? projectDirUnwrap(d, refX) : projectDir(d);
+ if (p) out.push(p);
+ }
+ return out;
+ }
+
+ function drawStickerBoundary(item, selected) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const centerProj = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : null;
+ const refX = centerProj ? centerProj.x : null;
+ const steps = editor.mode === "pano" ? 28 : 20;
+ const edges = [
+ sampleStickerEdge(item, 0, steps, refX),
+ sampleStickerEdge(item, 1, steps, refX),
+ sampleStickerEdge(item, 2, steps, refX),
+ sampleStickerEdge(item, 3, steps, refX),
+ ];
+
+ ctx.strokeStyle = selected ? "rgba(250, 250, 250, 0.9)" : "#71717a";
+ ctx.lineWidth = selected ? 2 : 1;
+ ctx.beginPath();
+ let started = false;
+ for (const edge of edges) {
+ for (const p of edge) {
+ if (!started) {
+ ctx.moveTo(p.x, p.y);
+ started = true;
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ }
+ }
+ ctx.closePath();
+ ctx.stroke();
+ }
+
+ function renderModalStickerBodyFallback() {
+ if (type !== "stickers") return false;
+ let anyDrawn = false;
+ const items = [...getList()].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ for (const item of items) {
+ if (item?.visible === false) continue;
+ const g = objectGeom(item);
+ const img = getStickerImage(item);
+ const alpha = getStickerDisplayAlpha(item);
+ if (img && (img.complete || img.width)) {
+ if (drawStickerMeshMapped(item, img, { x0: 0, y0: 0, x1: 1, y1: 1 }, { x0: 0, y0: 0, x1: 1, y1: 1 }, alpha)) {
+ anyDrawn = true;
+ }
+ continue;
+ }
+ if (!g.visible) continue;
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
+ ctx.beginPath();
+ ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath();
+ ctx.fill();
+ ctx.globalAlpha = prevAlpha;
+ anyDrawn = true;
+ }
+ return anyDrawn;
+ }
+
+ function drawObjects() {
+ const [usedNu, usedNv] = getMeshDivisions();
+ const items = [...getList()].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ for (const item of items) {
+ const selected = item.id === editor.selectedId;
+ const g = objectGeom(item);
+ if (type !== "stickers" && !g.visible) {
+ continue;
+ }
+
+ if (type === "stickers") {
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = getStickerDisplayAlpha(item);
+ drawStickerBoundary(item, selected);
+ ctx.globalAlpha = prevAlpha;
+ } else {
+ ctx.fillStyle = selected ? "rgba(0, 112, 243, 0.24)" : "rgba(255, 255, 255, 0.12)";
+ ctx.beginPath();
+ ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath();
+ ctx.fill();
+ ctx.strokeStyle = selected ? "rgba(255, 255, 255, 1)" : "rgba(255, 255, 255, 0.82)";
+ ctx.lineWidth = selected ? 2.8 : 1.9;
+ ctx.beginPath();
+ ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath();
+ ctx.stroke();
+ }
+
+ if (selected && g.visible) {
+ const accent = (type === "stickers" && isExternalSticker(item)) ? "#f59e0b" : "#0070f3";
+ ctx.fillStyle = accent;
+ g.corners.forEach((p) => { ctx.beginPath(); ctx.arc(p.x, p.y, 6.5, 0, Math.PI * 2); ctx.fill(); });
+ if (type === "cutout") {
+ ctx.strokeStyle = accent;
+ ctx.lineCap = "round";
+ ctx.lineWidth = 4;
+ g.edgeMidpoints.forEach((m) => {
+ const dx = (m.b?.x ?? m.x) - (m.a?.x ?? m.x);
+ const dy = (m.b?.y ?? m.y) - (m.a?.y ?? m.y);
+ const ll2 = Math.hypot(dx, dy) || 1;
+ const tx = dx / ll2;
+ const ty = dy / ll2;
+ const half = 10;
+ ctx.beginPath();
+ ctx.moveTo(m.x - tx * half, m.y - ty * half);
+ ctx.lineTo(m.x + tx * half, m.y + ty * half);
+ ctx.stroke();
+ });
+ ctx.lineCap = "butt";
+ }
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.9)";
+ ctx.lineWidth = 1.8;
+ ctx.beginPath();
+ ctx.moveTo(g.rotateStemBase.x, g.rotateStemBase.y);
+ ctx.lineTo(g.rotateHandle.x, g.rotateHandle.y);
+ ctx.stroke();
+ ctx.fillStyle = accent;
+ ctx.beginPath(); ctx.arc(g.rotateHandle.x, g.rotateHandle.y, 10, 0, Math.PI * 2); ctx.fill();
+ }
+ }
+
+ if (editor.hqFrames && usedNu >= 40 && usedNv >= 30) {
+ editor.hqFrames -= 1;
+ if (editor.hqFrames > 0) requestDraw();
+ }
+ }
+
+ function drawCutoutOutputPreview() {
+ if (type !== "cutout") return;
+ const shot = getSelected() || state.shots?.[0];
+ if (!shot) {
+ editor.outputPreviewRect = null;
+ if (outputPreviewToggleBtn) outputPreviewToggleBtn.style.display = "none";
+ return;
+ }
+
+ const margin = 14;
+ const mix = clamp(Number(editor.outputPreviewAnim ?? (editor.outputPreviewExpanded ? 1 : 0)), 0, 1);
+ const maxWCollapsed = Math.max(120, Math.min(250, canvas.width * 0.28));
+ const maxWExpanded = Math.max(260, Math.min(560, canvas.width * 0.62));
+ const maxHCollapsed = Math.max(76, Math.min(150, canvas.height * 0.22));
+ const maxHExpanded = Math.max(160, Math.min(340, canvas.height * 0.48));
+ const maxW = lerp(maxWCollapsed, maxWExpanded, mix);
+ const maxH = lerp(maxHCollapsed, maxHExpanded, mix);
+ const cutoutView = buildCutoutViewParamsFromShot(shot);
+ const aspect = Number(cutoutView?.aspect || 1);
+ let pw = maxW;
+ let ph = pw / aspect;
+ if (ph > maxH) {
+ ph = maxH;
+ pw = ph * aspect;
+ }
+ const px = canvas.width - margin - pw;
+ const py = margin;
+ const radius = 12;
+ editor.outputPreviewRect = { x: px, y: py, w: pw, h: ph };
+ const placeOutputPreviewToggle = () => {
+ if (!outputPreviewToggleBtn) return;
+ const left = `${Math.round(px + pw - 8 - 24)}px`;
+ const top = `${Math.round(py + 8)}px`;
+ outputPreviewToggleBtn.style.display = "inline-flex";
+ if (outputPreviewToggleBtn.style.left !== left) outputPreviewToggleBtn.style.left = left;
+ if (outputPreviewToggleBtn.style.top !== top) outputPreviewToggleBtn.style.top = top;
+ };
+
+ const roundedRect = (x, y, w, h, r) => {
+ const rr = Math.max(0, Math.min(r, Math.min(w, h) * 0.5));
+ ctx.beginPath();
+ if (typeof ctx.roundRect === "function") {
+ ctx.roundRect(x, y, w, h, rr);
+ } else {
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ }
+ ctx.closePath();
+ };
+
+ ctx.save();
+ ctx.shadowColor = "rgba(0, 0, 0, 0.45)";
+ ctx.shadowBlur = 22;
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 8;
+ ctx.fillStyle = "rgba(10, 10, 10, 0.72)";
+ roundedRect(px, py, pw, ph, radius);
+ ctx.fill();
+ ctx.restore();
+
+ ctx.save();
+ roundedRect(px, py, pw, ph, radius);
+ ctx.clip();
+
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) {
+ ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
+ ctx.fillRect(px, py, pw, ph);
+ ctx.fillStyle = "rgba(250, 250, 250, 0.7)";
+ ctx.font = "500 10px Geist, sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Connect ERP image", px + pw * 0.5, py + ph * 0.5);
+ ctx.restore();
+ placeOutputPreviewToggle();
+ return;
+ }
+
+ if (Number(img.naturalWidth || img.width || 0) <= 1 || Number(img.naturalHeight || img.height || 0) <= 1) {
+ ctx.restore();
+ if (outputPreviewToggleBtn) outputPreviewToggleBtn.style.display = "none";
+ return;
+ }
+
+ const glDrawn = renderCutoutViewToContext2D({
+ owner: node,
+ cacheKey: "modal_cutout_output_preview",
+ ctx,
+ rect: { x: px, y: py, w: pw, h: ph },
+ img,
+ view: cutoutView,
+ });
+ const fallbackDrawn = !glDrawn && drawCutoutProjectionPreview(
+ ctx,
+ node,
+ img,
+ { x: px, y: py, w: pw, h: ph },
+ shot,
+ mix > 0.65 ? "high" : "balanced",
+ );
+ ctx.restore();
+ placeOutputPreviewToggle();
+ }
+
+ function drawScene() {
+ if (editor.mode === "unwrap") drawGridUnwrap(false);
+ else drawGridPano(false);
+ const stickerSceneDrawn = renderModalStickerScene();
+ if (type === "stickers" && !stickerSceneDrawn) {
+ renderModalStickerBodyFallback();
+ }
+ drawObjects();
+ drawCutoutOutputPreview();
+ if (fovValueEl) fovValueEl.textContent = `${editor.viewFov.toFixed(1)}`;
+ updateSelectionMenu();
+ if (!runtime.hasPresentedFrame) {
+ runtime.hasPresentedFrame = true;
+ canvas.style.opacity = "1";
+ }
+ }
+
+ function isCutoutTransformInteractionActive() {
+ if (type !== "cutout") return false;
+ const kind = String(editor.interaction?.kind || "");
+ return kind === "move" || kind === "scale" || kind === "scale_x" || kind === "scale_y" || kind === "rotate";
+ }
+
+ function requestDraw() {
+ syncLookAtFrameButtonState();
+ node.__panoLiveStateOverride = JSON.stringify(state);
+ if (!isCutoutTransformInteractionActive()) {
+ node.__panoDomPreview?.requestDraw?.();
+ node.setDirtyCanvas?.(true, false);
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+ }
+ runtime.dirty = true;
+ }
+
+ function syncCanvasSize() {
+ const rect = canvas.getBoundingClientRect();
+ const nextW = Math.max(2, Math.round(rect.width));
+ const nextH = Math.max(2, Math.round(rect.height));
+ if (canvas.width !== nextW || canvas.height !== nextH) {
+ canvas.width = nextW;
+ canvas.height = nextH;
+ runtime.dirty = true;
+ if (type === "cutout") {
+ runtime.pendingStableLayoutFrames = Math.max(Number(runtime.pendingStableLayoutFrames || 0), 1);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ function tick(ts = performance.now()) {
+ if (!runtime.running) return;
+ const dt = runtime.lastTickTs > 0 ? Math.max(0.001, (ts - runtime.lastTickTs) / 1000) : (1 / 60);
+ runtime.lastTickTs = ts;
+ if (editor.outputPreviewAnim !== editor.outputPreviewAnimTo) {
+ const dur = Math.max(1, Number(editor.outputPreviewAnimDurationMs || 180));
+ const t = clamp((ts - Number(editor.outputPreviewAnimStartTs || 0)) / dur, 0, 1);
+ const expanding = editor.outputPreviewAnimTo > editor.outputPreviewAnimFrom;
+ const eased = expanding ? easeOutCubic(t) : easeInCubic(t);
+ editor.outputPreviewAnim = lerp(editor.outputPreviewAnimFrom, editor.outputPreviewAnimTo, eased);
+ runtime.dirty = true;
+ if (t >= 1) {
+ editor.outputPreviewAnim = editor.outputPreviewAnimTo;
+ }
+ }
+
+ if (editor.viewTween?.active) {
+ const tw = editor.viewTween;
+ const t = clamp((ts - tw.startTs) / tw.durationMs, 0, 1);
+ const eased = easeInOutCubic(t);
+ editor.viewYaw = wrapYaw(tw.startYaw + tw.deltaYaw * eased);
+ editor.viewPitch = tw.startPitch + (tw.targetPitch - tw.startPitch) * eased;
+ editor.viewFov = tw.startFov + (tw.targetFov - tw.startFov) * eased;
+ runtime.dirty = true;
+ if (t >= 1) editor.viewTween = null;
+ }
+
+ editor.viewInertia.vx = Number(viewController.state.inertia.vx || 0);
+ editor.viewInertia.vy = Number(viewController.state.inertia.vy || 0);
+ editor.viewInertia.active = !!viewController.state.inertia.active;
+ if (viewController.stepInertia(ts)) {
+ editor.viewInertia.vx = Number(viewController.state.inertia.vx || 0);
+ editor.viewInertia.vy = Number(viewController.state.inertia.vy || 0);
+ editor.viewInertia.active = !!viewController.state.inertia.active;
+ runtime.dirty = true;
+ }
+
+ if (runtime.dirty || (ts - runtime.lastSizeCheckTs) >= 220) {
+ syncCanvasSize();
+ runtime.lastSizeCheckTs = ts;
+ }
+ if (runtime.pendingStableLayoutFrames > 0) {
+ runtime.pendingStableLayoutFrames -= 1;
+ runtime.dirty = true;
+ }
+ if (runtime.dirty) {
+ if (runtime.pendingStableLayoutFrames > 0) {
+ runtime.rafId = requestAnimationFrame(tick);
+ return;
+ }
+ runtime.dirty = false;
+ drawScene();
+ }
+ runtime.rafId = requestAnimationFrame(tick);
+ }
+
+ function stopRenderLoop() {
+ runtime.running = false;
+ if (runtime.rafId) cancelAnimationFrame(runtime.rafId);
+ runtime.rafId = 0;
+ }
+
+ function pushHistory() {
+ if (readOnly) return;
+ const snap = JSON.stringify(state);
+ if (editor.history[editor.historyIndex] === snap) return;
+ editor.history = editor.history.slice(0, editor.historyIndex + 1);
+ editor.history.push(snap);
+ if (editor.history.length > 80) editor.history.shift();
+ editor.historyIndex = editor.history.length - 1;
+ }
+
+ function restoreHistory(step) {
+ if (readOnly) return;
+ const idx = editor.historyIndex + step;
+ if (idx < 0 || idx >= editor.history.length) return;
+ const parsed = JSON.parse(editor.history[idx]);
+ Object.keys(state).forEach((k) => delete state[k]);
+ Object.assign(state, parsed);
+ editor.historyIndex = idx;
+ editor.selectedId = type === "stickers" ? state.active.selected_sticker_id : state.active.selected_shot_id;
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function addParamRow(container, selected, key, label, min, max, step, enabled = true) {
+ const row = document.createElement("div");
+ row.dataset.key = key;
+ row.dataset.min = String(min);
+ row.dataset.max = String(max);
+ row.className = "pano-field";
+ row.innerHTML = ``;
+ const [rng, num] = row.querySelectorAll("input");
+ const setRangeFill = () => {
+ const nMin = Number(min);
+ const nMax = Number(max);
+ const nVal = Number(rng.value);
+ const pct = ((nVal - nMin) / Math.max(1e-6, nMax - nMin)) * 100;
+ rng.style.setProperty("--v", `${clamp(pct, 0, 100)}%`);
+ };
+ rng.disabled = !enabled;
+ num.disabled = !enabled;
+ const setVal = (v) => {
+ if (!enabled) return;
+ let out = Number(v);
+ if (Number.isNaN(out)) out = 0;
+ out = clamp(out, min, max);
+ if (key === "yaw_deg") out = wrapYaw(out);
+ selected[key] = out;
+ rng.value = String(out);
+ num.value = formatParamValue(out);
+ setRangeFill();
+ requestDraw();
+ };
+ rng.oninput = () => setVal(rng.value);
+ num.oninput = () => setVal(num.value);
+ rng.onchange = () => pushHistory();
+ num.onchange = () => pushHistory();
+ setRangeFill();
+ container.appendChild(row);
+ }
+
+ function syncSidePanelControls() {
+ const selected = getSelected();
+ if (!selected) return;
+ editor.panelLastValues = {
+ yaw_deg: Number(selected.yaw_deg || 0),
+ pitch_deg: Number(selected.pitch_deg || 0),
+ hFOV_deg: Number(selected.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selected.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selected.rot_deg || 0),
+ roll_deg: Number(selected.roll_deg || 0),
+ out_w: Number(selected.out_w || 1024),
+ out_h: Number(selected.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selected),
+ };
+ const rows = side.querySelectorAll(".pano-field[data-key]");
+ rows.forEach((row) => {
+ const key = row.dataset.key;
+ if (!key) return;
+ if (!(key in selected)) return;
+ const rng = row.querySelector("input[type='range']");
+ const num = row.querySelector("input[type='number']");
+ if (!rng || !num) return;
+ const min = Number(row.dataset.min ?? rng.min ?? 0);
+ const max = Number(row.dataset.max ?? rng.max ?? 0);
+ let out = Number(selected[key] || 0);
+ if (Number.isNaN(out)) out = 0;
+ out = clamp(out, min, max);
+ const s = String(out);
+ if (rng.value !== s) rng.value = s;
+ const t = formatParamValue(out);
+ if (num.value !== t) num.value = t;
+ const pct = ((out - min) / Math.max(1e-6, max - min)) * 100;
+ rng.style.setProperty("--v", `${clamp(pct, 0, 100)}%`);
+ });
+ const resolvedAspect = getCutoutAspectLabel(selected);
+ const aspectLabel = side.querySelector(".pano-cutout-aspect-label span");
+ if (aspectLabel) aspectLabel.textContent = resolvedAspect;
+ const presetBtns = side.querySelectorAll(".pano-cutout-aspect-pop [data-aspect]");
+ presetBtns.forEach((btn) => {
+ btn.classList.toggle("active", String(btn.getAttribute("data-aspect")) === resolvedAspect);
+ });
+ }
+
+ function updateSidePanel() {
+ if (hideSidebar) return;
+ const staticNodes = [...side.children].slice(0, 2);
+ side.innerHTML = "";
+ staticNodes.forEach((n) => side.appendChild(n));
+
+ const sideActions = side.querySelector(".pano-side-actions");
+ if (sideActions) {
+ sideActions.innerHTML = "";
+ }
+ if (previewMode) {
+ const inspector = document.createElement("div");
+ inspector.className = "pano-inspector";
+ const uiDetails = document.createElement("details");
+ uiDetails.className = "pano-ui-settings";
+ uiDetails.open = false;
+ uiDetails.innerHTML = `
+
+ UI Settings
+ ${ICON.chevron}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const segX = uiDetails.querySelector("[data-setting='invert-x']");
+ const segY = uiDetails.querySelector("[data-setting='invert-y']");
+ const qualityPicker = uiDetails.querySelector("[data-ui-picker='quality']");
+ const resetUi = uiDetails.querySelector("[data-action='ui-reset-defaults']");
+ const setupUiPicker = (pickerEl, options, getValue, setValue) => {
+ const trigger = pickerEl.querySelector(".pano-picker-trigger");
+ const label = pickerEl.querySelector(".pano-picker-label");
+ const pop = pickerEl.querySelector(".pano-picker-pop");
+ const refresh = () => {
+ const cur = String(getValue());
+ const found = options.find((o) => String(o.value) === cur) || options[0];
+ label.textContent = found.label;
+ pop.innerHTML = "";
+ options.forEach((o) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = `pano-picker-item${String(o.value) === cur ? " active" : ""}`;
+ b.textContent = o.label;
+ b.onclick = () => {
+ setValue(o.value);
+ pop.hidden = true;
+ refresh();
+ persistUiSettings();
+ requestDraw();
+ };
+ pop.appendChild(b);
+ });
+ };
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ uiDetails.querySelectorAll(".pano-ui-picker .pano-picker-pop").forEach((el) => {
+ if (el !== pop) el.hidden = true;
+ });
+ pop.hidden = !pop.hidden;
+ };
+ refresh();
+ return refresh;
+ };
+ const setSegmentValue = (seg, on) => {
+ seg.setAttribute("data-selected", on ? "1" : "0");
+ seg.querySelectorAll(".pano-segment-btn").forEach((b) => {
+ b.setAttribute("aria-pressed", b.getAttribute("data-value") === (on ? "1" : "0") ? "true" : "false");
+ });
+ };
+ segX.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_x = on;
+ setSegmentValue(segX, on);
+ persistUiSettings();
+ requestDraw();
+ };
+ });
+ segY.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_y = on;
+ setSegmentValue(segY, on);
+ persistUiSettings();
+ requestDraw();
+ };
+ });
+ const refreshQuality = setupUiPicker(
+ qualityPicker,
+ [
+ { value: "draft", label: "Draft" },
+ { value: "balanced", label: "Balanced" },
+ { value: "high", label: "High" },
+ ],
+ () => String(state.ui_settings.preview_quality || "balanced"),
+ (v) => {
+ const q = String(v || "balanced");
+ state.ui_settings.preview_quality = (q === "draft" || q === "high") ? q : "balanced";
+ },
+ );
+ resetUi.onclick = () => {
+ state.ui_settings.invert_view_x = false;
+ state.ui_settings.invert_view_y = false;
+ state.ui_settings.preview_quality = "balanced";
+ setSegmentValue(segX, false);
+ setSegmentValue(segY, false);
+ refreshQuality();
+ persistUiSettings();
+ requestDraw();
+ };
+ inspector.appendChild(uiDetails);
+ side.appendChild(inspector);
+
+ const footer = document.createElement("div");
+ footer.className = "pano-side-footer";
+ footer.innerHTML = ``;
+ footer.querySelector("[data-action='close-preview']").onclick = () => closeEditor();
+ side.appendChild(footer);
+ installTooltipHandlers(inspector);
+ return;
+ }
+
+ const list = getList();
+ const selected = getSelected();
+ if (selected) {
+ editor.panelLastValues = {
+ yaw_deg: Number(selected.yaw_deg || 0),
+ pitch_deg: Number(selected.pitch_deg || 0),
+ hFOV_deg: Number(selected.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selected.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selected.rot_deg || 0),
+ roll_deg: Number(selected.roll_deg || 0),
+ out_w: Number(selected.out_w || 1024),
+ out_h: Number(selected.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selected),
+ };
+ }
+ const fallback = editor.panelLastValues || (type === "stickers"
+ ? { yaw_deg: 0, pitch_deg: 0, hFOV_deg: 30, vFOV_deg: 30, rot_deg: 0 }
+ : { yaw_deg: 0, pitch_deg: 0, hFOV_deg: 90, vFOV_deg: 60, roll_deg: 0, out_w: 1024, out_h: 1024, aspect_id: "1:1" });
+ const effective = selected || fallback;
+ const enabled = !!selected;
+
+ const inspector = document.createElement("div");
+ inspector.className = "pano-inspector";
+ inspector.innerHTML = `
+
+ Transform
+
+ `;
+ side.appendChild(inspector);
+
+ if (type === "stickers") {
+ const targetRow = document.createElement("div");
+ targetRow.className = "pano-field-wide pano-target-row";
+ targetRow.innerHTML = `
+
+
+ `;
+ const trigger = targetRow.querySelector(".pano-picker-trigger");
+ const labelEl = targetRow.querySelector(".pano-picker-label");
+ const pop = targetRow.querySelector(".pano-picker-pop");
+ const items = [{ id: "", label: "No image" }];
+ list.forEach((item, i) => {
+ const baseLabel = isExternalSticker(item)
+ ? String(item.id || EXTERNAL_STICKER_ID)
+ : String(state.assets?.[item.asset_id]?.name || item.asset_id || item.id);
+ const label = `${i + 1}. ${baseLabel}${isExternalSticker(item) && isStickerHidden(item) ? " (hidden)" : ""}`;
+ items.push({ id: item.id, label });
+ });
+ const currentId = selected?.id || "";
+ const currentLabel = (items.find((it) => it.id === currentId) || items[0]).label;
+ labelEl.textContent = currentLabel;
+ pop.innerHTML = "";
+ items.forEach((it) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `pano-picker-item${it.id === currentId ? " active" : ""}`;
+ btn.textContent = it.label;
+ btn.onclick = () => {
+ pop.hidden = true;
+ editor.selectedId = it.id || null;
+ state.active.selected_sticker_id = editor.selectedId;
+ const selectedNow = getSelected();
+ if (selectedNow) {
+ const targetYaw = wrapYaw(Number(selectedNow.yaw_deg || 0));
+ const targetPitch = clamp(Number(selectedNow.pitch_deg || 0), -89.9, 89.9);
+ startViewTween(targetYaw, targetPitch, editor.viewFov);
+ }
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ };
+ pop.appendChild(btn);
+ });
+ trigger.disabled = list.length === 0;
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ if (trigger.disabled) return;
+ pop.hidden = !pop.hidden;
+ };
+ inspector.appendChild(targetRow);
+ }
+
+ const toolsRow = document.createElement("div");
+ toolsRow.className = "pano-state-actions";
+ toolsRow.innerHTML = ``;
+ const copyInline = toolsRow.querySelector("[data-action='copy-state-inline']");
+ copyInline.disabled = !enabled;
+ copyInline.onclick = async () => {
+ if (!enabled) return;
+ const text = JSON.stringify(type === "cutout"
+ ? buildCanonicalCutoutStickerState(effective)
+ : buildCanonicalSelectedStickerState(selected));
+ try {
+ await navigator.clipboard.writeText(text);
+ const label = copyInline.querySelector("span");
+ if (label) {
+ label.textContent = "Copied";
+ window.setTimeout(() => {
+ label.textContent = "Copy State";
+ }, 900);
+ }
+ } catch {
+ // no-op fallback for environments without clipboard permission
+ }
+ };
+ inspector.appendChild(toolsRow);
+
+ const paramsWrap = document.createElement("div");
+ paramsWrap.className = `pano-params${editor.panelWasEnabled ? "" : " disabled"}`;
+ inspector.appendChild(paramsWrap);
+
+ if (type === "cutout") {
+ const aspectRow = document.createElement("div");
+ aspectRow.className = "pano-field-wide pano-aspect-row";
+ const aspectLabel = getCutoutAspectLabel(effective);
+ aspectRow.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ const trigger = aspectRow.querySelector(".pano-picker-trigger");
+ const pop = aspectRow.querySelector(".pano-cutout-aspect-pop");
+ const labelSpan = aspectRow.querySelector(".pano-cutout-aspect-label span");
+ const cw = aspectRow.querySelector("[data-role='custom-w']");
+ const ch = aspectRow.querySelector("[data-role='custom-h']");
+ const applyCustomBtn = aspectRow.querySelector("[data-action='aspect-custom-apply']");
+ const rotateBtn = aspectRow.querySelector("[data-action='rotate-90-side']");
+ const refreshAspectUi = () => {
+ const now = getSelected();
+ const text = getCutoutAspectLabel(now || effective);
+ if (labelSpan) labelSpan.textContent = text;
+ const presets = pop.querySelectorAll("[data-aspect]");
+ presets.forEach((btn) => {
+ btn.classList.toggle("active", String(btn.getAttribute("data-aspect")) === text);
+ });
+ };
+ if (enabled) {
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ pop.hidden = !pop.hidden;
+ };
+ pop.querySelectorAll("[data-aspect]").forEach((btn) => {
+ btn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ applyCutoutAspect(now, String(btn.getAttribute("data-aspect") || "1:1"));
+ pop.hidden = true;
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ });
+ applyCustomBtn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ const ok = applyCutoutAspectCustom(now, Number(cw.value), Number(ch.value));
+ if (!ok) return;
+ pop.hidden = true;
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ rotateBtn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ rotateCutoutAspect90(now);
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ }
+ refreshAspectUi();
+ paramsWrap.appendChild(aspectRow);
+ }
+
+ addParamRow(paramsWrap, effective, "yaw_deg", "Yaw", -180, 180, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "pitch_deg", "Pitch", -90, 90, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "hFOV_deg", "H FOV", 1, 179, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "vFOV_deg", "V FOV", 1, 179, 0.1, enabled && !readOnly);
+ if (type === "stickers") {
+ addParamRow(paramsWrap, effective, "rot_deg", "Rotation", -180, 180, 0.1, enabled && !readOnly);
+ } else {
+ addParamRow(paramsWrap, effective, "roll_deg", "Roll", -180, 180, 0.1, enabled && !readOnly);
+ }
+
+ if (enabled !== editor.panelWasEnabled) {
+ requestAnimationFrame(() => {
+ paramsWrap.classList.toggle("disabled", !enabled);
+ });
+ } else {
+ paramsWrap.classList.toggle("disabled", !enabled);
+ }
+ editor.panelWasEnabled = enabled;
+ syncLookAtFrameButtonState();
+
+ if (!readOnly) {
+ const uiDetails = document.createElement("details");
+ uiDetails.className = "pano-ui-settings";
+ uiDetails.open = false;
+ uiDetails.innerHTML = `
+
+ UI Settings
+ ${ICON.chevron}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const segX = uiDetails.querySelector("[data-setting='invert-x']");
+ const segY = uiDetails.querySelector("[data-setting='invert-y']");
+ const qualityPicker = uiDetails.querySelector("[data-ui-picker='quality']");
+ const resetUi = uiDetails.querySelector("[data-action='ui-reset-defaults']");
+ const setupUiPicker = (pickerEl, options, getValue, setValue) => {
+ const trigger = pickerEl.querySelector(".pano-picker-trigger");
+ const label = pickerEl.querySelector(".pano-picker-label");
+ const pop = pickerEl.querySelector(".pano-picker-pop");
+ const refresh = () => {
+ const cur = String(getValue());
+ const found = options.find((o) => String(o.value) === cur) || options[0];
+ label.textContent = found.label;
+ pop.innerHTML = "";
+ options.forEach((o) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = `pano-picker-item${String(o.value) === cur ? " active" : ""}`;
+ b.textContent = o.label;
+ b.onclick = () => {
+ setValue(o.value);
+ pop.hidden = true;
+ refresh();
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ pop.appendChild(b);
+ });
+ };
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ uiDetails.querySelectorAll(".pano-ui-picker .pano-picker-pop").forEach((el) => {
+ if (el !== pop) el.hidden = true;
+ });
+ pop.hidden = !pop.hidden;
+ };
+ refresh();
+ return refresh;
+ };
+ const setSegmentValue = (seg, on) => {
+ seg.setAttribute("data-selected", on ? "1" : "0");
+ seg.querySelectorAll(".pano-segment-btn").forEach((b) => {
+ b.setAttribute("aria-pressed", b.getAttribute("data-value") === (on ? "1" : "0") ? "true" : "false");
+ });
+ };
+ segX.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_x = on;
+ setSegmentValue(segX, on);
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ });
+ segY.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_y = on;
+ setSegmentValue(segY, on);
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ });
+ const refreshQuality = setupUiPicker(
+ qualityPicker,
+ [
+ { value: "draft", label: "Draft" },
+ { value: "balanced", label: "Balanced" },
+ { value: "high", label: "High" },
+ ],
+ () => String(state.ui_settings.preview_quality || "balanced"),
+ (v) => {
+ const q = String(v || "balanced");
+ state.ui_settings.preview_quality = (q === "draft" || q === "high") ? q : "balanced";
+ },
+ );
+ resetUi.onclick = () => {
+ state.ui_settings.invert_view_x = false;
+ state.ui_settings.invert_view_y = false;
+ state.ui_settings.preview_quality = "balanced";
+ setSegmentValue(segX, false);
+ setSegmentValue(segY, false);
+ refreshQuality();
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ inspector.appendChild(uiDetails);
+ }
+
+ const footer = document.createElement("div");
+ footer.className = "pano-side-footer";
+ footer.innerHTML = `
+
+
+ `;
+ footer.querySelector("[data-action='cancel-close']").onclick = () => closeEditor();
+ footer.querySelector("[data-action='save-close']").onclick = () => {
+ apply();
+ closeEditor();
+ };
+ side.appendChild(footer);
+ installTooltipHandlers(inspector);
+ }
+
+ function isImageFile(file) {
+ if (!file) return false;
+ const t = String(file.type || "").toLowerCase();
+ if (t.startsWith("image/")) return true;
+ const n = String(file.name || "").toLowerCase();
+ return n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".webp") || n.endsWith(".gif") || n.endsWith(".bmp");
+ }
+
+ async function addImageStickerFromFile(file) {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ if (!isImageFile(file)) return;
+ const aid = uid("asset");
+ const tempUrl = URL.createObjectURL(file);
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const i = new Image();
+ i.onload = () => resolve(i);
+ i.onerror = () => reject(new Error("image load failed"));
+ i.src = tempUrl;
+ });
+ imageCache.set(aid, img);
+ const uploaded = await uploadStickerAssetFile(file, String(file.name || aid));
+ state.assets[aid] = uploaded;
+ const ar = (img.naturalHeight || img.height || 1) / (img.naturalWidth || img.width || 1);
+ const id = uid("st");
+ state.stickers.push({
+ id,
+ asset_id: aid,
+ yaw_deg: editor.viewYaw,
+ pitch_deg: editor.viewPitch,
+ hFOV_deg: 30,
+ vFOV_deg: clamp(30 * ar, 1, 179),
+ rot_deg: 0,
+ z_index: getNextStickerZIndex(),
+ });
+ editor.selectedId = id;
+ state.active.selected_sticker_id = id;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ } catch (err) {
+ console.error("[TY360] failed to add sticker asset", err);
+ delete state.assets[aid];
+ imageCache.delete(aid);
+ } finally {
+ URL.revokeObjectURL(tempUrl);
+ }
+ }
+
+ function addImageSticker() {
+ if (readOnly) return;
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "image/*";
+ input.onchange = () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ void addImageStickerFromFile(file);
+ };
+ input.click();
+ }
+
+ async function migrateLegacyEmbeddedAssets() {
+ if (readOnly || type !== "stickers") return;
+ const assets = state.assets && typeof state.assets === "object" ? state.assets : {};
+ const entries = Object.entries(assets).filter(([, asset]) => {
+ const t = String(asset?.type || "").toLowerCase();
+ const v = String(asset?.value || "");
+ return t === "dataurl" && v.startsWith("data:image");
+ });
+ if (!entries.length) return;
+ let changed = false;
+ for (const [assetId, asset] of entries) {
+ try {
+ const dataUrl = String(asset?.value || "");
+ if (!dataUrl) continue;
+ const blob = await fetch(dataUrl).then((r) => r.blob());
+ const ext = String(blob.type || "image/png").split("/")[1] || "png";
+ const name = String(asset?.name || `${assetId}.${ext}`);
+ const file = new File([blob], name, { type: blob.type || "image/png" });
+ const uploaded = await uploadStickerAssetFile(file, name);
+ state.assets[assetId] = {
+ ...uploaded,
+ w: Number(asset?.w || 0),
+ h: Number(asset?.h || 0),
+ };
+ changed = true;
+ } catch (err) {
+ console.error("[TY360] failed to migrate embedded sticker asset", { assetId, err });
+ }
+ }
+ if (changed) {
+ commitAndRefreshNode();
+ requestDraw();
+ }
+ }
+
+ function addCutoutFrame() {
+ if (readOnly) return;
+ if (type !== "cutout") return;
+ state.shots = [{
+ id: uid("sh"),
+ yaw_deg: editor.viewYaw,
+ pitch_deg: editor.viewPitch,
+ hFOV_deg: 64,
+ vFOV_deg: 40,
+ roll_deg: 0,
+ out_w: 1024,
+ out_h: 1024,
+ aspect_id: "1:1",
+ }];
+ editor.selectedId = state.shots[0].id;
+ editor.cutoutAspectOpen = false;
+ state.active.selected_shot_id = editor.selectedId;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function clearCutoutFrame() {
+ if (readOnly) return;
+ if (type !== "cutout") return;
+ state.shots = [];
+ editor.selectedId = null;
+ editor.cutoutAspectOpen = false;
+ state.active.selected_shot_id = null;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function showCanvasConfirm(title, text, confirmLabel = "Clear") {
+ return new Promise((resolve) => {
+ const layer = document.createElement("div");
+ layer.className = "pano-canvas-confirm";
+ layer.innerHTML = `
+
+
${title}
+
${text}
+
+
+
+
+
+ `;
+ const close = (ok) => {
+ layer.remove();
+ resolve(!!ok);
+ };
+ layer.addEventListener("pointerdown", (ev) => {
+ if (ev.target === layer) close(false);
+ });
+ const cancelBtn = layer.querySelector("[data-action='cancel']");
+ const confirmBtn = layer.querySelector("[data-action='confirm']");
+ cancelBtn.onclick = () => close(false);
+ confirmBtn.onclick = () => close(true);
+ stageWrap.appendChild(layer);
+ confirmBtn.focus();
+ });
+ }
+
+ async function clearAll() {
+ if (readOnly) return;
+ const ok = await showCanvasConfirm(
+ type === "stickers" ? "Clear All Stickers" : "Remove Frame",
+ type === "stickers"
+ ? "This will remove all stickers from the current state."
+ : "This will remove the current cutout frame.",
+ type === "stickers" ? "Clear All" : "Remove",
+ );
+ if (!ok) return;
+ if (type === "stickers") {
+ state.stickers = [];
+ pruneUnusedAssets();
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ return;
+ }
+ clearCutoutFrame();
+ }
+
+ function duplicateSelected() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ if (isExternalSticker(selected)) return;
+ const copy = JSON.parse(JSON.stringify(selected));
+ copy.id = uid(type === "stickers" ? "st" : "sh");
+ copy.yaw_deg = wrapYaw((copy.yaw_deg || 0) + 8);
+ if (type === "stickers") {
+ copy.z_index = getNextStickerZIndex();
+ state.stickers.push(copy);
+ state.active.selected_sticker_id = copy.id;
+ } else {
+ state.shots = [copy];
+ state.active.selected_shot_id = copy.id;
+ }
+ editor.selectedId = copy.id;
+ pushHistory();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function deleteSelected() {
+ if (readOnly) return;
+ const selected = getSelected();
+ if (!selected) return;
+ if (type === "stickers") {
+ if (isExternalSticker(selected)) {
+ selected.visible = isStickerHidden(selected);
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ state.stickers = state.stickers.filter((s) => s.id !== selected.id);
+ pruneUnusedAssets();
+ editor.selectedId = state.stickers[0]?.id || null;
+ state.active.selected_sticker_id = editor.selectedId;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ return;
+ }
+ clearCutoutFrame();
+ }
+
+ function applyCutoutAspect(selected, aspect) {
+ if (!selected) return;
+ const pairs = {
+ "1:1": [1, 1],
+ "3:2": [3, 2],
+ "4:3": [4, 3],
+ "16:9": [16, 9],
+ };
+ const currentLandscape = (() => {
+ const hf = Number(selected.hFOV_deg || 64);
+ const vf = Number(selected.vFOV_deg || 40);
+ if (Math.abs(hf - vf) > 1e-6) return hf >= vf;
+ return Number(selected.out_w || 1024) >= Number(selected.out_h || 1024);
+ })();
+ let [aw, ah] = pairs[String(aspect)] || pairs["1:1"];
+ if ((aw >= ah) !== currentLandscape) [aw, ah] = [ah, aw];
+ const ratio = aw / ah;
+ const hf = clamp(Number(selected.hFOV_deg || 64), 1, 179);
+ const vf = clamp(Number(selected.vFOV_deg || 40), 1, 179);
+ const span = Math.sqrt(Math.max(1, hf * vf));
+ const targetHF = clamp(span * Math.sqrt(ratio), 1, 179);
+ const targetVF = clamp(span / Math.sqrt(ratio), 1, 179);
+ selected.hFOV_deg = targetHF;
+ selected.vFOV_deg = targetVF;
+ const base = Math.max(512, Number(selected.out_w || 1024), Number(selected.out_h || 1024));
+ const scale = base / Math.max(aw, ah);
+ const ow = Math.max(256, Math.round((aw * scale) / 8) * 8);
+ const oh = Math.max(256, Math.round((ah * scale) / 8) * 8);
+ selected.out_w = ow;
+ selected.out_h = oh;
+ selected.aspect_id = String(aspect);
+ }
+
+ function applyCutoutAspectCustom(selected, w, h) {
+ if (!selected) return false;
+ const rw = Math.max(1, Number(w));
+ const rh = Math.max(1, Number(h));
+ if (!Number.isFinite(rw) || !Number.isFinite(rh)) return false;
+ const currentLandscape = (() => {
+ const hf = Number(selected.hFOV_deg || 64);
+ const vf = Number(selected.vFOV_deg || 40);
+ if (Math.abs(hf - vf) > 1e-6) return hf >= vf;
+ return Number(selected.out_w || 1024) >= Number(selected.out_h || 1024);
+ })();
+ let aw = rw;
+ let ah = rh;
+ if ((aw >= ah) !== currentLandscape) [aw, ah] = [ah, aw];
+ const ratio = aw / ah;
+ const hf = clamp(Number(selected.hFOV_deg || 64), 1, 179);
+ const vf = clamp(Number(selected.vFOV_deg || 40), 1, 179);
+ const span = Math.sqrt(Math.max(1, hf * vf));
+ selected.hFOV_deg = clamp(span * Math.sqrt(ratio), 1, 179);
+ selected.vFOV_deg = clamp(span / Math.sqrt(ratio), 1, 179);
+ const base = Math.max(512, Number(selected.out_w || 1024), Number(selected.out_h || 1024));
+ const scale = base / Math.max(aw, ah);
+ selected.out_w = Math.max(256, Math.round((aw * scale) / 8) * 8);
+ selected.out_h = Math.max(256, Math.round((ah * scale) / 8) * 8);
+ selected.aspect_id = `${Math.round(rw)}:${Math.round(rh)}`;
+ return true;
+ }
+
+ function rotateCutoutAspect90(selected) {
+ if (!selected) return;
+ const ow = Math.max(8, Number(selected.out_w || 1024));
+ const oh = Math.max(8, Number(selected.out_h || 1024));
+ selected.out_w = oh;
+ selected.out_h = ow;
+ const hf = Math.max(1, Number(selected.hFOV_deg || 90));
+ const vf = Math.max(1, Number(selected.vFOV_deg || 60));
+ selected.hFOV_deg = vf;
+ selected.vFOV_deg = hf;
+ }
+
+ function normalizeStickerZIndices() {
+ state.stickers
+ .sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0))
+ .forEach((st, i) => { st.z_index = i; });
+ }
+
+ function bringSelectedToFront() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ normalizeStickerZIndices();
+ const sorted = [...state.stickers].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ const idx = sorted.findIndex((s) => s.id === selected.id);
+ if (idx < 0 || idx === sorted.length - 1) return;
+ const [item] = sorted.splice(idx, 1);
+ sorted.push(item);
+ sorted.forEach((s, i) => { s.z_index = i; });
+ pushHistory();
+ updateSelectionMenu();
+ requestDraw();
+ }
+
+ function sendSelectedToBack() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ normalizeStickerZIndices();
+ const sorted = [...state.stickers].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ const idx = sorted.findIndex((s) => s.id === selected.id);
+ if (idx <= 0) return;
+ const [item] = sorted.splice(idx, 1);
+ sorted.unshift(item);
+ sorted.forEach((s, i) => { s.z_index = i; });
+ pushHistory();
+ updateSelectionMenu();
+ requestDraw();
+ }
+
+ function apply() {
+ if (readOnly) return;
+ state.projection_model = "pinhole_rectilinear";
+ state.alpha_mode = "straight";
+ if (presetWidget) state.output_preset = parseOutputPresetValue(presetWidget.value, Number(state.output_preset || 2048));
+ if (bgWidget) state.bg_color = String(bgWidget.value || state.bg_color || "#00ff00");
+ commitState();
+ node.setDirtyCanvas(true, true);
+ }
+
+ function commitState() {
+ if (readOnly) return;
+ const text = JSON.stringify(state);
+ if (stateWidget) {
+ stateWidget.value = text;
+ stateWidget.callback?.(text);
+ }
+ }
+ function persistUiSettings() {
+ state.ui_settings = saveSharedUiSettings(state.ui_settings);
+ if (!readOnly) {
+ commitState();
+ }
+ }
+ function commitAndRefreshNode() {
+ if (readOnly) return;
+ commitState();
+ node.setDirtyCanvas?.(true, true);
+ }
+
+ function screenPos(evt) {
+ const r = canvas.getBoundingClientRect();
+ return {
+ x: ((evt.clientX - r.left) / r.width) * canvas.width,
+ y: ((evt.clientY - r.top) / r.height) * canvas.height,
+ };
+ }
+
+ function hitObjectAt(p) {
+ const items = [...getList()].sort((a, b) => Number(b.z_index || 0) - Number(a.z_index || 0));
+ for (const item of items) {
+ const g = objectGeom(item);
+ if (!g.visible) continue;
+ if (pointInPoly(p, g.corners)) return { item, geom: g };
+ }
+ return null;
+ }
+
+ function handleHit(geom, p) {
+ if (!geom || !geom.visible) return { kind: "none", cursor: editor.mode === "pano" ? "grab" : "default" };
+ if (type === "cutout" && Array.isArray(geom.edgeMidpoints)) {
+ const mid = geom.edgeMidpoints.find((m) => dist2(m, p) <= 13 * 13);
+ if (mid) {
+ const horiz = (mid.edge === "left" || mid.edge === "right");
+ return { kind: horiz ? "scale_x" : "scale_y", cursor: horiz ? "ew-resize" : "ns-resize", edge: mid.edge, mid };
+ }
+ }
+ const cornerIdx = geom.corners.findIndex((c) => dist2(c, p) <= 11 * 11);
+ if (cornerIdx >= 0) {
+ const c = geom.corners[cornerIdx];
+ const vx = c.x - geom.center.x;
+ const vy = c.y - geom.center.y;
+ const cursor = (vx * vy) >= 0 ? "nwse-resize" : "nesw-resize";
+ return { kind: "scale", cornerIdx, cursor };
+ }
+ if (dist2(geom.rotateHandle, p) <= 12 * 12) return { kind: "rotate", cursor: "grab" };
+ if (pointInPoly(p, geom.corners)) return { kind: "move", cursor: "move" };
+ return { kind: "none", cursor: editor.mode === "pano" ? "grab" : "default" };
+ }
+
+ function updateCursor(p) {
+ if (editor.interaction) {
+ if (editor.interaction.kind === "view") canvas.style.cursor = "grabbing";
+ else if (editor.interaction.kind === "move") canvas.style.cursor = "move";
+ else if (editor.interaction.kind === "scale" || editor.interaction.kind === "scale_x" || editor.interaction.kind === "scale_y") canvas.style.cursor = editor.interaction.cursor || "nwse-resize";
+ else if (editor.interaction.kind === "rotate") canvas.style.cursor = "grabbing";
+ else canvas.style.cursor = "default";
+ return;
+ }
+ const selected = getSelected();
+ const geom = selected ? objectGeom(selected) : null;
+ const h = handleHit(geom, p);
+ canvas.style.cursor = h.cursor;
+ }
+
+ function updateSelectionMenu() {
+ if (!selectionMenu) return;
+ const selected = getSelected();
+ if (!selected || editor.interaction) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ const menuMode = type === "stickers"
+ ? `stickers:${isExternalSticker(selected) ? "external" : "normal"}`
+ : `cutout:${editor.cutoutAspectOpen ? "open" : "closed"}`;
+ if (editor.menuMode !== menuMode) {
+ if (type === "stickers") {
+ selectionMenu.innerHTML = `
+
+
+ ${isExternalSticker(selected) ? "" : ``}
+ ${isExternalSticker(selected) ? `` : ""}
+ ${isExternalSticker(selected)
+ ? ``
+ : ``}
+ `;
+ } else {
+ const activeAspect = getCutoutAspectLabel(selected);
+ selectionMenu.innerHTML = `
+
+
+
+ `;
+ }
+ editor.menuMode = menuMode;
+ editor.menuSize.measured = false;
+ installTooltipHandlers(selectionMenu);
+ }
+ if (type === "stickers" && isExternalSticker(selected)) {
+ const backBtn = selectionMenu.querySelector("[data-action='back-initial']");
+ if (backBtn) {
+ const enabled = canRestoreSelectedToInitial();
+ backBtn.disabled = !enabled;
+ backBtn.setAttribute("aria-disabled", enabled ? "false" : "true");
+ backBtn.setAttribute("data-tip", enabled ? "Back to initial position" : "Already at initial position");
+ }
+ const toggleBtn = selectionMenu.querySelector("[data-action='toggle-visible']");
+ if (toggleBtn) {
+ const hidden = isStickerHidden(selected);
+ toggleBtn.innerHTML = hidden ? ICON.eye : ICON.eye_dashed;
+ toggleBtn.setAttribute("aria-label", hidden ? "Show" : "Hide");
+ toggleBtn.setAttribute("data-tip", hidden ? "Show input image" : "Hide input image");
+ }
+ }
+ const geom = objectGeom(selected);
+ if (!geom?.visible) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ const poly = geom.corners;
+ const xs = poly.map((p) => p.x);
+ const ys = poly.map((p) => p.y);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const maxY = Math.max(...ys);
+ const prevDisplay = selectionMenu.style.display;
+ const prevVisibility = selectionMenu.style.visibility;
+ selectionMenu.style.display = "flex";
+ selectionMenu.style.visibility = "hidden";
+ const rect = selectionMenu.getBoundingClientRect();
+ const measuredW = Math.round(Number(rect?.width || 0)) || selectionMenu.offsetWidth || editor.menuSize.w || 220;
+ const measuredH = Math.round(Number(rect?.height || 0)) || selectionMenu.offsetHeight || editor.menuSize.h || 40;
+ editor.menuSize.w = Number.isFinite(measuredW) && measuredW > 0 ? measuredW : 220;
+ editor.menuSize.h = Number.isFinite(measuredH) && measuredH > 0 ? measuredH : 40;
+ selectionMenu.style.display = prevDisplay;
+ selectionMenu.style.visibility = prevVisibility;
+ editor.menuSize.measured = true;
+ const menuW = editor.menuSize.w;
+ const menuH = editor.menuSize.h;
+ const pad = 14;
+ selectionMenu.style.display = "flex";
+ let x = (minX + maxX) * 0.5 - menuW * 0.5;
+ let y = maxY + 22;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ x = clamp(x, pad, canvas.width - menuW - pad);
+ if (y + menuH > canvas.height - pad) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ selectionMenu.style.left = `${x}px`;
+ selectionMenu.style.top = `${y}px`;
+ }
+
+ function hideTooltip() {
+ if (!tooltipEl) return;
+ if (tooltip.timer) {
+ clearTimeout(tooltip.timer);
+ tooltip.timer = 0;
+ }
+ tooltip.target = null;
+ tooltipEl.classList.remove("show");
+ }
+
+ function showTooltipFor(el) {
+ if (!tooltipEl || !el || !el.isConnected) return;
+ const text = String(el.getAttribute("data-tip") || "").trim();
+ if (!text) return;
+ tooltipEl.textContent = text;
+ const hostRect = stageWrap.getBoundingClientRect();
+ const rect = el.getBoundingClientRect();
+ const pad = 8;
+ const mw = tooltipEl.offsetWidth || 100;
+ const mh = tooltipEl.offsetHeight || 24;
+ let x = rect.left - hostRect.left + rect.width * 0.5 - mw * 0.5;
+ let y = rect.top - hostRect.top - mh - 8;
+ x = clamp(x, pad, Math.max(pad, hostRect.width - mw - pad));
+ y = Math.max(pad, y);
+ tooltipEl.style.left = `${x}px`;
+ tooltipEl.style.top = `${y}px`;
+ tooltipEl.classList.add("show");
+ }
+
+ function installTooltipHandlers(scope) {
+ scope.querySelectorAll("[data-tip]").forEach((el) => {
+ if (el.__panoTipBound) return;
+ el.__panoTipBound = true;
+ el.addEventListener("pointerenter", () => {
+ tooltip.target = el;
+ if (tooltip.timer) clearTimeout(tooltip.timer);
+ tooltip.timer = window.setTimeout(() => {
+ if (tooltip.target === el) showTooltipFor(el);
+ }, 1000);
+ });
+ el.addEventListener("pointerleave", () => {
+ if (tooltip.target === el) tooltip.target = null;
+ hideTooltip();
+ });
+ el.addEventListener("pointerdown", hideTooltip);
+ });
+ }
+
+ const viewController = createPanoInteractionController({
+ getView: () => ({ yaw: editor.viewYaw, pitch: editor.viewPitch, fov: editor.viewFov }),
+ setView: (next) => {
+ editor.viewYaw = wrapYaw(Number(next.yaw || 0));
+ editor.viewPitch = clamp(Number(next.pitch || 0), -89.9, 89.9);
+ editor.viewFov = clamp(Number(next.fov || editor.viewFov || 100), 35, 140);
+ },
+ getInvert: () => ({
+ x: state.ui_settings?.invert_view_x ? -1 : 1,
+ y: state.ui_settings?.invert_view_y ? -1 : 1,
+ }),
+ getUnwrapRect,
+ onInteraction: () => {
+ runtime.dirty = true;
+ },
+ });
+
+ canvas.onpointerdown = (e) => {
+ const p = screenPos(e);
+ editor.viewTween = null;
+ viewController.state.inertia.active = false;
+ viewController.state.inertia.vx = 0;
+ viewController.state.inertia.vy = 0;
+ if (e.button === 1) {
+ e.preventDefault();
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (e.button !== 0) return;
+ if (readOnly) {
+ if (editor.mode === "pano") {
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ }
+ return;
+ }
+
+ const selected = getSelected();
+ const selGeom = selected ? objectGeom(selected) : null;
+
+ if (selected && selGeom?.visible) {
+ const h = handleHit(selGeom, p);
+ if (h.kind === "scale") {
+ editor.interaction = {
+ kind: "scale",
+ item: selected,
+ center: selGeom.center,
+ startDist: Math.max(1, Math.hypot(p.x - selGeom.center.x, p.y - selGeom.center.y)),
+ startHFOV: Number(selected.hFOV_deg || 20),
+ startVFOV: Number(selected.vFOV_deg || 20),
+ cursor: h.cursor,
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "scale_x" || h.kind === "scale_y") {
+ editor.interaction = {
+ kind: h.kind,
+ item: selected,
+ center: selGeom.center,
+ startDist: Math.max(1, Math.hypot(p.x - selGeom.center.x, p.y - selGeom.center.y)),
+ startHFOV: Number(selected.hFOV_deg || 20),
+ startVFOV: Number(selected.vFOV_deg || 20),
+ cursor: h.cursor,
+ edge: h.edge,
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "rotate") {
+ editor.interaction = {
+ kind: "rotate",
+ item: selected,
+ center: selGeom.center,
+ startRot: Number(selected.rot_deg || selected.roll_deg || 0),
+ startAng: Math.atan2(p.y - selGeom.center.y, p.x - selGeom.center.x),
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "move") {
+ editor.interaction = {
+ kind: "move",
+ item: selected,
+ offset: { x: p.x - selGeom.center.x, y: p.y - selGeom.center.y },
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ }
+
+ const hit = hitObjectAt(p);
+ if (hit) {
+ const isNewSelection = editor.selectedId !== hit.item.id;
+ if (isNewSelection && editor.selectedId) {
+ pushHistory();
+ commitState();
+ }
+ editor.selectedId = hit.item.id;
+ if (type === "cutout" && isNewSelection) editor.cutoutAspectOpen = false;
+ if (type === "stickers") state.active.selected_sticker_id = hit.item.id;
+ else state.active.selected_shot_id = hit.item.id;
+ if (isNewSelection) updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ if (isNewSelection) {
+ updateCursor(p);
+ return;
+ }
+ editor.interaction = {
+ kind: "move",
+ item: hit.item,
+ offset: { x: p.x - hit.geom.center.x, y: p.y - hit.geom.center.y },
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+
+ const selectedBeforeClear = getSelected();
+ if (selectedBeforeClear) {
+ editor.panelLastValues = {
+ yaw_deg: Number(selectedBeforeClear.yaw_deg || 0),
+ pitch_deg: Number(selectedBeforeClear.pitch_deg || 0),
+ hFOV_deg: Number(selectedBeforeClear.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selectedBeforeClear.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selectedBeforeClear.rot_deg || 0),
+ roll_deg: Number(selectedBeforeClear.roll_deg || 0),
+ out_w: Number(selectedBeforeClear.out_w || 1024),
+ out_h: Number(selectedBeforeClear.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selectedBeforeClear),
+ };
+ }
+ const hadSelection = !!editor.selectedId;
+ if (hadSelection) {
+ pushHistory();
+ commitState();
+ }
+ editor.selectedId = null;
+ if (type === "cutout") editor.cutoutAspectOpen = false;
+ if (type === "stickers") state.active.selected_sticker_id = null;
+ else state.active.selected_shot_id = null;
+ if (hadSelection) updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+
+ if (editor.mode === "pano") {
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ }
+ };
+
+ canvas.onpointermove = (e) => {
+ if (!editor.interaction) return;
+ const p = screenPos(e);
+ updateCursor(p);
+ const it = editor.interaction;
+
+ if (it.kind === "view") {
+ const now = performance.now();
+ viewController.moveDrag(p.x, p.y, editor.mode === "unwrap" ? "unwrap" : "pano", now);
+ it.lastTs = now;
+ it.last = p;
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "move") {
+ const tx = p.x - it.offset.x;
+ const ty = p.y - it.offset.y;
+ if (editor.mode === "unwrap") {
+ const r = getUnwrapRect();
+ const nx = clamp((tx - r.x) / Math.max(r.w, 1), 0, 1);
+ const ny = clamp((ty - r.y) / Math.max(r.h, 1), 0, 1);
+ it.item.yaw_deg = wrapYaw(nx * 360 - 180);
+ it.item.pitch_deg = clamp(90 - ny * 180, -90, 90);
+ } else {
+ const dir = screenToWorldDir(tx, ty);
+ const yp = dirToYawPitch(dir);
+ it.item.yaw_deg = yp.yaw;
+ it.item.pitch_deg = yp.pitch;
+ }
+ requestDraw();
+ return;
+ }
+
+
+ if (it.kind === "scale") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.hFOV_deg = clamp(it.startHFOV * ratio, 1, 179);
+ it.item.vFOV_deg = clamp(it.startVFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "scale_x") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.hFOV_deg = clamp(it.startHFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "scale_y") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.vFOV_deg = clamp(it.startVFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "rotate") {
+ const a = Math.atan2(p.y - it.center.y, p.x - it.center.x);
+ let delta = (a - it.startAng) * RAD2DEG;
+ let out = it.startRot - delta;
+ if (e.shiftKey) out = Math.round(out / 45) * 45;
+ const key = type === "stickers" ? "rot_deg" : "roll_deg";
+ it.item[key] = out;
+ requestDraw();
+ }
+ };
+
+ canvas.onpointerup = () => {
+ const ended = editor.interaction;
+ if (editor.interaction && editor.interaction.kind !== "view") {
+ pushHistory();
+ commitState();
+ node.setDirtyCanvas(true, true);
+ syncSidePanelControls();
+ editor.hqFrames = 1;
+ updateSelectionMenu();
+ requestDraw();
+ }
+ editor.interaction = null;
+ if (ended && ended.kind === "view") {
+ viewController.endDrag(performance.now());
+ }
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ updateSelectionMenu();
+ requestDraw();
+ };
+
+ canvas.onpointercancel = () => {
+ if (editor.interaction?.kind === "view") {
+ viewController.endDrag(performance.now());
+ }
+ editor.interaction = null;
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ };
+
+ canvas.onauxclick = (e) => {
+ if (e.button === 1) e.preventDefault();
+ };
+
+ canvas.onmousemove = (e) => {
+ if (editor.interaction) return;
+ const p = screenPos(e);
+ updateCursor(p);
+ };
+
+ canvas.onwheel = (e) => {
+ if (editor.mode !== "pano") return;
+ if (viewController.applyWheelEvent(e)) requestDraw();
+ e.preventDefault();
+ };
+
+ canvas.ondragover = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ e.preventDefault();
+ setDropCue(true);
+ };
+
+ canvas.ondrop = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ e.preventDefault();
+ e.stopPropagation();
+ dragCue.depth = 0;
+ setDropCue(false);
+ const files = Array.from(e.dataTransfer?.files || []);
+ const file = files.find((f) => isImageFile(f));
+ if (!file) return;
+ void addImageStickerFromFile(file);
+ };
+
+ const onWindowDragEnter = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragHasImageFiles(e)) return;
+ dragCue.depth += 1;
+ setDropCue(true);
+ e.preventDefault();
+ };
+ const onWindowDragOver = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragCue.active && dragHasImageFiles(e)) setDropCue(true);
+ if (dragCue.active) e.preventDefault();
+ };
+ const onWindowDragLeave = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragCue.active) return;
+ dragCue.depth = Math.max(0, dragCue.depth - 1);
+ const leftWindow = (Number(e.clientX) <= 0 && Number(e.clientY) <= 0);
+ if (dragCue.depth === 0 || leftWindow) setDropCue(false);
+ };
+ const onWindowDrop = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ dragCue.depth = 0;
+ setDropCue(false);
+ if (dragHasImageFiles(e)) e.preventDefault();
+ };
+ window.addEventListener("dragenter", onWindowDragEnter, true);
+ window.addEventListener("dragover", onWindowDragOver, true);
+ window.addEventListener("dragleave", onWindowDragLeave, true);
+ window.addEventListener("drop", onWindowDrop, true);
+
+ viewBtns.forEach((btn) => {
+ btn.onclick = () => {
+ editor.mode = btn.dataset.view;
+ viewBtns.forEach((b) => b.setAttribute("aria-pressed", b === btn ? "true" : "false"));
+ if (viewToggle) viewToggle.setAttribute("data-selected", editor.mode === "unwrap" ? "unwrap" : "pano");
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ requestDraw();
+ };
+ });
+
+ const undoBtn = root.querySelector("[data-action='undo']");
+ if (undoBtn) {
+ undoBtn.onclick = () => {
+ if (readOnly) return;
+ restoreHistory(-1);
+ };
+ }
+ const redoBtn = root.querySelector("[data-action='redo']");
+ if (redoBtn) {
+ redoBtn.onclick = () => {
+ if (readOnly) return;
+ restoreHistory(1);
+ };
+ }
+ const addBtn = root.querySelector("[data-action='add']");
+ if (addBtn) {
+ addBtn.onclick = () => {
+ if (readOnly) return;
+ (type === "stickers" ? addImageSticker() : addCutoutFrame());
+ };
+ }
+ if (lookAtFrameBtn) {
+ lookAtFrameBtn.onclick = () => {
+ if (getList().length === 0) return;
+ const target = getSelected() || getList()[0];
+ if (!target) return;
+ editor.selectedId = target.id || null;
+ state.active.selected_shot_id = editor.selectedId;
+ const targetYaw = wrapYaw(Number(target.yaw_deg || 0));
+ const targetPitch = clamp(Number(target.pitch_deg || 0), -89.9, 89.9);
+ startViewTween(targetYaw, targetPitch, editor.viewFov);
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ };
+ }
+ const clearBtn = root.querySelector("[data-action='clear']");
+ if (clearBtn) {
+ clearBtn.onclick = () => {
+ if (readOnly) return;
+ clearAll();
+ };
+ }
+ const applyBtn = root.querySelector("[data-action='save']");
+ if (applyBtn) applyBtn.onclick = () => {
+ if (readOnly) return;
+ apply();
+ };
+ root.querySelector("[data-action='reset-view']").onclick = () => {
+ startViewTween(0, 0, 100, 180, 680);
+ };
+ const gridBtn = root.querySelector("[data-action='toggle-grid']");
+ const syncGridToggleButton = () => {
+ if (!gridBtn) return;
+ const visible = !!editor.showGrid;
+ gridBtn.innerHTML = visible ? ICON.eye : ICON.eye_dashed;
+ gridBtn.setAttribute("aria-pressed", visible ? "true" : "false");
+ gridBtn.setAttribute("aria-label", visible ? "Hide Grid" : "Show Grid");
+ gridBtn.setAttribute("data-tip", visible ? "Hide grid" : "Show grid");
+ };
+ if (gridBtn) {
+ syncGridToggleButton();
+ gridBtn.onclick = () => {
+ editor.showGrid = !editor.showGrid;
+ setNodeGridVisibility(node?.id, editor.showGrid);
+ syncGridToggleButton();
+ requestDraw();
+ };
+ }
+ const syncFullscreenButton = () => {
+ if (!fullscreenBtn) return;
+ const active = !!editor.fullscreen;
+ fullscreenBtn.innerHTML = active ? ICON.fullscreen_close : ICON.fullscreen;
+ fullscreenBtn.setAttribute("aria-label", active ? "Exit Fullscreen" : "Fullscreen");
+ fullscreenBtn.setAttribute("data-tip", active ? "Exit fullscreen" : "Fullscreen");
+ };
+ const setFullscreenState = (active) => {
+ const on = !!active;
+ if (editor.fullscreen === on) return;
+ editor.fullscreen = on;
+ root.classList.toggle("pano-modal-fullscreen", on);
+ if (on) {
+ editor.fullscreenPrevShowGrid = !!editor.showGrid;
+ editor.showGrid = false;
+ } else if (editor.fullscreenPrevShowGrid !== null) {
+ editor.showGrid = !!editor.fullscreenPrevShowGrid;
+ editor.fullscreenPrevShowGrid = null;
+ }
+ syncGridToggleButton();
+ syncFullscreenButton();
+ requestDraw();
+ };
+ const isOverlayFullscreen = () => document.fullscreenElement === overlay;
+ const toggleFullscreen = async () => {
+ try {
+ if (!document.fullscreenEnabled) {
+ setFullscreenState(!editor.fullscreen);
+ return;
+ }
+ if (!isOverlayFullscreen()) {
+ await overlay.requestFullscreen?.();
+ } else {
+ await document.exitFullscreen?.();
+ }
+ } catch {
+ setFullscreenState(!editor.fullscreen);
+ }
+ };
+ const onFullscreenChange = () => {
+ if (document.fullscreenEnabled) {
+ setFullscreenState(isOverlayFullscreen());
+ }
+ };
+ document.addEventListener("fullscreenchange", onFullscreenChange);
+ if (fullscreenBtn) {
+ syncFullscreenButton();
+ fullscreenBtn.onclick = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ toggleFullscreen();
+ };
+ }
+ const syncOutputPreviewToggleButton = () => {
+ if (!outputPreviewToggleBtn) return;
+ const expanded = !!editor.outputPreviewExpanded;
+ outputPreviewToggleBtn.innerHTML = expanded ? ICON.fullscreen_close : ICON.fullscreen;
+ outputPreviewToggleBtn.setAttribute("aria-label", expanded ? "Reduce Preview" : "Expand Preview");
+ outputPreviewToggleBtn.setAttribute("data-tip", expanded ? "Reduce preview" : "Expand preview");
+ };
+ if (outputPreviewToggleBtn) {
+ syncOutputPreviewToggleButton();
+ outputPreviewToggleBtn.onclick = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const nextExpanded = !editor.outputPreviewExpanded;
+ editor.outputPreviewExpanded = nextExpanded;
+ editor.outputPreviewAnimFrom = editor.outputPreviewAnim;
+ editor.outputPreviewAnimTo = nextExpanded ? 1 : 0;
+ editor.outputPreviewAnimStartTs = performance.now();
+ syncOutputPreviewToggleButton();
+ requestDraw();
+ };
+ }
+ selectionMenu.addEventListener("click", (ev) => {
+ const target = ev.target.closest("[data-action]");
+ if (!target) return;
+ const action = target.getAttribute("data-action");
+ if (readOnly) return;
+ if (action === "aspect") {
+ editor.cutoutAspectOpen = !editor.cutoutAspectOpen;
+ editor.menuSize.measured = false;
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "aspect-set") {
+ const selected = getSelected();
+ if (!selected) return;
+ const aspect = String(target.getAttribute("data-aspect") || "1:1");
+ applyCutoutAspect(selected, aspect);
+ editor.cutoutAspectOpen = false;
+ editor.menuSize.measured = false;
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "rotate-90") {
+ const selected = getSelected();
+ if (!selected) return;
+ rotateCutoutAspect90(selected);
+ editor.cutoutAspectOpen = false;
+ editor.menuSize.measured = false;
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "bring-front") {
+ bringSelectedToFront();
+ return;
+ }
+ if (action === "send-back") {
+ sendSelectedToBack();
+ return;
+ }
+ if (action === "duplicate") {
+ duplicateSelected();
+ return;
+ }
+ if (action === "back-initial") {
+ restoreSelectedToInitialPose();
+ return;
+ }
+ if (action === "toggle-visible") {
+ deleteSelected();
+ return;
+ }
+ if (action === "delete") {
+ deleteSelected();
+ return;
+ }
+ requestDraw();
+ });
+
+ const modalPrevOnExecuted = node.onExecuted;
+ const modalPrevOnConnectionsChange = node.onConnectionsChange;
+ let modalOnExecuted = null;
+ let modalOnConnectionsChange = null;
+ let modalExternalStickerSync = null;
+ if (!readOnly && type === "stickers") {
+ modalExternalStickerSync = (reason = "sync") => {
+ reconcileExternalStickerFromInputs(reason);
+ };
+ node.__panoExternalStickerSync = modalExternalStickerSync;
+ modalOnExecuted = function onPanoEditorExecuted(...args) {
+ if (typeof modalPrevOnExecuted === "function") {
+ modalPrevOnExecuted.apply(this, args);
+ }
+ this.__panoExternalStickerSync?.("executed");
+ };
+ node.onExecuted = modalOnExecuted;
+ modalOnConnectionsChange = function onPanoEditorConnectionsChange(...args) {
+ if (typeof modalPrevOnConnectionsChange === "function") {
+ modalPrevOnConnectionsChange.apply(this, args);
+ }
+ this.__panoExternalStickerSync?.("connections");
+ };
+ node.onConnectionsChange = modalOnConnectionsChange;
+ }
+
+ const closeEditor = () => {
+ if (document.fullscreenElement === overlay) {
+ document.exitFullscreen?.().catch(() => { });
+ }
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
+ node.__panoLiveStateOverride = null;
+ node.__panoDomPreview?.requestDraw?.();
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+ hideTooltip();
+ stopRenderLoop();
+ setDropCue(false);
+ window.removeEventListener("keydown", onEscClose, true);
+ window.removeEventListener("keydown", onDeleteKey, true);
+ window.removeEventListener("keydown", onUndoRedoKey, true);
+ window.removeEventListener("dragenter", onWindowDragEnter, true);
+ window.removeEventListener("dragover", onWindowDragOver, true);
+ window.removeEventListener("dragleave", onWindowDragLeave, true);
+ window.removeEventListener("drop", onWindowDrop, true);
+ if (!readOnly && type === "stickers") {
+ if (node.onExecuted === modalOnExecuted) node.onExecuted = modalPrevOnExecuted;
+ if (node.onConnectionsChange === modalOnConnectionsChange) node.onConnectionsChange = modalPrevOnConnectionsChange;
+ if (node.__panoExternalStickerSync === modalExternalStickerSync) node.__panoExternalStickerSync = null;
+ }
+ overlay.remove();
+ };
+ const onEscClose = (ev) => {
+ if (ev.key !== "Escape") return;
+ if (editor.fullscreen && document.fullscreenElement === overlay) {
+ document.exitFullscreen?.().catch(() => { });
+ return;
+ }
+ if (editor.fullscreen) {
+ setFullscreenState(false);
+ return;
+ }
+ closeEditor();
+ };
+ const onDeleteKey = (ev) => {
+ const key = String(ev.key || "");
+ const code = String(ev.code || "");
+ const keyCode = Number(ev.keyCode || 0);
+ const isDeleteKey = key === "Delete" || code === "Delete" || keyCode === 46;
+ const isBackspaceKey = key === "Backspace" || code === "Backspace" || keyCode === 8;
+ if (!isDeleteKey && !isBackspaceKey) return;
+ const t = ev.target;
+ const tag = (t?.tagName || "").toUpperCase();
+ if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return;
+ const selected = getSelected();
+ if (!selected) return;
+ deleteSelected();
+ ev.preventDefault();
+ ev.stopPropagation();
+ };
+ const onUndoRedoKey = (ev) => {
+ if (readOnly) return;
+ if (!ev.ctrlKey && !ev.metaKey) return;
+ const key = String(ev.key || "").toLowerCase();
+ const code = String(ev.code || "");
+ if (key !== "z" && code !== "KeyZ") return;
+ const t = ev.target;
+ const tag = (t?.tagName || "").toUpperCase();
+ if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return;
+ restoreHistory(ev.shiftKey ? 1 : -1);
+ ev.preventDefault();
+ ev.stopPropagation();
+ };
+ window.addEventListener("keydown", onEscClose, true);
+ window.addEventListener("keydown", onDeleteKey, true);
+ window.addEventListener("keydown", onUndoRedoKey, true);
+ overlay.addEventListener("pointerdown", (ev) => {
+ if (ev.target === overlay) closeEditor();
+ });
+
+ installTooltipHandlers(root);
+ applyInitialCutoutFocus();
+ if (!readOnly && type === "stickers") {
+ reconcileExternalStickerFromInputs("open");
+ }
+ void migrateLegacyEmbeddedAssets();
+ pushHistory();
+ updateSidePanel();
+ syncLookAtFrameButtonState();
+ syncCanvasSize();
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ requestDraw();
+ runtime.rafId = requestAnimationFrame(tick);
+}
+
+function installEditorButton(nodeType, nodeData, matchType, buttonText) {
+ const cleanupPreviewBindings = (node) => {
+ try { node.__panoDomRestore?.(); } catch { }
+ try { node.__panoLegacyRestore?.(); } catch { }
+ node.__panoDomPreview = null;
+ node.__panoLegacyPreviewHooked = false;
+ node.__panoPreviewHooked = false;
+ node.__panoPreviewAttached = false;
+ node.__panoPreviewMountKey = null;
+ };
+
+ function installOrUpdate(node) {
+ const mountKey = `editor_btn|${matchType}`;
+ const alreadyAttached = node.__panoPreviewAttached === true && node.__panoPreviewMountKey === mountKey;
+
+ // Avoid redundant cleanup/re-attach if already attached with the same key
+ if (alreadyAttached) return;
+
+ cleanupPreviewBindings(node);
+ hideWidget(node, STATE_WIDGET);
+
+ const sw = getWidget(node, STATE_WIDGET);
+ if (sw && !sw.__panoPreviewPatchedCb) {
+ sw.__panoPreviewPatchedCb = true;
+ const prevCb = sw.callback;
+ sw.callback = (v) => {
+ const r = prevCb ? prevCb(v) : undefined;
+ // Only trigger soft repaint, avoid forcing a full workflow persistence on every move if storage is tight
+ node.setDirtyCanvas?.(true, false);
+ return r;
+ };
+ }
+ const bg = getWidget(node, "bg_color");
+ if (bg && (bg.value == null || String(bg.value).trim() === "" || String(bg.value).toLowerCase() === "#000000")) {
+ bg.value = "#00ff00";
+ bg.callback?.("#00ff00");
+ }
+
+ if (matchType === "PanoramaStickers") {
+ ensureActionButtonWidget(node, buttonText, () => showEditor(node, "stickers"));
+ if (ENABLE_STICKERS_NODE_PREVIEW) {
+ attachStickersNodePreview(node, {
+ enabled: true,
+ buttonText,
+ onOpen: () => showEditor(node, "stickers"),
+ });
+ // Respect user-sized nodes; initialize only when preview is enabled and size is invalid.
+ if (!Array.isArray(node.size) || node.size[0] < 10 || node.size[1] < 10) {
+ node.size = [360, 260];
+ }
+ } else {
+ // Without node preview, let LiteGraph size the node from widgets only.
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ return;
+ }
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ return;
+ }
+
+ ensureActionButtonWidget(node, buttonText, () => showEditor(node, "cutout"));
+ attachCutoutPreview(node, {
+ buttonText,
+ onOpen: () => showEditor(node, "cutout"),
+ });
+
+ if (!Array.isArray(node.size) || node.size[0] < 10 || node.size[1] < 10) {
+ node.size = [360, 260];
+ }
+
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ }
+
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
+ installOrUpdate(this);
+ return r;
+ };
+
+ const onConfigure = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function () {
+ const r = onConfigure ? onConfigure.apply(this, arguments) : undefined;
+ if (this.widgets) installOrUpdate(this);
+ return r;
+ };
+
+ const onAdded = nodeType.prototype.onAdded;
+ nodeType.prototype.onAdded = function () {
+ const r = onAdded ? onAdded.apply(this, arguments) : undefined;
+ if (this.widgets) installOrUpdate(this);
+ return r;
+ };
+}
+
+function installStandalonePreviewNode(nodeType) {
+ if (!Array.isArray(nodeType?.prototype?.size) || nodeType.prototype.size[0] < 100 || nodeType.prototype.size[1] < 100) {
+ nodeType.prototype.size = [360, 260];
+ }
+}
+
+function installStandalonePreviewInstance(node) {
+ if (!node) return;
+ if (node.__panoStandaloneInstallDone) return;
+ if (node.__panoStandaloneInstallProbeActive) return;
+ node.__panoStandaloneInstallProbeActive = true;
+
+ const tryInstall = () => {
+ const nodeId = Number(node?.id ?? -1);
+ const ready = nodeId >= 0 && !!node?.graph;
+ const tries = Number(node.__panoStandaloneInstallProbeTries || 0) + 1;
+ node.__panoStandaloneInstallProbeTries = tries;
+
+ if (!ready && tries < 40) {
+ requestAnimationFrame(tryInstall);
+ return;
+ }
+ ensureActionButtonWidget(node, "Open Preview", () => showEditor(node, "stickers", { readOnly: true, hideSidebar: false }));
+ attachPreviewNode(node, {
+ buttonText: "Open Preview",
+ modalTitle: "TY360 Preview",
+ imageInputName: "erp_image",
+ onOpen: (n) => showEditor(n, "stickers", { readOnly: true, hideSidebar: false }),
+ });
+ node.__panoStandaloneInstallDone = true;
+ node.__panoStandaloneInstallProbeActive = false;
+ };
+
+ requestAnimationFrame(tryInstall);
+}
+
+app.registerExtension({
+ name: "ComfyUI.TY360.Editor",
+ beforeRegisterNodeDef(nodeType, nodeData) {
+ const name = String(nodeData?.name || "");
+ if (name === "TY360Roundtrip" || name === "TY360 Roundtrip") {
+ installEditorButton(nodeType, nodeData, "TY360Roundtrip", "Open Roundtrip Editor");
+ }
+ if (isPanoramaPreviewNodeName(name)) {
+ installStandalonePreviewNode(nodeType);
+ }
+ },
+ nodeCreated(node) {
+ const name = String(node?.comfyClass || node?.type || node?.title || "");
+ if (!isPanoramaPreviewNodeName(name)) return;
+ installStandalonePreviewInstance(node);
+ },
+});
diff --git a/web/pano_editor_core.js b/web/pano_editor_core.js
new file mode 100644
index 0000000..921454e
--- /dev/null
+++ b/web/pano_editor_core.js
@@ -0,0 +1,5327 @@
+function clamp(v, lo, hi) {
+ return Math.max(lo, Math.min(hi, v));
+}
+
+function clampSafe(v, lo, hi) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return Number(lo);
+ return Math.max(Number(lo), Math.min(Number(hi), n));
+}
+
+function wrapYaw(y) {
+ return ((y + 180) % 360 + 360) % 360 - 180;
+}
+
+function shortestYawDelta(from, to) {
+ let d = wrapYaw(to) - wrapYaw(from);
+ if (d > 180) d -= 360;
+ if (d < -180) d += 360;
+ return d;
+}
+
+const INTERACTION_DEG2RAD = Math.PI / 180;
+const PANO_DRAG_SENSITIVITY = 0.12;
+const PANO_WHEEL_STEP = 3;
+const PANO_FOV_MIN = 35;
+const PANO_FOV_MAX = 140;
+const PANO_INITIAL_FOV = 100;
+const PANO_INERTIA_BLEND_OLD = 0.4;
+const PANO_INERTIA_BLEND_INST = 0.6;
+const PANO_INERTIA_DAMPING = 5.5;
+const PANO_INERTIA_START_SPEED = 20;
+const PANO_INERTIA_STOP_SPEED = 0.8;
+
+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;
+}
+
+function createPanoInteractionController(options = {}) {
+ const getView = typeof options.getView === "function" ? options.getView : (() => ({ yaw: 0, pitch: 0, fov: PANO_INITIAL_FOV }));
+ const setView = typeof options.setView === "function" ? options.setView : (() => {});
+ const getInvert = typeof options.getInvert === "function" ? options.getInvert : (() => ({ x: 1, y: 1 }));
+ const getUnwrapRect = typeof options.getUnwrapRect === "function" ? options.getUnwrapRect : (() => ({ w: 1, h: 1 }));
+ const onInteraction = typeof options.onInteraction === "function" ? options.onInteraction : (() => {});
+ const onDebug = typeof options.onDebug === "function" ? options.onDebug : null;
+
+ const state = {
+ drag: { active: false, lastX: 0, lastY: 0, lastTs: 0, pointerId: null },
+ inertia: { vx: 0, vy: 0, active: false, lastTs: 0 },
+ };
+
+ function log(tag, payload = null) {
+ if (!onDebug) return;
+ onDebug(tag, payload);
+ }
+
+ function startDrag(x, y, pointerId = null, ts = performance.now()) {
+ state.drag.active = true;
+ state.drag.lastX = Number(x || 0);
+ state.drag.lastY = Number(y || 0);
+ state.drag.lastTs = Number(ts || performance.now());
+ state.drag.pointerId = pointerId;
+ state.inertia.active = false;
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ state.inertia.lastTs = state.drag.lastTs;
+ log("drag", { phase: "start", x: state.drag.lastX, y: state.drag.lastY, pointerId });
+ return true;
+ }
+
+ function moveDrag(x, y, mode = "pano", ts = performance.now()) {
+ if (!state.drag.active) return false;
+ const now = Number(ts || performance.now());
+ const px = Number(x);
+ const py = Number(y);
+ const dt = Math.max(0.001, (now - (state.drag.lastTs || now)) / 1000);
+ const dx = px - state.drag.lastX;
+ const dy = py - state.drag.lastY;
+ state.drag.lastX = px;
+ state.drag.lastY = py;
+ state.drag.lastTs = now;
+
+ const inv = getInvert() || { x: 1, y: 1 };
+ const invX = Number(inv.x || 1);
+ const invY = Number(inv.y || 1);
+ const view = { ...getView() };
+
+ let dYaw = 0;
+ let dPitch = 0;
+ if (mode === "unwrap") {
+ const r = getUnwrapRect() || { w: 1, h: 1 };
+ const rw = Math.max(1, Number(r.w || 1));
+ const rh = Math.max(1, Number(r.h || 1));
+ const nx = dx / rw;
+ const ny = dy / rh;
+ dYaw = -nx * 360 * invX;
+ dPitch = ny * 180 * invY;
+ } else {
+ dYaw = -dx * PANO_DRAG_SENSITIVITY * invX;
+ dPitch = dy * PANO_DRAG_SENSITIVITY * invY;
+ }
+
+ view.yaw = wrapYaw(Number(view.yaw || 0) + dYaw);
+ view.pitch = clamp(Number(view.pitch || 0) + dPitch, -89.9, 89.9);
+ setView(view);
+
+ state.inertia.vx = state.inertia.vx * PANO_INERTIA_BLEND_OLD + (dYaw / dt) * PANO_INERTIA_BLEND_INST;
+ state.inertia.vy = state.inertia.vy * PANO_INERTIA_BLEND_OLD + (dPitch / dt) * PANO_INERTIA_BLEND_INST;
+ state.inertia.active = false;
+ state.inertia.lastTs = now;
+ onInteraction();
+ log("drag", { phase: "move", dx, dy, dYaw, dPitch });
+ return true;
+ }
+
+ function endDrag(ts = performance.now()) {
+ if (!state.drag.active) return false;
+ state.drag.active = false;
+ state.drag.lastTs = Number(ts || performance.now());
+ const speed = Math.hypot(state.inertia.vx || 0, state.inertia.vy || 0);
+ state.inertia.active = speed > PANO_INERTIA_START_SPEED;
+ state.inertia.lastTs = state.drag.lastTs;
+ log("drag", { phase: "end", speed, inertiaActive: state.inertia.active });
+ return true;
+ }
+
+ function stepInertia(ts = performance.now()) {
+ if (!state.inertia.active) return false;
+ const now = Number(ts || performance.now());
+ const dt = Math.max(0.001, (now - (state.inertia.lastTs || now)) / 1000);
+ state.inertia.lastTs = now;
+ const view = { ...getView() };
+ view.yaw = wrapYaw(Number(view.yaw || 0) + state.inertia.vx * dt);
+ view.pitch = clamp(Number(view.pitch || 0) + state.inertia.vy * dt, -89.9, 89.9);
+ const damping = Math.exp(-PANO_INERTIA_DAMPING * dt);
+ state.inertia.vx *= damping;
+ state.inertia.vy *= damping;
+ if (Math.abs(state.inertia.vx) < PANO_INERTIA_STOP_SPEED && Math.abs(state.inertia.vy) < PANO_INERTIA_STOP_SPEED) {
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ state.inertia.active = false;
+ }
+ setView(view);
+ onInteraction();
+ return state.inertia.active;
+ }
+
+ function applyWheel(deltaSign) {
+ const sign = Math.sign(Number(deltaSign || 0));
+ if (!sign) return false;
+ const view = { ...getView() };
+ view.fov = clamp(Number(view.fov || PANO_INITIAL_FOV) + sign * PANO_WHEEL_STEP, PANO_FOV_MIN, PANO_FOV_MAX);
+ setView(view);
+ onInteraction();
+ return true;
+ }
+
+ function applyWheelEvent(ev) {
+ return applyWheel(Math.sign(readWheelDelta(ev)));
+ }
+
+ function resetView() {
+ setView({ yaw: 0, pitch: 0, fov: PANO_INITIAL_FOV });
+ state.inertia.active = false;
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ onInteraction();
+ }
+
+ return {
+ state,
+ startDrag,
+ moveDrag,
+ endDrag,
+ stepInertia,
+ applyWheel,
+ applyWheelEvent,
+ resetView,
+ };
+}
+
+const CUTOUT_PREVIEW_DEG2RAD = Math.PI / 180;
+
+function cutoutFiniteOr(v, fallback = 0) {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : Number(fallback);
+}
+
+function cutoutVec3(x, y, z) {
+ return { x, y, z };
+}
+
+function cutoutAdd(a, b) {
+ return cutoutVec3(a.x + b.x, a.y + b.y, a.z + b.z);
+}
+
+function cutoutMul(a, s) {
+ return cutoutVec3(a.x * s, a.y * s, a.z * s);
+}
+
+function cutoutNorm(a) {
+ const l = Math.hypot(a.x, a.y, a.z) || 1e-8;
+ return cutoutVec3(a.x / l, a.y / l, a.z / l);
+}
+
+function cutoutYawPitchToDir(yawDeg, pitchDeg) {
+ const yaw = Number(yawDeg || 0) * CUTOUT_PREVIEW_DEG2RAD;
+ const pitch = Number(pitchDeg || 0) * CUTOUT_PREVIEW_DEG2RAD;
+ const cp = Math.cos(pitch);
+ return cutoutVec3(cp * Math.sin(yaw), Math.sin(pitch), cp * Math.cos(yaw));
+}
+
+function cutoutCross(a, b) {
+ return cutoutVec3(
+ 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 cutoutPreviewCameraBasis(yawDeg, pitchDeg, rollDeg = 0) {
+ const fwd = cutoutYawPitchToDir(yawDeg, pitchDeg);
+ const worldUp = cutoutVec3(0, 1, 0);
+ let right = cutoutCross(worldUp, fwd);
+ if (Math.hypot(right.x, right.y, right.z) < 1e-6) right = cutoutVec3(1, 0, 0);
+ right = cutoutNorm(right);
+ let up = cutoutNorm(cutoutCross(fwd, right));
+ const rr = Number(rollDeg || 0) * CUTOUT_PREVIEW_DEG2RAD;
+ const cr = Math.cos(rr);
+ const sr = Math.sin(rr);
+ const r2 = cutoutAdd(cutoutMul(right, cr), cutoutMul(up, sr));
+ const u2 = cutoutAdd(cutoutMul(right, -sr), cutoutMul(up, cr));
+ return { fwd, right: cutoutNorm(r2), up: cutoutNorm(u2) };
+}
+
+function expandTriForPreview(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 drawImageTriForProjection(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 false;
+ const [e0, e1, e2] = expandTriForPreview(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();
+ return true;
+}
+
+function getWrappedErpCanvasForProjection(owner, 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 (!owner.__panoSharedWrappedErpCache) owner.__panoSharedWrappedErpCache = { src: "", w: 0, h: 0, canvas: null };
+ const src = String(img.src || "");
+ const cached = owner.__panoSharedWrappedErpCache;
+ 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);
+ owner.__panoSharedWrappedErpCache = { src, w: iw, h: ih, canvas: cv };
+ return cv;
+}
+
+function getCutoutShotParams(shot) {
+ const s = shot || {};
+ const yaw = cutoutFiniteOr(s.yaw_deg, 0);
+ const pitch = cutoutFiniteOr(s.pitch_deg, 0);
+ const roll = cutoutFiniteOr((s.roll_deg ?? s.rot_deg), 0);
+ const hfov = clamp(cutoutFiniteOr(s.hFOV_deg, 90), 1, 179);
+ const vfov = clamp(cutoutFiniteOr(s.vFOV_deg, 60), 1, 179);
+ const aspectRaw = Math.tan((hfov * CUTOUT_PREVIEW_DEG2RAD) * 0.5) / Math.max(1e-6, Math.tan((vfov * CUTOUT_PREVIEW_DEG2RAD) * 0.5));
+ const aspect = clamp(aspectRaw, 0.1, 10.0);
+ return { yaw, pitch, roll, hfov, vfov, aspect };
+}
+
+function drawCutoutProjectionPreview(ctx, owner, img, rect, shot, quality = "balanced") {
+ const setStats = (stats = {}) => {
+ owner.__panoCutoutProjectionStats = {
+ drawnTriCount: 0,
+ Nu: 0,
+ Nv: 0,
+ rectW: cutoutFiniteOr(rect?.w, 0),
+ rectH: cutoutFiniteOr(rect?.h, 0),
+ imageW: cutoutFiniteOr(img?.naturalWidth || img?.width, 0),
+ imageH: cutoutFiniteOr(img?.naturalHeight || img?.height, 0),
+ ...stats,
+ };
+ };
+ if (!ctx || !owner || !img || !rect || !shot) {
+ if (owner) setStats({ reason: "invalid_args" });
+ return false;
+ }
+ if (!img.complete || !(img.naturalWidth || img.width)) {
+ setStats({ reason: "image_not_ready" });
+ return false;
+ }
+ const rw = cutoutFiniteOr(rect.w, 0);
+ const rh = cutoutFiniteOr(rect.h, 0);
+ if (rw <= 1 || rh <= 1) {
+ setStats({ reason: "invalid_rect", rectW: rw, rectH: rh });
+ return false;
+ }
+ const iw = Number(img.naturalWidth || img.width || 0);
+ const ih = Number(img.naturalHeight || img.height || 0);
+ if (iw <= 1 || ih <= 1) {
+ setStats({ reason: "invalid_image", imageW: iw, imageH: ih });
+ return false;
+ }
+ const source = getWrappedErpCanvasForProjection(owner, img) || img;
+ const { yaw, pitch, roll, hfov, vfov } = getCutoutShotParams(shot);
+ const basis = cutoutPreviewCameraBasis(yaw, pitch, roll);
+ const tanX = Math.tan((hfov * CUTOUT_PREVIEW_DEG2RAD) * 0.5);
+ const tanY = Math.tan((vfov * CUTOUT_PREVIEW_DEG2RAD) * 0.5);
+ const Nu = quality === "high" ? 20 : (quality === "draft" ? 10 : 14);
+ const Nv = quality === "high" ? 14 : (quality === "draft" ? 7 : 10);
+ let drawnTriCount = 0;
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const nx = (u * 2 - 1) * tanX;
+ const ny = (1 - v * 2) * tanY;
+ const d = cutoutNorm(cutoutAdd(cutoutAdd(basis.fwd, cutoutMul(basis.right, nx)), cutoutMul(basis.up, ny)));
+ const lon = Math.atan2(d.x, d.z);
+ const lat = Math.asin(clamp(d.y, -1, 1));
+ let su = (lon / (2 * Math.PI) + 0.5) * iw;
+ while (su < 0) su += iw;
+ while (su >= iw) su -= iw;
+ const sv = (0.5 - lat / Math.PI) * ih;
+ verts[j][i] = { x: rect.x + u * rect.w, y: rect.y + v * rect.h };
+ sample[j][i] = { x: su, y: sv };
+ }
+ }
+ 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];
+ 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;
+ });
+ }
+ if (drawImageTriForProjection(ctx, source, s00, s10, s11, p00, p10, p11)) drawnTriCount += 1;
+ if (drawImageTriForProjection(ctx, source, s00, s11, s01, p00, p11, p01)) drawnTriCount += 1;
+ }
+ }
+ setStats({ drawnTriCount, Nu, Nv, rectW: rw, rectH: rh, imageW: iw, imageH: ih, reason: "ok" });
+ return drawnTriCount > 0;
+}
+
+const app = null;
+const api = null;
+const attachCutoutPreview = () => {};
+const attachPreviewNode = () => {};
+const attachStickersNodePreview = () => {};
+const isPanoramaPreviewNodeName = () => false;
+
+const STATE_WIDGET = "state_json";
+const EXTERNAL_STICKER_ID = "sticker_image_1";
+const EXTERNAL_STICKER_SOURCE_KIND = "external_image";
+const EXTERNAL_STICKER_PREVIEW_KEY = "pano_sticker_input_images";
+const ENABLE_STICKERS_NODE_PREVIEW = false;
+const DEG2RAD = Math.PI / 180;
+const RAD2DEG = 180 / Math.PI;
+const ICON = {
+ // Source: @geist-ui/icons globe.js (v1.0.2)
+ globe: "",
+ pano: "",
+ unwrap: "",
+ undo: "",
+ redo: "",
+ add: "",
+ clear: "",
+ duplicate: "",
+ bring_front: "",
+ send_back: "",
+ aspect: "",
+ rotate_90: "",
+ back_initial: "",
+ delete: "",
+ reset: "",
+ eye: "",
+ eye_dashed: "",
+ fullscreen: "",
+ camera: "",
+ fullscreen_close: "",
+ close: "",
+ copy: "",
+ chevron: "",
+};
+
+function easeInOutCubic(t) {
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
+}
+function easeOutCubic(t) {
+ return 1 - Math.pow(1 - t, 3);
+}
+function easeInCubic(t) {
+ return t * t * t;
+}
+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 dirToYawPitch(d) {
+ return {
+ yaw: wrapYaw(Math.atan2(d.x, d.z) * RAD2DEG),
+ pitch: clamp(Math.asin(clamp(d.y, -1, 1)) * RAD2DEG, -90, 90),
+ };
+}
+function pointInPoly(pt, poly) {
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const xi = poly[i].x; const yi = poly[i].y;
+ const xj = poly[j].x; const yj = poly[j].y;
+ const intersect = ((yi > pt.y) !== (yj > pt.y))
+ && (pt.x < ((xj - xi) * (pt.y - yi)) / ((yj - yi) || 1e-6) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+}
+function dist2(a, b) {
+ const dx = a.x - b.x;
+ const dy = a.y - b.y;
+ return dx * dx + dy * dy;
+}
+function lerp(a, b, t) {
+ return a + (b - a) * t;
+}
+function formatParamValue(v) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return "0";
+ return Number(n.toFixed(3)).toString();
+}
+function toPositiveFinite(v, fallback = 0) {
+ const n = Number(v);
+ return Number.isFinite(n) && n > 0 ? n : Number(fallback);
+}
+function ratioTextFromPair(w, h) {
+ const ww = toPositiveFinite(w, 1);
+ const hh = toPositiveFinite(h, 1);
+ if (ww <= 0 || hh <= 0) return "1:1";
+ const scale = 1000;
+ const wi = Math.max(1, Math.round(ww * scale));
+ const hi = Math.max(1, Math.round(hh * scale));
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
+ const g = gcd(wi, hi) || 1;
+ const rw = Math.max(1, Math.round(wi / g));
+ const rh = Math.max(1, Math.round(hi / g));
+ return `${rw}:${rh}`;
+}
+function getCutoutAspectLabel(item) {
+ if (!item || typeof item !== "object") return "1:1";
+ const stored = String(item.aspect_id || "").trim();
+ if (stored) return stored;
+ const ow = toPositiveFinite(item.out_w, 0);
+ const oh = toPositiveFinite(item.out_h, 0);
+ if (ow > 0 && oh > 0) return ratioTextFromPair(ow, oh);
+ const hf = clamp(Number(item.hFOV_deg || 90), 1, 179) * DEG2RAD;
+ const vf = clamp(Number(item.vFOV_deg || 60), 1, 179) * DEG2RAD;
+ const rw = Math.max(1e-6, Math.tan(hf * 0.5));
+ const rh = Math.max(1e-6, Math.tan(vf * 0.5));
+ return ratioTextFromPair(rw, rh);
+}
+function expandTri(d0, d1, d2, px = 1.1) {
+ 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 installCss() {
+ if (document.getElementById("pano-suite-style-link")) return;
+ const link = document.createElement("link");
+ link.id = "pano-suite-style-link";
+ link.rel = "stylesheet";
+ link.href = new URL("./pano_editor.css", import.meta.url).toString();
+ document.head.appendChild(link);
+}
+
+const SHARED_UI_SETTINGS_KEY = "pano_suite.ui_settings.v1";
+const NODE_GRID_VISIBILITY_KEY = "pano_suite.node_grid_visibility.v1";
+let sharedUiSettingsMemory = null;
+let nodeGridVisibilityMemory = null;
+let parseStateJsonCache = { text: null, parsed: null };
+
+function normalizeUiSettings(raw) {
+ const src = (raw && typeof raw === "object") ? raw : {};
+ const q = String(src.preview_quality || "balanced");
+ return {
+ invert_view_x: !!src.invert_view_x,
+ invert_view_y: !!src.invert_view_y,
+ preview_quality: (q === "draft" || q === "balanced" || q === "high") ? q : "balanced",
+ };
+}
+
+function loadSharedUiSettings() {
+ try {
+ const text = String(window?.localStorage?.getItem(SHARED_UI_SETTINGS_KEY) || "").trim();
+ if (!text) return sharedUiSettingsMemory ? normalizeUiSettings(sharedUiSettingsMemory) : null;
+ const parsed = JSON.parse(text);
+ const normalized = normalizeUiSettings(parsed);
+ sharedUiSettingsMemory = normalized;
+ return normalized;
+ } catch {
+ return sharedUiSettingsMemory ? normalizeUiSettings(sharedUiSettingsMemory) : null;
+ }
+}
+
+function saveSharedUiSettings(settings) {
+ const normalized = normalizeUiSettings(settings);
+ sharedUiSettingsMemory = normalized;
+ try {
+ window?.localStorage?.setItem(SHARED_UI_SETTINGS_KEY, JSON.stringify(normalized));
+ } catch {
+ // localStorage unavailable; memory fallback is used.
+ }
+ return normalized;
+}
+
+function loadNodeGridVisibilityMap() {
+ if (nodeGridVisibilityMemory && typeof nodeGridVisibilityMemory === "object") {
+ return nodeGridVisibilityMemory;
+ }
+ try {
+ const text = String(window?.localStorage?.getItem(NODE_GRID_VISIBILITY_KEY) || "").trim();
+ if (!text) {
+ nodeGridVisibilityMemory = {};
+ return nodeGridVisibilityMemory;
+ }
+ const parsed = JSON.parse(text);
+ nodeGridVisibilityMemory = parsed && typeof parsed === "object" ? parsed : {};
+ return nodeGridVisibilityMemory;
+ } catch {
+ nodeGridVisibilityMemory = {};
+ return nodeGridVisibilityMemory;
+ }
+}
+
+function getNodeGridVisibility(nodeId, fallback = true) {
+ const key = String(nodeId ?? "").trim();
+ if (!key) return !!fallback;
+ const map = loadNodeGridVisibilityMap();
+ const v = map[key];
+ return typeof v === "boolean" ? v : !!fallback;
+}
+
+function setNodeGridVisibility(nodeId, visible) {
+ const key = String(nodeId ?? "").trim();
+ if (!key) return;
+ const map = loadNodeGridVisibilityMap();
+ map[key] = !!visible;
+ nodeGridVisibilityMemory = map;
+ try {
+ window?.localStorage?.setItem(NODE_GRID_VISIBILITY_KEY, JSON.stringify(map));
+ } catch {
+ // localStorage unavailable; memory fallback is used.
+ }
+}
+
+function cloneAssetMap(raw) {
+ if (!raw || typeof raw !== "object") return {};
+ const out = {};
+ Object.entries(raw).forEach(([k, v]) => {
+ out[k] = (v && typeof v === "object") ? { ...v } : v;
+ });
+ return out;
+}
+
+function cloneStickerList(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((item) => {
+ if (!item || typeof item !== "object") return item;
+ const next = { ...item };
+ if (next.crop && typeof next.crop === "object") next.crop = { ...next.crop };
+ if (next.initial_pose && typeof next.initial_pose === "object") next.initial_pose = { ...next.initial_pose };
+ next.visible = next.visible !== false;
+ return next;
+ });
+}
+
+function cloneShotList(raw) {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((item) => ((item && typeof item === "object") ? { ...item } : item));
+}
+
+function parseState(text, preset = 2048, bg = "#00ff00") {
+ const sharedUi = loadSharedUiSettings();
+ const base = {
+ version: 1,
+ projection_model: "pinhole_rectilinear",
+ alpha_mode: "straight",
+ bg_color: bg,
+ output_preset: preset,
+ assets: {},
+ stickers: [],
+ shots: [],
+ ui_settings: {
+ invert_view_x: !!sharedUi?.invert_view_x,
+ invert_view_y: !!sharedUi?.invert_view_y,
+ preview_quality: String(sharedUi?.preview_quality || "balanced"),
+ },
+ active: { selected_sticker_id: null, selected_shot_id: null },
+ };
+ const textTrimmed = String(text || "").trim();
+ if (!textTrimmed) return base;
+ try {
+ let p = null;
+ if (parseStateJsonCache.text === textTrimmed) {
+ p = parseStateJsonCache.parsed;
+ } else {
+ p = JSON.parse(textTrimmed);
+ parseStateJsonCache = { text: textTrimmed, parsed: p };
+ }
+ if (!p || typeof p !== "object") return base;
+ const merged = {
+ ...base,
+ ...p,
+ version: 1,
+ projection_model: "pinhole_rectilinear",
+ alpha_mode: "straight",
+ assets: cloneAssetMap(p.assets),
+ stickers: cloneStickerList(p.stickers),
+ shots: cloneShotList(p.shots),
+ ui_settings: {
+ invert_view_x: !!(p.ui_settings && p.ui_settings.invert_view_x),
+ invert_view_y: !!(p.ui_settings && p.ui_settings.invert_view_y),
+ preview_quality: (() => {
+ const q = String(p.ui_settings?.preview_quality || "balanced");
+ return (q === "draft" || q === "balanced" || q === "high") ? q : "balanced";
+ })(),
+ },
+ active: p.active && typeof p.active === "object" ? { ...p.active } : { ...base.active },
+ };
+ if (sharedUi) {
+ merged.ui_settings = normalizeUiSettings({ ...merged.ui_settings, ...sharedUi });
+ }
+ return merged;
+ } catch {
+ parseStateJsonCache = { text: textTrimmed, parsed: null };
+ return base;
+ }
+}
+
+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 stickerCornerDirs(item) {
+ 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 { fwd, right, up } = cameraBasis(
+ Number(item.yaw_deg || 0),
+ Number(item.pitch_deg || 0),
+ Number(item.rot_deg || item.roll_deg || 0),
+ );
+ const mk = (x, y) => norm(add(add(fwd, mul(right, x * tx)), mul(up, y * ty)));
+ return [
+ mk(-1, 1),
+ mk(1, 1),
+ mk(1, -1),
+ mk(-1, -1),
+ ];
+}
+
+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;
+ 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);
+ };
+ node.__panoPreviewImageCache.set(key, { src, img });
+ return img;
+}
+
+function drawImageTriPreview(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;
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(d0.x, d0.y);
+ ctx.lineTo(d1.x, d1.y);
+ ctx.lineTo(d2.x, d2.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 drawLatLonGrid(ctx, rect, viewBasis, tanHalfY) {
+ const drawLine = (pts, color, width = 1) => {
+ let open = false;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ for (const p of pts) {
+ if (!p) {
+ open = false;
+ continue;
+ }
+ if (!open) {
+ ctx.moveTo(p.x, p.y);
+ open = true;
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ }
+ ctx.stroke();
+ };
+ const lonVals = [];
+ for (let lon = -180; lon <= 180; lon += 15) lonVals.push(lon);
+ const latVals = [];
+ for (let lat = -75; lat <= 75; lat += 15) latVals.push(lat);
+ lonVals.forEach((lonDeg) => {
+ const pts = [];
+ for (let latDeg = -85; latDeg <= 85; latDeg += 4) {
+ const lat = latDeg * DEG2RAD;
+ const lon = lonDeg * DEG2RAD;
+ const d = vec3(Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon));
+ pts.push(projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ }
+ drawLine(pts, "rgba(61, 61, 66, 0.88)", lonDeg % 90 === 0 ? 1.3 : 1);
+ });
+ latVals.forEach((latDeg) => {
+ const pts = [];
+ for (let lonDeg = -180; lonDeg <= 180; lonDeg += 4) {
+ const lat = latDeg * DEG2RAD;
+ const lon = lonDeg * DEG2RAD;
+ const d = vec3(Math.cos(lat) * Math.sin(lon), Math.sin(lat), Math.cos(lat) * Math.cos(lon));
+ pts.push(projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ }
+ drawLine(pts, latDeg === 0 ? "rgba(250, 250, 250, 0.86)" : "rgba(61, 61, 66, 0.88)", latDeg === 0 ? 1.5 : 1);
+ });
+}
+
+function drawStickerPreviewPano(ctx, node, rect, viewBasis, tanHalfY, state, item) {
+ 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 Nu = 12;
+ const Nv = 9;
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const uu = c0x + u * cw;
+ const vv = c0y + v * ch;
+ const x = (uu * 2 - 1) * tx;
+ const y = (1 - vv * 2) * ty;
+ const d = norm(add(add(basis.fwd, mul(basis.right, x)), mul(basis.up, y)));
+ verts[j][i] = projectDirToPreview(d, viewBasis, rect, tanHalfY);
+ sample[j][i] = { x: u, y: v };
+ }
+ }
+
+ const asset = state.assets?.[item.asset_id];
+ const img = getNodePreviewImage(node, item.asset_id, asset);
+ const iw = Math.max(1, Number(img?.naturalWidth || img?.width || 1));
+ const ih = Math.max(1, Number(img?.naturalHeight || img?.height || 1));
+ 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;
+ if (img && img.complete && (img.naturalWidth || 0) > 0) {
+ const s00 = { x: sample[j][i].x * iw, y: sample[j][i].y * ih };
+ const s10 = { x: sample[j][i + 1].x * iw, y: sample[j][i + 1].y * ih };
+ const s01 = { x: sample[j + 1][i].x * iw, y: sample[j + 1][i].y * ih };
+ const s11 = { x: sample[j + 1][i + 1].x * iw, y: sample[j + 1][i + 1].y * ih };
+ drawImageTriPreview(ctx, img, s00, s10, s11, p00, p10, p11);
+ drawImageTriPreview(ctx, img, s00, s11, s01, p00, p11, p01);
+ } else {
+ ctx.fillStyle = "rgba(0, 112, 243, 0.20)";
+ ctx.beginPath();
+ ctx.moveTo(p00.x, p00.y);
+ ctx.lineTo(p10.x, p10.y);
+ ctx.lineTo(p11.x, p11.y);
+ ctx.lineTo(p01.x, p01.y);
+ ctx.closePath();
+ ctx.fill();
+ }
+ }
+ }
+ const corners = stickerCornerDirs(item).map((d) => projectDirToPreview(d, viewBasis, rect, tanHalfY));
+ if (corners.every((p) => !!p)) {
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.85)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(corners[0].x, corners[0].y);
+ ctx.lineTo(corners[1].x, corners[1].y);
+ ctx.lineTo(corners[2].x, corners[2].y);
+ ctx.lineTo(corners[3].x, corners[3].y);
+ ctx.closePath();
+ ctx.stroke();
+ }
+}
+
+function drawPanoramaNodePreview(node, ctx) {
+ const stateWidget = getWidget(node, STATE_WIDGET);
+ const raw = String(stateWidget?.value || "");
+ const bg = String(getWidget(node, "bg_color")?.value || "#00ff00");
+ const state = parseState(raw, 2048, bg);
+
+ const rect = getNodePreviewRect(node);
+ if (!rect) return;
+ if (!node.__panoPreviewView) {
+ const selectedId = state.active?.selected_sticker_id || null;
+ const selected = (state.stickers || []).find((s) => s.id === selectedId) || null;
+ node.__panoPreviewView = {
+ yaw: Number(selected?.yaw_deg || 0),
+ pitch: Number(selected?.pitch_deg || 0),
+ fov: 100,
+ };
+ }
+ applyNodePreviewInertia(node);
+ const viewYaw = Number(node.__panoPreviewView.yaw || 0);
+ const viewPitch = Number(node.__panoPreviewView.pitch || 0);
+ const viewBasis = cameraBasis(viewYaw, viewPitch, 0);
+ const tanHalfY = Math.tan((Number(node.__panoPreviewView.fov || 100) * DEG2RAD) * 0.5);
+
+ ctx.save();
+ ctx.fillStyle = "#0a0a0a";
+ 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();
+
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2);
+ drawLatLonGrid(ctx, rect, viewBasis, tanHalfY);
+
+ const stickers = [...(state.stickers || [])].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ stickers.forEach((item) => drawStickerPreviewPano(ctx, node, rect, viewBasis, tanHalfY, state, item));
+ const labels = [
+ { name: "Left", dir: yawPitchToDir(-90, 0) },
+ { name: "Front", dir: yawPitchToDir(0, 0) },
+ { name: "Right", dir: yawPitchToDir(90, 0) },
+ { name: "Back", dir: yawPitchToDir(180, 0) },
+ ];
+ ctx.fillStyle = "rgba(250, 250, 250, 0.48)";
+ ctx.font = "500 10px Geist, sans-serif";
+ ctx.textAlign = "center";
+ labels.forEach((l) => {
+ const p = projectDirToPreview(l.dir, viewBasis, rect, tanHalfY);
+ if (p) ctx.fillText(l.name, p.x, p.y + 20);
+ });
+
+ const fov = Number(node.__panoPreviewView?.fov || 100);
+ ctx.textAlign = "left";
+ ctx.font = "11px Geist, sans-serif";
+ ctx.fillStyle = "rgba(250, 250, 250, 0.88)";
+ ctx.fillText(`FOV ${fov.toFixed(1)}`, rect.x + 8, rect.y + rect.h - 10);
+
+ const rb = getNodePreviewResetButtonRect(rect);
+ ctx.fillStyle = "rgba(17, 17, 17, 0.92)";
+ ctx.strokeStyle = "rgba(82, 82, 91, 0.95)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(rb.x, rb.y, rb.w, rb.h, 7);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "rgba(250, 250, 250, 0.92)";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.font = "10px Geist, sans-serif";
+ ctx.fillText("Reset", rb.x + rb.w * 0.5, rb.y + rb.h * 0.5 + 0.5);
+ ctx.textBaseline = "alphabetic";
+ ctx.restore();
+
+}
+
+function applyNodePreviewInertia(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 getNodePreviewRect(node) {
+ const pad = 8;
+ const widgetsBottom = getNodeWidgetsBottom(node);
+ const btn = getNodeEditorButtonRect(node);
+ const top = btn ? (btn.y + btn.h + 2) : (widgetsBottom + 2);
+ const x = pad;
+ const w = Math.max(120, Number(node.size?.[0] || 0) - pad * 2);
+ const h = Math.max(84, Number(node.size?.[1] || 0) - top - pad);
+ if (h < 40 || w < 80) return null;
+ return { x, y: top, w, h };
+}
+
+function getNodeWidgetsBottom(node) {
+ const widgetTop = 32;
+ const widgets = Array.isArray(node.widgets) ? node.widgets : [];
+ let y = widgetTop;
+ widgets.forEach((w) => {
+ if (!w || w.hidden || w.type === "hidden") return;
+ let h = 22;
+ try {
+ const size = typeof w.computeSize === "function" ? w.computeSize(node.size?.[0] || 0) : null;
+ if (Array.isArray(size) && Number.isFinite(Number(size[1]))) h = Number(size[1]);
+ } catch {
+ h = 22;
+ }
+ y += h;
+ });
+ return y;
+}
+
+function getNodeEditorButtonRect(node) {
+ if (!node?.__panoCustomEditorButton) return null;
+ const pad = 8;
+ const y = getNodeWidgetsBottom(node) + 2;
+ const w = Math.max(120, Number(node.size?.[0] || 0) - pad * 2);
+ return { x: pad, y, w, h: 30 };
+}
+
+function getNodeAutoHeightWithEditorButton(node) {
+ const button = getNodeEditorButtonRect(node);
+ if (!button) return Math.ceil(getNodeWidgetsBottom(node) + 40);
+ const bottomPad = 8;
+ return Math.ceil(button.y + button.h + bottomPad);
+}
+
+function drawNodeEditorButton(node, ctx) {
+ const r = getNodeEditorButtonRect(node);
+ if (!r) return;
+ const hover = !!node.__panoEditorBtnHover;
+ 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.__panoEditorButtonText || "Open Editor"), r.x + r.w * 0.5, r.y + r.h * 0.5 + 0.5);
+ ctx.restore();
+}
+
+function pointInRect(x, y, r) {
+ return !!r && x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.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 getWidget(node, name) { return node.widgets?.find((w) => w.name === name) || null; }
+function panoEditorDebug(tag, payload = null) {
+ const enabled = window?.__PANO_PREVIEW_DEBUG__ === true
+ || String(window?.localStorage?.getItem("panoPreviewDebug") || "").trim() === "1";
+ if (!enabled) return;
+ try {
+ if (payload == null) console.log(`[PANO_EDITOR][${tag}]`);
+ else console.log(`[PANO_EDITOR][${tag}]`, payload);
+ } catch { }
+}
+function escapeHtml(text) {
+ return String(text ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """)
+ .replaceAll("'", "'");
+}
+function getEditorNodeTitle(node, type) {
+ const rawType = String(node?.comfyClass || node?.type || node?.title || "").trim();
+ const titleMap = {
+ TY360Roundtrip: "TY360 Roundtrip",
+ "TY360 Roundtrip": "TY360 Roundtrip",
+ TY360Preview: "TY360 Preview",
+ "TY360 Preview": "TY360 Preview",
+ TY360Pasteback: "TY360 Pasteback",
+ "TY360 Pasteback": "TY360 Pasteback",
+ };
+ if (titleMap[rawType]) return titleMap[rawType];
+ if (rawType) return rawType;
+ if (type === "cutout") return "TY360 Roundtrip";
+ return "TY360 Preview";
+}
+function hideWidget(node, widgetName) {
+ const widgets = Array.isArray(node?.widgets) ? node.widgets : [];
+ widgets.forEach((w) => {
+ const n = String(w?.name || "");
+ if (!(n === widgetName || n.trim() === widgetName || n.toLowerCase().includes(String(widgetName).toLowerCase()))) return;
+ if (w.__panoHidden) return;
+ w.__panoHidden = true;
+ w.computeSize = () => [0, 0];
+ w.type = "hidden";
+ w.hidden = true;
+ w.options = { ...(w.options || {}), hidden: true };
+ if (w.inputEl?.style) w.inputEl.style.display = "none";
+ if (w.parentEl?.style) w.parentEl.style.display = "none";
+ });
+}
+
+function ensureActionButtonWidget(node, buttonText, callback) {
+ if (!node || typeof node.addWidget !== "function") return null;
+ const widgets = Array.isArray(node.widgets) ? node.widgets : [];
+ let widget = widgets.find((w) => String(w?.name || "") === String(buttonText));
+ if (widget) {
+ widget.callback = callback;
+ widget.hidden = false;
+ widget.__panoHidden = false;
+ widget.type = "button";
+ if (widget.inputEl?.style) widget.inputEl.style.display = "";
+ if (widget.parentEl?.style) widget.parentEl.style.display = "";
+ if (typeof widget.computeSize !== "function" || widget.computeSize() == null || widget.hidden) {
+ widget.computeSize = () => [Math.max(120, Number(node?.size?.[0] || 0) - 20), 30];
+ }
+ return widget;
+ }
+ widget = node.addWidget("button", buttonText, null, callback);
+ if (widget) {
+ widget.serialize = false;
+ }
+ return widget;
+}
+function uid(prefix) { return `${prefix}_${Math.random().toString(16).slice(2, 10)}`; }
+
+function parseOutputPresetValue(v, fallback = 2048) {
+ if (typeof v === "number" && Number.isFinite(v)) return Math.round(v);
+ const s = String(v ?? "").trim();
+ if (!s) return fallback;
+ const head = s.includes("x") ? s.split("x", 1)[0].trim() : s;
+ const n = Number(head);
+ return Number.isFinite(n) ? Math.round(n) : fallback;
+}
+
+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 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 adapterSrc = currentEditorAdapters?.loadAssetPreviewSrc?.(asset);
+ if (typeof adapterSrc === "string" && adapterSrc.trim()) return adapterSrc.trim();
+ const type = String(asset.type || "").trim().toLowerCase();
+ if (type === "dataurl") return String(asset.value || "");
+ if (type === "external_url") 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: Use strictly direct lookup.
+ // Iterating all outputs every frame causes massive lag when many nodes are present.
+ const raw = String(nodeId);
+ if (store instanceof Map) {
+ return store.get(nodeId) || store.get(raw) || store.get(Number(raw)) || null;
+ }
+ return store[nodeId] || store[raw] || 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 findLinkedInputImageSource(node, preferredInputNames = []) {
+ const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
+ if (!inputs.length) return { src: "", sourceType: "", inputName: "" };
+ const preferred = preferredInputNames
+ .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])];
+ panoEditorDebug("image-resolve.start", {
+ nodeId: node?.id ?? null,
+ preferredInputNames,
+ resolvedIndices: indices,
+ });
+
+ 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);
+ const resolvedOriginSlot = Number(originSlot || 0);
+ if (!originNode) continue;
+ panoEditorDebug("image-resolve.link", {
+ input: String(input?.name || ""),
+ linkId,
+ originId,
+ originSlot: resolvedOriginSlot,
+ originType: String(originNode?.comfyClass || originNode?.type || originNode?.title || ""),
+ });
+
+ 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) {
+ panoEditorDebug("image-resolve.hit", { sourceType: "appNodeImageUrls", src, input: String(input?.name || "") });
+ 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) {
+ panoEditorDebug("image-resolve.hit", { sourceType: "nodeOutputs", src, input: String(input?.name || "") });
+ 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) {
+ panoEditorDebug("image-resolve.hit", { sourceType: "nodeImgs", src, input: String(input?.name || "") });
+ 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) {
+ panoEditorDebug("image-resolve.hit", { sourceType: "widget", src, input: String(input?.name || "") });
+ 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) {
+ panoEditorDebug("image-resolve.hit", { sourceType: "selfOutput", src, input: "fallback" });
+ return { src, sourceType: "selfOutput", inputName: "fallback" };
+ }
+ }
+ }
+
+ panoEditorDebug("image-resolve.miss", { nodeId: node?.id ?? null, preferredInputNames });
+ return { src: "", sourceType: "", inputName: "" };
+}
+
+function getLinkedInputImage(node, preferredInputNames = [], onLoad = null) {
+ 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 { }
+ panoEditorDebug("image.load.error", {
+ nodeId: node?.id ?? null,
+ 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 = () => {
+ panoEditorDebug("image.load.ok", {
+ nodeId: node?.id ?? null,
+ src: srcRaw,
+ resolvedSrc: String(cacheEntry.resolvedSrc || img.src || ""),
+ preferredInputNames,
+ sourceType: String(resolved?.sourceType || ""),
+ attemptCount: attempt + 1,
+ });
+ onLoad?.();
+ node.setDirtyCanvas?.(true, true);
+ };
+ img.onerror = (ev) => {
+ if (attempt + 1 < candidates.length) {
+ tryLoadNext();
+ return;
+ }
+ try { node.__panoLinkedInputImageCache?.delete?.(key); } catch { }
+ panoEditorDebug("image.load.error", {
+ nodeId: node?.id ?? null,
+ src: srcRaw,
+ resolvedSrc: String(cacheEntry.resolvedSrc || ""),
+ preferredInputNames,
+ sourceType: String(resolved?.sourceType || ""),
+ attemptCount: attempt + 1,
+ errorType: String(ev?.type || "error"),
+ });
+ };
+ tryLoadNext();
+ return img;
+}
+
+let currentEditorAdapters = null;
+
+function createNodeBackedEditor(node, type, options = {}) {
+ currentEditorAdapters = options?.adapters || null;
+ const readOnly = options?.readOnly === true;
+ const hideSidebar = options?.hideSidebar ?? readOnly;
+ const hideFooter = options?.hideFooter === true;
+ const previewMode = readOnly;
+ const embedded = options?.embedded === true;
+ const mountTarget = options?.mount instanceof HTMLElement ? options.mount : null;
+ const destroyOnClose = options?.destroyOnClose === true;
+ const nodeTitle = getEditorNodeTitle(node, type);
+ const sideTitleHtml = `${ICON.globe}${escapeHtml(nodeTitle)}`;
+ installCss();
+ const presetWidget = getWidget(node, "output_preset");
+ const bgWidget = getWidget(node, "bg_color");
+ const stateWidget = getWidget(node, STATE_WIDGET);
+
+ const state = parseState(
+ String(stateWidget?.value || ""),
+ parseOutputPresetValue(presetWidget?.value, 2048),
+ String(bgWidget?.value || "#00ff00"),
+ );
+ node.__panoLiveStateOverride = JSON.stringify(state);
+ node.__panoDomPreview?.requestDraw?.();
+ node.setDirtyCanvas?.(true, true);
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+ panoEditorDebug("modal.open", {
+ source: "pano_editor_core.js",
+ nodeId: node?.id ?? null,
+ type,
+ readOnly,
+ hideSidebar,
+ inputs: Array.isArray(node?.inputs) ? node.inputs.map((i) => String(i?.name || "")) : [],
+ });
+
+ if (type === "cutout") {
+ state.shots = Array.isArray(state.shots) ? state.shots.slice(0, 1) : [];
+ if (!state.shots.length) {
+ state.active.selected_shot_id = null;
+ }
+ }
+ const bottomToolbarHtml = previewMode ? "" : `
+
+
+
+
+ ${type === "cutout" ? `` : ""}
+
+
+ `;
+ const fullscreenBtnHtml = previewMode
+ ? ``
+ : "";
+ const rootMarkup = `
+
+
+
+
Drag and drop image here
+
+
+
+
+
+
+
+ ${bottomToolbarHtml}
+
+ FOV
+ 100.0
+
+
+ ${fullscreenBtnHtml}
+
+
+
+
+
+
+ `;
+ let overlay = null;
+ let root = null;
+ if (embedded) {
+ if (mountTarget) {
+ root = document.createElement("div");
+ root.className = "pano-modal";
+ root.style.width = "100%";
+ root.style.height = String(options?.embeddedHeight || "44vh");
+ root.style.minHeight = String(options?.embeddedMinHeight || "420px");
+ root.style.maxHeight = "none";
+ root.style.maxWidth = "none";
+ root.style.boxShadow = "none";
+ root.innerHTML = rootMarkup;
+ mountTarget.replaceChildren(root);
+ } else {
+ root = document.createElement("div");
+ root.className = "pano-modal";
+ root.style.width = "100%";
+ root.style.height = String(options?.embeddedHeight || "44vh");
+ root.style.minHeight = String(options?.embeddedMinHeight || "420px");
+ root.style.maxHeight = "none";
+ root.style.maxWidth = "none";
+ root.style.boxShadow = "none";
+ root.innerHTML = rootMarkup;
+ document.body.appendChild(root);
+ }
+ } else {
+ overlay = document.createElement("div");
+ overlay.className = "pano-modal-overlay";
+ root = document.createElement("div");
+ root.className = "pano-modal";
+ root.innerHTML = rootMarkup;
+ (mountTarget || document.body).appendChild(overlay);
+ overlay.appendChild(root);
+ }
+
+ const canvas = root.querySelector("canvas");
+ const stageWrap = root.querySelector(".pano-stage-wrap");
+ const ctx = canvas.getContext("2d");
+ const side = root.querySelector("[data-side]");
+ const viewBtns = root.querySelectorAll("[data-view]");
+ const viewToggle = root.querySelector(".pano-view-toggle");
+ const fovValueEl = root.querySelector("[data-fov-value]");
+ const selectionMenu = root.querySelector("[data-selection-menu]");
+ const outputPreviewToggleBtn = root.querySelector("[data-action='toggle-output-preview-size']");
+ const lookAtFrameBtn = root.querySelector("[data-action='look-at-frame']");
+ const fullscreenBtn = root.querySelector("[data-action='toggle-fullscreen']");
+ const tooltipEl = root.querySelector("[data-tooltip]");
+ if (hideSidebar) {
+ side?.remove();
+ root.classList.add("pano-modal-readonly");
+ }
+ root.addEventListener("pointerdown", (ev) => {
+ hideTooltip();
+ if (ev.target.closest(".pano-picker")) return;
+ root.querySelectorAll(".pano-picker-pop").forEach((el) => {
+ el.hidden = true;
+ });
+ if (type === "cutout" && editor.cutoutAspectOpen && !ev.target.closest(".pano-aspect-popover") && !ev.target.closest("[data-action='aspect']")) {
+ editor.cutoutAspectOpen = false;
+ editor.menuMode = "";
+ editor.menuSize.measured = false;
+ updateSelectionMenu();
+ requestDraw();
+ }
+ });
+
+ const editor = {
+ mode: "pano",
+ selectedId: type === "stickers" ? state.active.selected_sticker_id : state.active.selected_shot_id,
+ viewYaw: 0,
+ viewPitch: 0,
+ viewFov: 100,
+ history: [],
+ historyIndex: -1,
+ interaction: null,
+ hqFrames: 0,
+ viewInertia: { vx: 0, vy: 0, active: false },
+ menuSize: { w: 220, h: 40, measured: false },
+ menuMode: "",
+ cutoutAspectOpen: false,
+ showGrid: getNodeGridVisibility(node?.id, true),
+ outputPreviewExpanded: false,
+ outputPreviewAnim: 0,
+ outputPreviewAnimFrom: 0,
+ outputPreviewAnimTo: 0,
+ outputPreviewAnimStartTs: 0,
+ outputPreviewAnimDurationMs: 180,
+ outputPreviewRect: null,
+ panelLastValues: null,
+ panelWasEnabled: false,
+ viewTween: null,
+ fullscreen: false,
+ fullscreenPrevShowGrid: null,
+ };
+ if (type === "stickers") {
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ }
+ const imageCache = new Map();
+ const runtime = {
+ dirty: true,
+ rafId: 0,
+ running: true,
+ lastTickTs: 0,
+ lastSizeCheckTs: 0,
+ };
+ const tooltip = {
+ timer: 0,
+ target: null,
+ };
+ const dragCue = {
+ active: false,
+ depth: 0,
+ };
+
+ function dragHasImageFiles(e) {
+ const dt = e?.dataTransfer;
+ if (!dt) return false;
+ if (dt.items && dt.items.length) {
+ for (const item of dt.items) {
+ if (!item || item.kind !== "file") continue;
+ const t = String(item.type || "").toLowerCase();
+ if (!t || t.startsWith("image/")) return true;
+ }
+ return false;
+ }
+ if (dt.files && dt.files.length) {
+ return Array.from(dt.files).some((f) => isImageFile(f));
+ }
+ return false;
+ }
+
+ function setDropCue(on) {
+ const next = !!on;
+ if (dragCue.active === next) return;
+ dragCue.active = next;
+ stageWrap.classList.toggle("drop-active", next);
+ }
+
+ function startViewTween(targetYaw, targetPitch, targetFov = editor.viewFov, minMs = 140, maxMs = 620) {
+ const dyaw = shortestYawDelta(editor.viewYaw, targetYaw);
+ const dpitch = targetPitch - editor.viewPitch;
+ const dfov = targetFov - editor.viewFov;
+ const dist = Math.hypot(dyaw, dpitch) + Math.abs(dfov) * 0.6;
+ const durationMs = Math.round(clamp(minMs + dist * 2.2, minMs, maxMs));
+ editor.viewTween = {
+ active: true,
+ startTs: performance.now(),
+ durationMs,
+ startYaw: editor.viewYaw,
+ startPitch: editor.viewPitch,
+ startFov: editor.viewFov,
+ targetPitch,
+ targetFov,
+ deltaYaw: dyaw,
+ };
+ editor.viewInertia.active = false;
+ editor.viewInertia.vx = 0;
+ editor.viewInertia.vy = 0;
+ requestDraw();
+ }
+
+ // Coordinate sanity: front-facing sticker should have top edge above bottom edge.
+ const __sanity = stickerCornerOrderSanity();
+ if (!__sanity) {
+ panoEditorDebug("corner-order.sanity-failed");
+ }
+
+ function getList() { return type === "stickers" ? state.stickers : state.shots; }
+ function getSelected() { return getList().find((s) => s.id === editor.selectedId) || null; }
+ function getNextStickerZIndex() {
+ const stickers = Array.isArray(state.stickers) ? state.stickers : [];
+ return stickers.reduce((acc, item) => {
+ const next = Number(item?.z_index);
+ return Math.max(acc, Number.isFinite(next) ? next : 0);
+ }, -1) + 1;
+ }
+ function isExternalSticker(item) {
+ if (!item || typeof item !== "object") return false;
+ return String(item.id || "") === EXTERNAL_STICKER_ID
+ || String(item.source_kind || "") === EXTERNAL_STICKER_SOURCE_KIND;
+ }
+ function isStickerHidden(item) {
+ return !!(item && typeof item === "object" && item.visible === false);
+ }
+ function getStickerDisplayAlpha(item) {
+ if (isExternalSticker(item) && isStickerHidden(item)) return 0.2;
+ return 1;
+ }
+ function restoreSelectedToInitialPose() {
+ if (readOnly || type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected || !isExternalSticker(selected)) return;
+ const initial = selected.initial_pose;
+ if (!initial || typeof initial !== "object") return;
+ selected.yaw_deg = Number(initial.yaw_deg ?? selected.yaw_deg ?? 0);
+ selected.pitch_deg = Number(initial.pitch_deg ?? selected.pitch_deg ?? 0);
+ selected.hFOV_deg = Number(initial.hFOV_deg ?? selected.hFOV_deg ?? 30);
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ requestDraw();
+ });
+ if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ selected.vFOV_deg = computeStickerVFov(
+ Number(initial.hFOV_deg ?? selected.hFOV_deg ?? 30),
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ } else {
+ selected.vFOV_deg = Number(initial.vFOV_deg ?? selected.vFOV_deg ?? 30);
+ }
+ selected.rot_deg = Number(initial.rot_deg ?? selected.rot_deg ?? 0);
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ }
+ function getRestorePoseForSticker(item) {
+ if (!item || !isExternalSticker(item)) return null;
+ const initial = item.initial_pose;
+ if (!initial || typeof initial !== "object") return null;
+ const pose = {
+ yaw_deg: Number(initial.yaw_deg ?? item.yaw_deg ?? 0),
+ pitch_deg: Number(initial.pitch_deg ?? item.pitch_deg ?? 0),
+ hFOV_deg: Number(initial.hFOV_deg ?? item.hFOV_deg ?? 30),
+ vFOV_deg: Number(initial.vFOV_deg ?? item.vFOV_deg ?? 30),
+ rot_deg: Number(initial.rot_deg ?? item.rot_deg ?? 0),
+ };
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ requestDraw();
+ });
+ if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ pose.vFOV_deg = computeStickerVFov(
+ pose.hFOV_deg,
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ }
+ return pose;
+ }
+ function canRestoreSelectedToInitial() {
+ const selected = getSelected();
+ if (!selected || !isExternalSticker(selected)) return false;
+ const restorePose = getRestorePoseForSticker(selected);
+ if (!restorePose) return false;
+ const close = (a, b) => Math.abs(Number(a || 0) - Number(b || 0)) <= 1e-4;
+ return !(
+ close(selected.yaw_deg, restorePose.yaw_deg)
+ && close(selected.pitch_deg, restorePose.pitch_deg)
+ && close(selected.hFOV_deg, restorePose.hFOV_deg)
+ && close(selected.vFOV_deg, restorePose.vFOV_deg)
+ && close(selected.rot_deg, restorePose.rot_deg)
+ );
+ }
+ function getNodeUiList(key) {
+ const outputs = lookupNodeOutputEntry(node?.id);
+ if (Array.isArray(outputs?.ui?.[key])) return outputs.ui[key];
+ if (Array.isArray(outputs?.[key])) return outputs[key];
+ return [];
+ }
+ function getNodeUiValue(key) {
+ const outputs = lookupNodeOutputEntry(node?.id);
+ if (outputs?.ui && Object.prototype.hasOwnProperty.call(outputs.ui, key)) return outputs.ui[key];
+ if (outputs && Object.prototype.hasOwnProperty.call(outputs, key)) return outputs[key];
+ return null;
+ }
+ function normalizeInputPoseValue(value, debugValue = null) {
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
+ if (Array.isArray(value) && value.length > 0) {
+ const first = value[0];
+ if (first && typeof first === "object" && !Array.isArray(first)) return first;
+ }
+ if (Array.isArray(debugValue) && debugValue.length > 0) {
+ const parsed = debugValue[0]?.parsed_state;
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return {
+ yaw_deg: Number(parsed.yaw_deg || 0),
+ pitch_deg: Number(parsed.pitch_deg || 0),
+ hFOV_deg: Number(parsed.hFOV_deg || 30),
+ rot_deg: Number(parsed.roll_deg || 0),
+ };
+ }
+ }
+ return null;
+ }
+ function getStickerUiImage(key, onLoad = null) {
+ const list = getNodeUiList(key);
+ const first = Array.isArray(list) && list.length ? list[0] : null;
+ const src = imageSourceFromCandidate(first);
+ if (!src) return null;
+ const cacheKey = `__ui__${key}`;
+ const cached = imageCache.get(cacheKey);
+ if (cached && cached.__panoSrc === src) return cached;
+ const img = new Image();
+ img.__panoSrc = src;
+ img.onload = () => {
+ if (typeof onLoad === "function") onLoad(img);
+ else requestDraw();
+ };
+ img.src = src;
+ imageCache.set(cacheKey, img);
+ return img;
+ }
+ function hashStringSimple(text) {
+ const s = String(text || "");
+ let h = 2166136261;
+ for (let i = 0; i < s.length; i += 1) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return String(h >>> 0);
+ }
+ function computeStickerVFov(hFovDeg, width, height) {
+ const w = Math.max(1, Number(width || 1));
+ const h = Math.max(1, Number(height || 1));
+ const hf = clamp(Number(hFovDeg || 30), 0.1, 179) * DEG2RAD;
+ const vf = 2 * Math.atan(Math.tan(hf * 0.5) * (h / w));
+ return clamp(vf * RAD2DEG, 0.1, 179);
+ }
+ function parseLinkedStickerState(raw) {
+ const text = String(raw || "").trim();
+ if (!text) return null;
+ try {
+ const parsed = JSON.parse(text);
+ if (!parsed || typeof parsed !== "object") return null;
+ if (String(parsed.kind || "") !== "pano_sticker_state") return null;
+ const versionValue = parsed.version;
+ let version = null;
+ if (typeof versionValue === "number" && Number.isInteger(versionValue)) {
+ version = versionValue;
+ } else if (typeof versionValue === "string" && /^\d+$/.test(versionValue)) {
+ version = Number.parseInt(versionValue, 10);
+ }
+ if (version !== 1) return null;
+ const pose = parsed.pose;
+ if (!pose || typeof pose !== "object") return null;
+ const yawRaw = Number(pose.yaw_deg);
+ const pitchRaw = Number(pose.pitch_deg);
+ const rollRaw = Number(pose.roll_deg);
+ const hRaw = Number(pose.hFOV_deg);
+ if (![yawRaw, pitchRaw, rollRaw, hRaw].every((v) => Number.isFinite(v))) return null;
+ let yaw = ((yawRaw + 180) % 360 + 360) % 360 - 180;
+ if (Object.is(yaw, -0)) yaw = 0;
+ const out = {
+ yaw_deg: yaw,
+ pitch_deg: clamp(pitchRaw, -89.9, 89.9),
+ roll_deg: rollRaw,
+ hFOV_deg: clamp(hRaw, 0.1, 179),
+ };
+ const sourceAspect = Number(parsed.source_aspect);
+ if (Number.isFinite(sourceAspect) && sourceAspect > 0) out.source_aspect = sourceAspect;
+ return out;
+ } catch {
+ return null;
+ }
+ }
+ function buildCanonicalCutoutStickerState(item) {
+ const widthRaw = Number(item?.out_w);
+ const heightRaw = Number(item?.out_h);
+ const width = Math.max(1, Number.isFinite(widthRaw) ? widthRaw : 1024);
+ const height = Math.max(1, Number.isFinite(heightRaw) ? heightRaw : 1024);
+ const yawRaw = Number(item?.yaw_deg);
+ const pitchRaw = Number(item?.pitch_deg);
+ const rollRaw = Number(item?.roll_deg ?? item?.rot_deg);
+ const hFovRaw = Number(item?.hFOV_deg);
+ return {
+ kind: "pano_sticker_state",
+ version: 1,
+ pose: {
+ yaw_deg: wrapYaw(Number.isFinite(yawRaw) ? yawRaw : 0),
+ pitch_deg: clamp(Number.isFinite(pitchRaw) ? pitchRaw : 0, -89.9, 89.9),
+ roll_deg: Number.isFinite(rollRaw) ? rollRaw : 0,
+ hFOV_deg: clamp(Number.isFinite(hFovRaw) ? hFovRaw : 90, 0.1, 179),
+ },
+ source_aspect: width / height,
+ };
+ }
+ function buildCanonicalSelectedStickerState(item) {
+ if (!item || typeof item !== "object") return buildCanonicalCutoutStickerState(null);
+ const yawRaw = Number(item?.yaw_deg);
+ const pitchRaw = Number(item?.pitch_deg);
+ const rollRaw = Number(item?.roll_deg ?? item?.rot_deg);
+ const hFovRaw = Number(item?.hFOV_deg);
+ const vFovRaw = Number(item?.vFOV_deg);
+ let sourceAspect = 1;
+ if (Number.isFinite(hFovRaw) && Number.isFinite(vFovRaw)) {
+ const hf = clamp(hFovRaw, 0.1, 179) * DEG2RAD;
+ const vf = clamp(vFovRaw, 0.1, 179) * DEG2RAD;
+ const tanV = Math.tan(vf * 0.5);
+ if (Math.abs(tanV) > 1e-6) {
+ const ratio = Math.tan(hf * 0.5) / tanV;
+ if (Number.isFinite(ratio) && ratio > 0) sourceAspect = ratio;
+ }
+ }
+ if (item?.asset_id && state?.assets?.[item.asset_id]) {
+ const asset = state.assets[item.asset_id];
+ const width = Number(asset?.w || 0);
+ const height = Number(asset?.h || 0);
+ if (width > 0 && height > 0) sourceAspect = width / height;
+ }
+ return {
+ kind: "pano_sticker_state",
+ version: 1,
+ pose: {
+ yaw_deg: wrapYaw(Number.isFinite(yawRaw) ? yawRaw : 0),
+ pitch_deg: clamp(Number.isFinite(pitchRaw) ? pitchRaw : 0, -89.9, 89.9),
+ roll_deg: Number.isFinite(rollRaw) ? rollRaw : 0,
+ hFOV_deg: clamp(Number.isFinite(hFovRaw) ? hFovRaw : 30, 0.1, 179),
+ },
+ source_aspect: sourceAspect,
+ };
+ }
+ function getLinkedStringInputValue(inputName) {
+ const input = Array.isArray(node?.inputs)
+ ? node.inputs.find((entry) => String(entry?.name || "") === String(inputName))
+ : null;
+ const linkId = input?.link;
+ if (linkId != null) {
+ const linkInfo = getGraphLinkById(node.graph, linkId);
+ const { originId, originSlot } = resolveOriginFromLinkInfo(linkInfo);
+ const outputs = lookupNodeOutputEntry(originId);
+ const groups = [
+ outputs?.output,
+ outputs?.result,
+ outputs?.data?.output,
+ outputs?.data?.result,
+ outputs?.ui?.output,
+ outputs?.ui?.result,
+ ];
+ for (const group of groups) {
+ if (!Array.isArray(group)) continue;
+ const idx = Number(originSlot || 0);
+ const val = group[idx];
+ if (typeof val === "string" && val.trim()) return val;
+ }
+ }
+ return String(getWidget(node, inputName)?.value || "");
+ }
+ function buildExternalInitialPose(inputPose, stateRaw, previewImg) {
+ const parsed = (inputPose && typeof inputPose === "object")
+ ? {
+ yaw_deg: Number(inputPose.yaw_deg || 0),
+ pitch_deg: Number(inputPose.pitch_deg || 0),
+ roll_deg: Number(inputPose.rot_deg ?? inputPose.roll_deg ?? 0),
+ hFOV_deg: Number(inputPose.hFOV_deg || 30),
+ }
+ : parseLinkedStickerState(stateRaw);
+ if (parsed) {
+ const width = Number(previewImg?.naturalWidth || previewImg?.width || parsed.source_aspect || 1);
+ const height = Number(previewImg?.naturalHeight || previewImg?.height || 1);
+ return {
+ yaw_deg: Number(parsed.yaw_deg || 0),
+ pitch_deg: Number(parsed.pitch_deg || 0),
+ hFOV_deg: Number(parsed.hFOV_deg || 30),
+ vFOV_deg: computeStickerVFov(parsed.hFOV_deg, width, height),
+ rot_deg: Number(parsed.roll_deg || 0),
+ };
+ }
+ const width = Number(previewImg?.naturalWidth || previewImg?.width || 1);
+ const height = Number(previewImg?.naturalHeight || previewImg?.height || 1);
+ return {
+ yaw_deg: Number(editor.viewYaw || 0),
+ pitch_deg: Number(editor.viewPitch || 0),
+ hFOV_deg: 30,
+ vFOV_deg: computeStickerVFov(30, width, height),
+ rot_deg: 0,
+ };
+ }
+ function reconcileExternalStickerFromInputs(reason = "sync") {
+ if (type !== "stickers" || readOnly) return;
+ const input = Array.isArray(node?.inputs)
+ ? node.inputs.find((entry) => String(entry?.name || "") === "sticker_image")
+ : null;
+ const linkId = input?.link ?? null;
+ const previewImg = getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ node.__panoExternalStickerSync?.("image-loaded");
+ });
+ const inputPose = normalizeInputPoseValue(getNodeUiValue("pano_sticker_input_pose"), null);
+ const stateRaw = getLinkedStringInputValue("sticker_state");
+ const stateHash = inputPose && typeof inputPose === "object"
+ ? hashStringSimple(JSON.stringify(inputPose))
+ : hashStringSimple(stateRaw);
+ const stickers = Array.isArray(state.stickers) ? state.stickers : (state.stickers = []);
+ const existingIndex = stickers.findIndex((item) => String(item?.id || "") === EXTERNAL_STICKER_ID);
+ if (linkId == null) {
+ if (existingIndex >= 0) {
+ stickers.splice(existingIndex, 1);
+ if (editor.selectedId === EXTERNAL_STICKER_ID) {
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ }
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ }
+ return;
+ }
+ const maxZ = stickers.reduce((acc, item) => Math.max(acc, Number(item?.z_index || 0)), -1);
+ let target = existingIndex >= 0 ? stickers[existingIndex] : null;
+ const sourceChanged = !target
+ || Number(target.source_link_id ?? -1) !== Number(linkId)
+ || String(target.source_state_hash || "") !== stateHash;
+ if (!target) {
+ target = {
+ id: EXTERNAL_STICKER_ID,
+ source_kind: EXTERNAL_STICKER_SOURCE_KIND,
+ };
+ stickers.push(target);
+ }
+ target.id = EXTERNAL_STICKER_ID;
+ target.source_kind = EXTERNAL_STICKER_SOURCE_KIND;
+ target.source_link_id = Number(linkId);
+ target.source_state_hash = stateHash;
+ target.visible = target.visible !== false;
+ let stateChanged = false;
+ if (sourceChanged) {
+ const pose = buildExternalInitialPose(inputPose, stateRaw, previewImg);
+ Object.assign(target, pose, {
+ initial_pose: { ...pose },
+ visible: true,
+ z_index: maxZ + 1,
+ });
+ stateChanged = true;
+ } else if (previewImg && (previewImg.complete || previewImg.naturalWidth || previewImg.width)) {
+ const nextVFov = computeStickerVFov(
+ Number(target.hFOV_deg || 30),
+ Number(previewImg.naturalWidth || previewImg.width || 1),
+ Number(previewImg.naturalHeight || previewImg.height || 1),
+ );
+ if (Math.abs(Number(target.vFOV_deg || 0) - nextVFov) > 1e-6) {
+ target.vFOV_deg = nextVFov;
+ stateChanged = true;
+ }
+ }
+ if (stateChanged) {
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ }
+ requestDraw();
+ void reason;
+ }
+ function applyInitialCutoutFocus() {
+ if (type !== "cutout") return;
+ const shots = getList();
+ if (!Array.isArray(shots) || shots.length === 0) return;
+ const preferredId = String(state.active?.selected_shot_id || editor.selectedId || "");
+ const target = shots.find((s) => String(s?.id || "") === preferredId) || shots[0];
+ if (!target) return;
+ editor.selectedId = target.id || null;
+ state.active.selected_shot_id = editor.selectedId;
+ editor.viewYaw = wrapYaw(Number(target.yaw_deg || 0));
+ editor.viewPitch = clamp(Number(target.pitch_deg || 0), -89.9, 89.9);
+ }
+ function syncLookAtFrameButtonState() {
+ if (!lookAtFrameBtn) return;
+ const enabled = type === "cutout" && getList().length > 0;
+ lookAtFrameBtn.disabled = !enabled;
+ lookAtFrameBtn.setAttribute("aria-disabled", enabled ? "false" : "true");
+ }
+
+ function stickerCornerOrderSanity() {
+ const test = {
+ yaw_deg: 0, pitch_deg: 0, hFOV_deg: 20, vFOV_deg: 20, rot_deg: 0,
+ };
+ const dirs = stickerCornersDir(test);
+ if (!dirs || dirs.length !== 4) return false;
+ // top-left y should be >= bottom-left y in world-up axis
+ return dirs[0].y >= dirs[3].y;
+ }
+
+ function cameraBasis() {
+ const fwd = yawPitchToDir(editor.viewYaw, editor.viewPitch);
+ let upWorld = vec3(0, 1, 0);
+ if (Math.abs(dot(fwd, upWorld)) > 0.999) upWorld = vec3(0, 0, 1);
+ const right = norm(cross(upWorld, fwd));
+ const up = norm(cross(fwd, right));
+ return { right, up, fwd };
+ }
+
+ function projectDir(dir) {
+ const { right, up, fwd } = cameraBasis();
+ const cx = dot(dir, right);
+ const cy = dot(dir, up);
+ const cz = dot(dir, fwd);
+ if (cz <= 1e-5) return null;
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / w));
+ const sx = (w / 2) / Math.tan(hfov / 2);
+ const sy = (h / 2) / Math.tan(vfov / 2);
+ return {
+ x: w / 2 + (cx / cz) * sx,
+ y: h / 2 - (cy / cz) * sy,
+ z: cz,
+ };
+ }
+
+ function screenToWorldDir(x, y) {
+ const { right, up, fwd } = cameraBasis();
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / w));
+ const nx = ((x - w / 2) / (w / 2)) * Math.tan(hfov / 2);
+ const ny = ((h / 2 - y) / (h / 2)) * Math.tan(vfov / 2);
+ const world = add(add(mul(right, nx), mul(up, ny)), fwd);
+ return norm(world);
+ }
+
+ function getUnwrapRect() {
+ const w = canvas.width;
+ const h = canvas.height;
+ const targetAR = 2.0; // ERP 2:1
+ const canvasAR = w / Math.max(h, 1);
+ if (canvasAR >= targetAR) {
+ const rh = h;
+ const rw = rh * targetAR;
+ const rx = (w - rw) * 0.5;
+ return { x: rx, y: 0, w: rw, h: rh };
+ }
+ const rw = w;
+ const rh = rw / targetAR;
+ const ry = (h - rh) * 0.5;
+ return { x: 0, y: ry, w: rw, h: rh };
+ }
+
+ function getStickerImage(stickerOrAssetId) {
+ if (stickerOrAssetId && typeof stickerOrAssetId === "object" && isExternalSticker(stickerOrAssetId)) {
+ return getStickerUiImage(EXTERNAL_STICKER_PREVIEW_KEY, () => {
+ node.__panoExternalStickerSync?.("image-loaded");
+ });
+ }
+ const assetId = (stickerOrAssetId && typeof stickerOrAssetId === "object")
+ ? String(stickerOrAssetId.asset_id || "")
+ : String(stickerOrAssetId || "");
+ if (!assetId) return null;
+ const cached = imageCache.get(assetId);
+ if (cached) return cached;
+ const asset = state.assets?.[assetId];
+ const src = stickerAssetToPreviewSrc(asset);
+ if (!src) return null;
+ const img = new Image();
+ img.onload = () => requestDraw();
+ img.src = src;
+ imageCache.set(assetId, img);
+ return img;
+ }
+
+ async function uploadStickerAssetFile(file, fallbackName = "sticker.png") {
+ const customSave = options?.adapters?.saveAssetFile;
+ if (typeof customSave === "function") {
+ const customAsset = await customSave(file, { type, node, fallbackName });
+ if (customAsset && typeof customAsset === "object") {
+ return customAsset;
+ }
+ }
+ const dataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ""));
+ reader.onerror = () => reject(new Error("file read failed"));
+ reader.readAsDataURL(file);
+ });
+ return {
+ type: "dataurl",
+ value: dataUrl,
+ name: String(file?.name || fallbackName),
+ };
+ }
+
+ async function uploadStickerAssetFileViaComfy(file, fallbackName = "sticker.png") {
+ const body = new FormData();
+ body.append("image", file);
+ body.append("type", "input");
+ body.append("subfolder", "ty360_photo_edit");
+ const resp = await api.fetchApi("/upload/image", { method: "POST", body });
+ if (!resp || resp.status !== 200) {
+ throw new Error(`upload failed (${resp?.status || "no-response"})`);
+ }
+ const data = await resp.json();
+ const filename = String(data?.name || "").trim();
+ if (!filename) {
+ throw new Error("upload response missing filename");
+ }
+ return {
+ type: "comfy_image",
+ filename,
+ subfolder: String(data?.subfolder || "ty360_photo_edit"),
+ storage: String(data?.type || "input"),
+ name: String(file?.name || fallbackName),
+ };
+ }
+
+ function getConnectedErpImage() {
+ if (typeof options?.erpImageResolver === "function") {
+ const resolved = options.erpImageResolver();
+ if (resolved instanceof HTMLImageElement) {
+ if (!resolved.complete) {
+ resolved.onload = () => requestDraw();
+ }
+ return resolved;
+ }
+ const src = String(resolved || "").trim();
+ if (src) {
+ const img = new Image();
+ img.onload = () => requestDraw();
+ img.src = src;
+ return img;
+ }
+ }
+ const inputNames = Array.isArray(node?.inputs)
+ ? node.inputs.map((i) => String(i?.name || ""))
+ : [];
+ const hasEpr = inputNames.includes("erp_image");
+ const hasBg = inputNames.includes("bg_erp");
+ let preferred = [];
+ if (readOnly && (hasEpr || hasBg)) {
+ preferred = hasEpr ? ["erp_image", "bg_erp"] : ["bg_erp", "erp_image"];
+ } else {
+ preferred = type === "stickers" ? ["bg_erp", "erp_image"] : ["erp_image", "bg_erp"];
+ }
+ panoEditorDebug("erp-select", {
+ nodeId: node?.id ?? null,
+ type,
+ readOnly,
+ inputNames,
+ preferred,
+ });
+ return getLinkedInputImage(node, preferred, () => requestDraw());
+ }
+
+ function getWrappedErpCanvas(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 drawErpBackgroundUnwrap(rect) {
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) return;
+ ctx.save();
+ ctx.globalAlpha = 0.94;
+ ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h);
+ ctx.restore();
+ }
+
+ function drawErpBackgroundPano() {
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) 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(img);
+ if (!wrapped) return;
+ const q = String(state.ui_settings?.preview_quality || "balanced");
+ const cacheKey = [
+ String(img.src || ""),
+ iw,
+ ih,
+ canvas.width,
+ canvas.height,
+ q,
+ Number(editor.viewYaw || 0).toFixed(4),
+ Number(editor.viewPitch || 0).toFixed(4),
+ Number(editor.viewFov || 100).toFixed(4),
+ ].join("|");
+ const cached = node.__panoPanoBackgroundCache;
+ if (cached?.canvas && cached.key === cacheKey) {
+ ctx.drawImage(cached.canvas, 0, 0);
+ return;
+ }
+ const bgCanvas = document.createElement("canvas");
+ bgCanvas.width = canvas.width;
+ bgCanvas.height = canvas.height;
+ const bgCtx = bgCanvas.getContext("2d");
+ if (!bgCtx) return;
+ const Nu = q === "high" ? 44 : (q === "draft" ? 24 : 32);
+ const Nv = q === "high" ? 28 : (q === "draft" ? 14 : 20);
+ const verts = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+ const sample = Array.from({ length: Nv + 1 }, () => Array(Nu + 1).fill(null));
+
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const x = (canvas.width * i) / Nu;
+ const y = (canvas.height * j) / Nv;
+ const d = screenToWorldDir(x, y);
+ const ll = dirToLonLat(d);
+ let u = (ll.lon / (2 * Math.PI) + 0.5) * iw;
+ while (u < 0) u += iw;
+ while (u >= iw) u -= iw;
+ const v = (0.5 - ll.lat / Math.PI) * ih;
+ verts[j][i] = { x, y };
+ sample[j][i] = { x: u, y: v };
+ }
+ }
+
+ bgCtx.save();
+ bgCtx.globalAlpha = 0.94;
+ 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;
+ });
+ }
+ drawImageTriTo(bgCtx, wrapped, s00, s10, s11, p00, p10, p11);
+ drawImageTriTo(bgCtx, wrapped, s00, s11, s01, p00, p11, p01);
+ }
+ }
+ bgCtx.restore();
+ node.__panoPanoBackgroundCache = { key: cacheKey, canvas: bgCanvas };
+ ctx.drawImage(bgCanvas, 0, 0);
+ }
+
+ function pruneUnusedAssets() {
+ if (type !== "stickers") return;
+ const used = new Set(
+ (state.stickers || [])
+ .map((s) => String(s?.asset_id || ""))
+ .filter((id) => !!id),
+ );
+ Object.keys(state.assets || {}).forEach((id) => {
+ if (!used.has(id)) {
+ delete state.assets[id];
+ imageCache.delete(id);
+ }
+ });
+ }
+
+ function dirToLonLat(d) {
+ return {
+ lon: Math.atan2(d.x, d.z),
+ lat: Math.asin(clamp(d.y, -1, 1)),
+ };
+ }
+
+ function projectDirUnwrap(d, refX = null) {
+ const { lon, lat } = dirToLonLat(d);
+ const r = getUnwrapRect();
+ let x = r.x + ((lon / (2 * Math.PI)) + 0.5) * r.w;
+ const y = r.y + (0.5 - (lat / Math.PI)) * r.h;
+ if (refX !== null) {
+ while (x - refX > r.w / 2) x -= r.w;
+ while (x - refX < -r.w / 2) x += r.w;
+ }
+ return { x, y, z: 1 };
+ }
+
+ function getStickerFrame(item) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ let upWorld = vec3(0, 1, 0);
+ if (Math.abs(dot(centerDir, upWorld)) > 0.999) upWorld = vec3(0, 0, 1);
+ const right = norm(cross(upWorld, centerDir));
+ const up = norm(cross(centerDir, right));
+
+ const tanX = Math.tan(clamp(Number(item.hFOV_deg || 20), 0.1, 179) * 0.5 * DEG2RAD);
+ const tanY = Math.tan(clamp(Number(item.vFOV_deg || 20), 0.1, 179) * 0.5 * DEG2RAD);
+ const rot = Number(item.rot_deg || item.roll_deg || 0) * DEG2RAD;
+ const cr = Math.cos(rot);
+ const sr = Math.sin(rot);
+ return {
+ centerDir,
+ right,
+ up,
+ tanX,
+ tanY,
+ cr,
+ sr,
+ };
+ }
+
+ function stickerDirFromFrame(frame, x, y) {
+ const xr = x * frame.cr - y * frame.sr;
+ const yr = x * frame.sr + y * frame.cr;
+ return norm(add(add(frame.centerDir, mul(frame.right, xr)), mul(frame.up, yr)));
+ }
+
+ function stickerCornersDir(item) {
+ const frame = getStickerFrame(item);
+
+ // Corner order is fixed to: top-left, top-right, bottom-right, bottom-left
+ const cornersLocal = [
+ { u: -1, v: 1 },
+ { u: 1, v: 1 },
+ { u: 1, v: -1 },
+ { u: -1, v: -1 },
+ ];
+
+ return cornersLocal.map(({ u, v }) => {
+ return stickerDirFromFrame(frame, u * frame.tanX, v * frame.tanY);
+ });
+ }
+
+ function stickerSampleDir(item, u, v) {
+ const frame = getStickerFrame(item);
+
+ const x = (u * 2 - 1) * frame.tanX;
+ const y = (1 - v * 2) * frame.tanY;
+ return stickerDirFromFrame(frame, x, y);
+ }
+
+ function drawImageTriTo(targetCtx, img, s0, s1, s2, d0, d1, d2) {
+ const denom = (s0.x * (s1.y - s2.y)) + (s1.x * (s2.y - s0.y)) + (s2.x * (s0.y - s1.y));
+ if (Math.abs(denom) < 1e-6) return;
+
+ const a = ((d0.x * (s1.y - s2.y)) + (d1.x * (s2.y - s0.y)) + (d2.x * (s0.y - s1.y))) / denom;
+ const b = ((d0.x * (s2.x - s1.x)) + (d1.x * (s0.x - s2.x)) + (d2.x * (s1.x - s0.x))) / denom;
+ const c = ((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))) / denom;
+ const d = ((d0.y * (s1.y - s2.y)) + (d1.y * (s2.y - s0.y)) + (d2.y * (s0.y - s1.y))) / denom;
+ const e = ((d0.y * (s2.x - s1.x)) + (d1.y * (s0.x - s2.x)) + (d2.y * (s1.x - s0.x))) / denom;
+ const f = ((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))) / denom;
+
+ const [e0, e1, e2] = expandTri(d0, d1, d2, 0.45);
+ targetCtx.save();
+ targetCtx.beginPath();
+ targetCtx.moveTo(e0.x, e0.y);
+ targetCtx.lineTo(e1.x, e1.y);
+ targetCtx.lineTo(e2.x, e2.y);
+ targetCtx.closePath();
+ targetCtx.clip();
+ targetCtx.setTransform(a, d, b, e, c, f);
+ targetCtx.drawImage(img, 0, 0);
+ targetCtx.restore();
+ }
+
+ function drawImageTri(img, s0, s1, s2, d0, d1, d2) {
+ drawImageTriTo(ctx, img, s0, s1, s2, d0, d1, d2);
+ }
+
+ function getMeshDivisions() {
+ const q = String(state.ui_settings?.preview_quality || "balanced");
+ if (q === "draft") {
+ if (editor.hqFrames && editor.hqFrames > 0) return [28, 20];
+ if (editor.interaction) return [12, 9];
+ return [20, 14];
+ }
+ if (q === "high") {
+ if (editor.hqFrames && editor.hqFrames > 0) return [48, 36];
+ if (editor.interaction) return [20, 14];
+ return [36, 26];
+ }
+ if (editor.hqFrames && editor.hqFrames > 0) return [40, 30];
+ if (editor.interaction) return [16, 12];
+ return [28, 20];
+ }
+
+ function drawGridUnwrap() {
+ const w = canvas.width;
+ const h = canvas.height;
+ const r = getUnwrapRect();
+ ctx.globalAlpha = 1;
+ ctx.lineWidth = 1;
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(0, 0, w, h);
+
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(r.x, r.y, r.w, r.h);
+ drawErpBackgroundUnwrap(r);
+
+ if (editor.showGrid && !editor.fullscreen) {
+ ctx.strokeStyle = "#3f3f46";
+ for (let i = 0; i <= 16; i += 1) {
+ const x = r.x + (r.w * i) / 16;
+ ctx.beginPath(); ctx.moveTo(x, r.y); ctx.lineTo(x, r.y + r.h); ctx.stroke();
+ }
+ for (let i = 0; i <= 8; i += 1) {
+ const y = r.y + (r.h * i) / 8;
+ ctx.beginPath(); ctx.moveTo(r.x, y); ctx.lineTo(r.x + r.w, y); ctx.stroke();
+ }
+
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.86)";
+ ctx.lineWidth = 1.2;
+ ctx.beginPath(); ctx.moveTo(r.x, r.y + r.h / 2); ctx.lineTo(r.x + r.w, r.y + r.h / 2); ctx.stroke();
+
+ ctx.fillStyle = "rgba(250, 250, 250, 0.42)";
+ ctx.font = "500 11px Geist, sans-serif";
+ ctx.textAlign = "center";
+ const ly = r.y + r.h * 0.57;
+ ctx.fillText("Left", r.x + r.w * 0.25, ly);
+ ctx.fillText("Front", r.x + r.w * 0.50, ly);
+ ctx.fillText("Right", r.x + r.w * 0.75, ly);
+ ctx.fillText("Back", r.x + 38, ly);
+ ctx.fillText("Back", r.x + r.w - 38, ly);
+ }
+ }
+
+ function drawLineOnSphere(pointsDir, color, width = 1) {
+ let started = false;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = width;
+ ctx.beginPath();
+ for (const d of pointsDir) {
+ const p = projectDir(d);
+ 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 drawGridPano() {
+ const w = canvas.width;
+ const h = canvas.height;
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(0, 0, w, h);
+ drawErpBackgroundPano();
+
+ if (editor.showGrid && !editor.fullscreen) {
+ for (let lon = -180; lon <= 180; lon += 15) {
+ const pts = [];
+ for (let lat = -89; lat <= 89; lat += 4) pts.push(yawPitchToDir(lon, lat));
+ drawLineOnSphere(pts, "#3f3f46", lon % 90 === 0 ? 1.3 : 1);
+ }
+ for (let lat = -75; lat <= 75; lat += 15) {
+ const pts = [];
+ for (let lon = -180; lon <= 180; lon += 4) pts.push(yawPitchToDir(lon, lat));
+ drawLineOnSphere(pts, lat === 0 ? "rgba(250, 250, 250, 0.86)" : "#3f3f46", lat === 0 ? 1.5 : 1);
+ }
+
+ const labels = [
+ { name: "Left", dir: yawPitchToDir(-90, 0) },
+ { name: "Front", dir: yawPitchToDir(0, 0) },
+ { name: "Right", dir: yawPitchToDir(90, 0) },
+ { name: "Back", dir: yawPitchToDir(180, 0) },
+ ];
+ ctx.fillStyle = "rgba(250, 250, 250, 0.42)";
+ ctx.font = "500 11px Geist, sans-serif";
+ ctx.textAlign = "center";
+ labels.forEach((l) => {
+ const p = projectDir(l.dir);
+ if (p) ctx.fillText(l.name, p.x, p.y + 24);
+ });
+ }
+ }
+
+ function objectGeom(item) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const center = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : projectDir(centerDir);
+ if (!center) return { visible: false };
+ const projectDirForShape = (d, refX = null) => {
+ if (editor.mode === "unwrap") return projectDirUnwrap(d, refX);
+ const { right, up, fwd } = cameraBasis();
+ const cx = dot(d, right);
+ const cy = dot(d, up);
+ const cz = dot(d, fwd);
+ const w = canvas.width;
+ const h = canvas.height;
+ const hfov = editor.viewFov * DEG2RAD;
+ const vfov = 2 * Math.atan(Math.tan(hfov / 2) * (h / Math.max(w, 1)));
+ const sx = (w / 2) / Math.tan(hfov / 2);
+ const sy = (h / 2) / Math.tan(vfov / 2);
+ const z = Math.max(cz, 1e-4);
+ const guard = Math.max(w, h) * 2.0;
+ return {
+ x: clamp(w / 2 + (cx / z) * sx, -guard, w + guard),
+ y: clamp(h / 2 - (cy / z) * sy, -guard, h + guard),
+ z,
+ };
+ };
+ const frame = getStickerFrame(item);
+ const cornersDir = stickerCornersDir(item);
+ const corners = cornersDir.map((d) => projectDirForShape(d, center.x));
+ const rotateStemBaseDir = stickerDirFromFrame(frame, 0, frame.tanY);
+ const rotateHandleDir = stickerDirFromFrame(frame, 0, frame.tanY + Math.max(frame.tanY * 0.43, 0.053));
+ const rotateStemBase = projectDirForShape(rotateStemBaseDir, center.x);
+ const rotateHandleHint = projectDirForShape(rotateHandleDir, rotateStemBase?.x ?? center.x);
+ const handleDx = (rotateHandleHint?.x ?? rotateStemBase.x) - rotateStemBase.x;
+ const handleDy = (rotateHandleHint?.y ?? rotateStemBase.y) - rotateStemBase.y;
+ const handleLen = Math.hypot(handleDx, handleDy) || 1;
+ const rotateHandle = {
+ x: rotateStemBase.x + (handleDx / handleLen) * 30,
+ y: rotateStemBase.y + (handleDy / handleLen) * 30,
+ };
+ const topEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, 0, frame.tanY), center.x);
+ const rightEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, frame.tanX, 0), center.x);
+ const bottomEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, 0, -frame.tanY), center.x);
+ const leftEdgeCenter = projectDirForShape(stickerDirFromFrame(frame, -frame.tanX, 0), center.x);
+ const edgeMidpoints = [
+ {
+ edge: "top",
+ x: topEdgeCenter.x,
+ y: topEdgeCenter.y,
+ a: { x: corners[0].x, y: corners[0].y },
+ b: { x: corners[1].x, y: corners[1].y },
+ },
+ {
+ edge: "right",
+ x: rightEdgeCenter.x,
+ y: rightEdgeCenter.y,
+ a: { x: corners[1].x, y: corners[1].y },
+ b: { x: corners[2].x, y: corners[2].y },
+ },
+ {
+ edge: "bottom",
+ x: bottomEdgeCenter.x,
+ y: bottomEdgeCenter.y,
+ a: { x: corners[2].x, y: corners[2].y },
+ b: { x: corners[3].x, y: corners[3].y },
+ },
+ {
+ edge: "left",
+ x: leftEdgeCenter.x,
+ y: leftEdgeCenter.y,
+ a: { x: corners[3].x, y: corners[3].y },
+ b: { x: corners[0].x, y: corners[0].y },
+ },
+ ];
+ return {
+ center: { x: center.x, y: center.y },
+ corners: corners.map((c) => ({ x: c.x, y: c.y })),
+ edgeMidpoints,
+ rotateStemBase: { x: rotateStemBase.x, y: rotateStemBase.y },
+ rotateHandle,
+ topEdge: { a: 0, b: 1 },
+ visible: true,
+ };
+ }
+
+ function drawStickerMeshMapped(item, img, dstRect, srcRect, alpha = 1) {
+ const dx0 = clamp(Math.min(Number(dstRect.x0 ?? 0), Number(dstRect.x1 ?? 1)), 0, 1);
+ const dy0 = clamp(Math.min(Number(dstRect.y0 ?? 0), Number(dstRect.y1 ?? 1)), 0, 1);
+ const dx1 = clamp(Math.max(Number(dstRect.x0 ?? 0), Number(dstRect.x1 ?? 1)), 0, 1);
+ const dy1 = clamp(Math.max(Number(dstRect.y0 ?? 0), Number(dstRect.y1 ?? 1)), 0, 1);
+ const sx0 = clamp(Math.min(Number(srcRect.x0 ?? 0), Number(srcRect.x1 ?? 1)), 0, 1);
+ const sy0 = clamp(Math.min(Number(srcRect.y0 ?? 0), Number(srcRect.y1 ?? 1)), 0, 1);
+ const sx1 = clamp(Math.max(Number(srcRect.x0 ?? 0), Number(srcRect.x1 ?? 1)), 0, 1);
+ const sy1 = clamp(Math.max(Number(srcRect.y0 ?? 0), Number(srcRect.y1 ?? 1)), 0, 1);
+
+ const iw = img.naturalWidth || img.width;
+ const ih = img.naturalHeight || img.height;
+ const [Nu, Nv] = getMeshDivisions();
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const centerProj = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : null;
+
+ const verts = [];
+ for (let j = 0; j <= Nv; j += 1) {
+ for (let i = 0; i <= Nu; i += 1) {
+ const u = i / Nu;
+ const v = j / Nv;
+ const wu = dx0 + (dx1 - dx0) * u;
+ const wv = dy0 + (dy1 - dy0) * v;
+ const su = (sx0 + (sx1 - sx0) * u) * iw;
+ const sv = (sy0 + (sy1 - sy0) * v) * ih;
+ const d = stickerSampleDir(item, wu, wv);
+ const p = editor.mode === "unwrap" ? projectDirUnwrap(d, centerProj?.x ?? null) : projectDir(d);
+ verts.push({ p, s: { x: su, y: sv } });
+ }
+ }
+
+ const W = canvas.width;
+ let drawnTriangles = 0;
+ for (let j = 0; j < Nv; j += 1) {
+ for (let i = 0; i < Nu; i += 1) {
+ const idx = (jj, ii) => jj * (Nu + 1) + ii;
+ const v00 = verts[idx(j, i)];
+ const v10 = verts[idx(j, i + 1)];
+ const v11 = verts[idx(j + 1, i + 1)];
+ const v01 = verts[idx(j + 1, i)];
+ if (!v00.p || !v10.p || !v11.p || !v01.p) continue;
+
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ drawImageTri(img, v00.s, v10.s, v11.s, v00.p, v10.p, v11.p);
+ drawImageTri(img, v00.s, v11.s, v01.s, v00.p, v11.p, v01.p);
+ ctx.globalAlpha = prevAlpha;
+ drawnTriangles += 2;
+
+ if (editor.mode === "unwrap") {
+ const p00p = { x: v00.p.x + W, y: v00.p.y };
+ const p10p = { x: v10.p.x + W, y: v10.p.y };
+ const p11p = { x: v11.p.x + W, y: v11.p.y };
+ const p01p = { x: v01.p.x + W, y: v01.p.y };
+ const p00m = { x: v00.p.x - W, y: v00.p.y };
+ const p10m = { x: v10.p.x - W, y: v10.p.y };
+ const p11m = { x: v11.p.x - W, y: v11.p.y };
+ const p01m = { x: v01.p.x - W, y: v01.p.y };
+ const prevAlpha2 = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ drawImageTri(img, v00.s, v10.s, v11.s, p00p, p10p, p11p);
+ drawImageTri(img, v00.s, v11.s, v01.s, p00p, p11p, p01p);
+ drawImageTri(img, v00.s, v10.s, v11.s, p00m, p10m, p11m);
+ drawImageTri(img, v00.s, v11.s, v01.s, p00m, p11m, p01m);
+ ctx.globalAlpha = prevAlpha2;
+ drawnTriangles += 4;
+ }
+ }
+ }
+ return drawnTriangles > 0;
+ }
+
+ function drawStickerMesh(item, img) {
+ return drawStickerMeshMapped(item, img, { x0: 0, y0: 0, x1: 1, y1: 1 }, { x0: 0, y0: 0, x1: 1, y1: 1 }, 1);
+ }
+
+ function sampleStickerEdge(item, edge, steps, refX = null) {
+ const out = [];
+ for (let i = 0; i <= steps; i += 1) {
+ const t = i / steps;
+ let u = 0;
+ let v = 0;
+ if (edge === 0) { u = t; v = 0; } // top
+ else if (edge === 1) { u = 1; v = t; } // right
+ else if (edge === 2) { u = 1 - t; v = 1; } // bottom
+ else { u = 0; v = 1 - t; } // left
+
+ const d = stickerSampleDir(item, u, v);
+ const p = editor.mode === "unwrap" ? projectDirUnwrap(d, refX) : projectDir(d);
+ if (p) out.push(p);
+ }
+ return out;
+ }
+
+ function drawStickerBoundary(item, selected) {
+ const centerDir = yawPitchToDir(Number(item.yaw_deg || 0), Number(item.pitch_deg || 0));
+ const centerProj = editor.mode === "unwrap" ? projectDirUnwrap(centerDir) : null;
+ const refX = centerProj ? centerProj.x : null;
+ const steps = editor.mode === "pano" ? 28 : 20;
+ const edges = [
+ sampleStickerEdge(item, 0, steps, refX),
+ sampleStickerEdge(item, 1, steps, refX),
+ sampleStickerEdge(item, 2, steps, refX),
+ sampleStickerEdge(item, 3, steps, refX),
+ ];
+
+ ctx.strokeStyle = selected ? "rgba(250, 250, 250, 0.9)" : "#71717a";
+ ctx.lineWidth = selected ? 2 : 1;
+ ctx.beginPath();
+ let started = false;
+ for (const edge of edges) {
+ for (const p of edge) {
+ if (!started) {
+ ctx.moveTo(p.x, p.y);
+ started = true;
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ }
+ }
+ ctx.closePath();
+ ctx.stroke();
+ }
+
+ function drawObjects() {
+ const [usedNu, usedNv] = getMeshDivisions();
+ const items = [...getList()].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ for (const item of items) {
+ const selected = item.id === editor.selectedId;
+ const g = objectGeom(item);
+ let meshDrawn = false;
+
+ if (type === "stickers") {
+ const img = getStickerImage(item);
+ const alpha = getStickerDisplayAlpha(item);
+ if (img && (img.complete || img.width)) {
+ meshDrawn = drawStickerMeshMapped(item, img, { x0: 0, y0: 0, x1: 1, y1: 1 }, { x0: 0, y0: 0, x1: 1, y1: 1 }, alpha);
+ } else if (g.visible) {
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = alpha;
+ ctx.fillStyle = "rgba(255,255,255,0.08)";
+ ctx.beginPath(); ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath(); ctx.fill();
+ ctx.globalAlpha = prevAlpha;
+ meshDrawn = true;
+ }
+ } else if (!g.visible) {
+ continue;
+ }
+
+ if (type === "stickers") {
+ if (!meshDrawn) continue;
+ const prevAlpha = ctx.globalAlpha;
+ ctx.globalAlpha = getStickerDisplayAlpha(item);
+ drawStickerBoundary(item, selected);
+ ctx.globalAlpha = prevAlpha;
+ } else {
+ ctx.fillStyle = selected ? "rgba(0, 112, 243, 0.24)" : "rgba(255, 255, 255, 0.12)";
+ ctx.beginPath();
+ ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath();
+ ctx.fill();
+ ctx.strokeStyle = selected ? "rgba(255, 255, 255, 1)" : "rgba(255, 255, 255, 0.82)";
+ ctx.lineWidth = selected ? 2.8 : 1.9;
+ ctx.beginPath();
+ ctx.moveTo(g.corners[0].x, g.corners[0].y);
+ for (let i = 1; i < 4; i += 1) ctx.lineTo(g.corners[i].x, g.corners[i].y);
+ ctx.closePath();
+ ctx.stroke();
+ }
+
+ if (selected && g.visible) {
+ const accent = (type === "stickers" && isExternalSticker(item)) ? "#f59e0b" : "#0070f3";
+ ctx.fillStyle = accent;
+ g.corners.forEach((p) => { ctx.beginPath(); ctx.arc(p.x, p.y, 6.5, 0, Math.PI * 2); ctx.fill(); });
+ if (type === "cutout") {
+ ctx.strokeStyle = accent;
+ ctx.lineCap = "round";
+ ctx.lineWidth = 4;
+ g.edgeMidpoints.forEach((m) => {
+ const dx = (m.b?.x ?? m.x) - (m.a?.x ?? m.x);
+ const dy = (m.b?.y ?? m.y) - (m.a?.y ?? m.y);
+ const ll2 = Math.hypot(dx, dy) || 1;
+ const tx = dx / ll2;
+ const ty = dy / ll2;
+ const half = 10;
+ ctx.beginPath();
+ ctx.moveTo(m.x - tx * half, m.y - ty * half);
+ ctx.lineTo(m.x + tx * half, m.y + ty * half);
+ ctx.stroke();
+ });
+ ctx.lineCap = "butt";
+ }
+ ctx.strokeStyle = "rgba(250, 250, 250, 0.9)";
+ ctx.lineWidth = 1.8;
+ ctx.beginPath();
+ ctx.moveTo(g.rotateStemBase.x, g.rotateStemBase.y);
+ ctx.lineTo(g.rotateHandle.x, g.rotateHandle.y);
+ ctx.stroke();
+ ctx.fillStyle = accent;
+ ctx.beginPath(); ctx.arc(g.rotateHandle.x, g.rotateHandle.y, 10, 0, Math.PI * 2); ctx.fill();
+ }
+ }
+
+ if (editor.hqFrames && usedNu >= 40 && usedNv >= 30) {
+ editor.hqFrames -= 1;
+ if (editor.hqFrames > 0) requestDraw();
+ }
+ }
+
+ function drawCutoutOutputPreview() {
+ if (type !== "cutout") return;
+ const shot = getSelected() || state.shots?.[0];
+ if (!shot) {
+ editor.outputPreviewRect = null;
+ if (outputPreviewToggleBtn) outputPreviewToggleBtn.style.display = "none";
+ return;
+ }
+
+ const margin = 14;
+ const mix = clamp(Number(editor.outputPreviewAnim ?? (editor.outputPreviewExpanded ? 1 : 0)), 0, 1);
+ const maxWCollapsed = Math.max(120, Math.min(250, canvas.width * 0.28));
+ const maxWExpanded = Math.max(260, Math.min(560, canvas.width * 0.62));
+ const maxHCollapsed = Math.max(76, Math.min(150, canvas.height * 0.22));
+ const maxHExpanded = Math.max(160, Math.min(340, canvas.height * 0.48));
+ const maxW = lerp(maxWCollapsed, maxWExpanded, mix);
+ const maxH = lerp(maxHCollapsed, maxHExpanded, mix);
+ const params = getCutoutShotParams(shot);
+ const aspect = params.aspect;
+ let pw = maxW;
+ let ph = pw / aspect;
+ if (ph > maxH) {
+ ph = maxH;
+ pw = ph * aspect;
+ }
+ const px = canvas.width - margin - pw;
+ const py = margin;
+ const radius = 12;
+ editor.outputPreviewRect = { x: px, y: py, w: pw, h: ph };
+ const placeOutputPreviewToggle = () => {
+ if (!outputPreviewToggleBtn) return;
+ const left = `${Math.round(px + pw - 8 - 24)}px`;
+ const top = `${Math.round(py + 8)}px`;
+ outputPreviewToggleBtn.style.display = "inline-flex";
+ if (outputPreviewToggleBtn.style.left !== left) outputPreviewToggleBtn.style.left = left;
+ if (outputPreviewToggleBtn.style.top !== top) outputPreviewToggleBtn.style.top = top;
+ };
+
+ const roundedRect = (x, y, w, h, r) => {
+ const rr = Math.max(0, Math.min(r, Math.min(w, h) * 0.5));
+ ctx.beginPath();
+ if (typeof ctx.roundRect === "function") {
+ ctx.roundRect(x, y, w, h, rr);
+ } else {
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ }
+ ctx.closePath();
+ };
+
+ ctx.save();
+ ctx.shadowColor = "rgba(0, 0, 0, 0.45)";
+ ctx.shadowBlur = 22;
+ ctx.shadowOffsetX = 0;
+ ctx.shadowOffsetY = 8;
+ ctx.fillStyle = "rgba(10, 10, 10, 0.72)";
+ roundedRect(px, py, pw, ph, radius);
+ ctx.fill();
+ ctx.restore();
+
+ ctx.save();
+ roundedRect(px, py, pw, ph, radius);
+ ctx.clip();
+
+ const img = getConnectedErpImage();
+ if (!img || !img.complete || !(img.naturalWidth || img.width)) {
+ ctx.fillStyle = "rgba(255, 255, 255, 0.06)";
+ ctx.fillRect(px, py, pw, ph);
+ ctx.fillStyle = "rgba(250, 250, 250, 0.7)";
+ ctx.font = "500 10px Geist, sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText("Connect ERP image", px + pw * 0.5, py + ph * 0.5);
+ ctx.restore();
+ placeOutputPreviewToggle();
+ return;
+ }
+
+ if (Number(img.naturalWidth || img.width || 0) <= 1 || Number(img.naturalHeight || img.height || 0) <= 1) {
+ ctx.restore();
+ if (outputPreviewToggleBtn) outputPreviewToggleBtn.style.display = "none";
+ return;
+ }
+
+ const drawn = drawCutoutProjectionPreview(
+ ctx,
+ node,
+ img,
+ { x: px, y: py, w: pw, h: ph },
+ shot,
+ mix > 0.65 ? "high" : "balanced",
+ );
+ panoEditorDebug("cutout.draw", {
+ nodeId: node?.id ?? null,
+ hasImage: !!img,
+ imageComplete: !!img?.complete,
+ imageSize: img ? [Number(img.naturalWidth || img.width || 0), Number(img.naturalHeight || img.height || 0)] : null,
+ imageSrc: String(img?.src || ""),
+ drawn: !!drawn,
+ quality: mix > 0.65 ? "high" : "balanced",
+ });
+ ctx.restore();
+ placeOutputPreviewToggle();
+ }
+
+ function drawScene() {
+ if (editor.mode === "unwrap") drawGridUnwrap();
+ else drawGridPano();
+ drawObjects();
+ drawCutoutOutputPreview();
+ if (fovValueEl) fovValueEl.textContent = `${editor.viewFov.toFixed(1)}`;
+ updateSelectionMenu();
+ }
+
+ function isCutoutTransformInteractionActive() {
+ if (type !== "cutout") return false;
+ const kind = String(editor.interaction?.kind || "");
+ return kind === "move" || kind === "scale" || kind === "scale_x" || kind === "scale_y" || kind === "rotate";
+ }
+
+ function requestDraw() {
+ syncLookAtFrameButtonState();
+ node.__panoLiveStateOverride = JSON.stringify(state);
+ if (!isCutoutTransformInteractionActive()) {
+ node.__panoDomPreview?.requestDraw?.();
+ node.setDirtyCanvas?.(true, false);
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+ }
+ runtime.dirty = true;
+ }
+
+ function syncCanvasSize() {
+ const rect = canvas.getBoundingClientRect();
+ const nextW = Math.max(2, Math.round(rect.width));
+ const nextH = Math.max(2, Math.round(rect.height));
+ if (canvas.width !== nextW || canvas.height !== nextH) {
+ canvas.width = nextW;
+ canvas.height = nextH;
+ runtime.dirty = true;
+ }
+ }
+
+ function tick(ts = performance.now()) {
+ if (!runtime.running) return;
+ const dt = runtime.lastTickTs > 0 ? Math.max(0.001, (ts - runtime.lastTickTs) / 1000) : (1 / 60);
+ runtime.lastTickTs = ts;
+ if (editor.outputPreviewAnim !== editor.outputPreviewAnimTo) {
+ const dur = Math.max(1, Number(editor.outputPreviewAnimDurationMs || 180));
+ const t = clamp((ts - Number(editor.outputPreviewAnimStartTs || 0)) / dur, 0, 1);
+ const expanding = editor.outputPreviewAnimTo > editor.outputPreviewAnimFrom;
+ const eased = expanding ? easeOutCubic(t) : easeInCubic(t);
+ editor.outputPreviewAnim = lerp(editor.outputPreviewAnimFrom, editor.outputPreviewAnimTo, eased);
+ runtime.dirty = true;
+ if (t >= 1) {
+ editor.outputPreviewAnim = editor.outputPreviewAnimTo;
+ }
+ }
+
+ if (editor.viewTween?.active) {
+ const tw = editor.viewTween;
+ const t = clamp((ts - tw.startTs) / tw.durationMs, 0, 1);
+ const eased = easeInOutCubic(t);
+ editor.viewYaw = wrapYaw(tw.startYaw + tw.deltaYaw * eased);
+ editor.viewPitch = tw.startPitch + (tw.targetPitch - tw.startPitch) * eased;
+ editor.viewFov = tw.startFov + (tw.targetFov - tw.startFov) * eased;
+ runtime.dirty = true;
+ if (t >= 1) editor.viewTween = null;
+ }
+
+ editor.viewInertia.vx = Number(viewController.state.inertia.vx || 0);
+ editor.viewInertia.vy = Number(viewController.state.inertia.vy || 0);
+ editor.viewInertia.active = !!viewController.state.inertia.active;
+ if (viewController.stepInertia(ts)) {
+ editor.viewInertia.vx = Number(viewController.state.inertia.vx || 0);
+ editor.viewInertia.vy = Number(viewController.state.inertia.vy || 0);
+ editor.viewInertia.active = !!viewController.state.inertia.active;
+ runtime.dirty = true;
+ }
+
+ if (runtime.dirty || (ts - runtime.lastSizeCheckTs) >= 220) {
+ syncCanvasSize();
+ runtime.lastSizeCheckTs = ts;
+ }
+ if (runtime.dirty) {
+ runtime.dirty = false;
+ drawScene();
+ }
+ runtime.rafId = requestAnimationFrame(tick);
+ }
+
+ function stopRenderLoop() {
+ runtime.running = false;
+ if (runtime.rafId) cancelAnimationFrame(runtime.rafId);
+ runtime.rafId = 0;
+ }
+
+ function pushHistory() {
+ if (readOnly) return;
+ const snap = JSON.stringify(state);
+ if (editor.history[editor.historyIndex] === snap) return;
+ editor.history = editor.history.slice(0, editor.historyIndex + 1);
+ editor.history.push(snap);
+ if (editor.history.length > 80) editor.history.shift();
+ editor.historyIndex = editor.history.length - 1;
+ }
+
+ function restoreHistory(step) {
+ if (readOnly) return;
+ const idx = editor.historyIndex + step;
+ if (idx < 0 || idx >= editor.history.length) return;
+ const parsed = JSON.parse(editor.history[idx]);
+ Object.keys(state).forEach((k) => delete state[k]);
+ Object.assign(state, parsed);
+ editor.historyIndex = idx;
+ editor.selectedId = type === "stickers" ? state.active.selected_sticker_id : state.active.selected_shot_id;
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function addParamRow(container, selected, key, label, min, max, step, enabled = true) {
+ const row = document.createElement("div");
+ row.dataset.key = key;
+ row.dataset.min = String(min);
+ row.dataset.max = String(max);
+ row.className = "pano-field";
+ row.innerHTML = ``;
+ const [rng, num] = row.querySelectorAll("input");
+ const setRangeFill = () => {
+ const nMin = Number(min);
+ const nMax = Number(max);
+ const nVal = Number(rng.value);
+ const pct = ((nVal - nMin) / Math.max(1e-6, nMax - nMin)) * 100;
+ rng.style.setProperty("--v", `${clamp(pct, 0, 100)}%`);
+ };
+ rng.disabled = !enabled;
+ num.disabled = !enabled;
+ const setVal = (v) => {
+ if (!enabled) return;
+ let out = Number(v);
+ if (Number.isNaN(out)) out = 0;
+ out = clamp(out, min, max);
+ if (key === "yaw_deg") out = wrapYaw(out);
+ selected[key] = out;
+ rng.value = String(out);
+ num.value = formatParamValue(out);
+ setRangeFill();
+ requestDraw();
+ };
+ rng.oninput = () => setVal(rng.value);
+ num.oninput = () => setVal(num.value);
+ rng.onchange = () => pushHistory();
+ num.onchange = () => pushHistory();
+ setRangeFill();
+ container.appendChild(row);
+ }
+
+ function syncSidePanelControls() {
+ const selected = getSelected();
+ if (!selected) return;
+ editor.panelLastValues = {
+ yaw_deg: Number(selected.yaw_deg || 0),
+ pitch_deg: Number(selected.pitch_deg || 0),
+ hFOV_deg: Number(selected.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selected.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selected.rot_deg || 0),
+ roll_deg: Number(selected.roll_deg || 0),
+ out_w: Number(selected.out_w || 1024),
+ out_h: Number(selected.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selected),
+ };
+ const rows = side.querySelectorAll(".pano-field[data-key]");
+ rows.forEach((row) => {
+ const key = row.dataset.key;
+ if (!key) return;
+ if (!(key in selected)) return;
+ const rng = row.querySelector("input[type='range']");
+ const num = row.querySelector("input[type='number']");
+ if (!rng || !num) return;
+ const min = Number(row.dataset.min ?? rng.min ?? 0);
+ const max = Number(row.dataset.max ?? rng.max ?? 0);
+ let out = Number(selected[key] || 0);
+ if (Number.isNaN(out)) out = 0;
+ out = clamp(out, min, max);
+ const s = String(out);
+ if (rng.value !== s) rng.value = s;
+ const t = formatParamValue(out);
+ if (num.value !== t) num.value = t;
+ const pct = ((out - min) / Math.max(1e-6, max - min)) * 100;
+ rng.style.setProperty("--v", `${clamp(pct, 0, 100)}%`);
+ });
+ const resolvedAspect = getCutoutAspectLabel(selected);
+ const aspectLabel = side.querySelector(".pano-cutout-aspect-label span");
+ if (aspectLabel) aspectLabel.textContent = resolvedAspect;
+ const presetBtns = side.querySelectorAll(".pano-cutout-aspect-pop [data-aspect]");
+ presetBtns.forEach((btn) => {
+ btn.classList.toggle("active", String(btn.getAttribute("data-aspect")) === resolvedAspect);
+ });
+ }
+
+ function updateSidePanel() {
+ if (hideSidebar) return;
+ const staticNodes = [...side.children].slice(0, 2);
+ side.innerHTML = "";
+ staticNodes.forEach((n) => side.appendChild(n));
+
+ const sideActions = side.querySelector(".pano-side-actions");
+ if (sideActions) {
+ sideActions.innerHTML = "";
+ }
+ if (previewMode) {
+ const inspector = document.createElement("div");
+ inspector.className = "pano-inspector";
+ const uiDetails = document.createElement("details");
+ uiDetails.className = "pano-ui-settings";
+ uiDetails.open = false;
+ uiDetails.innerHTML = `
+
+ UI Settings
+ ${ICON.chevron}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const segX = uiDetails.querySelector("[data-setting='invert-x']");
+ const segY = uiDetails.querySelector("[data-setting='invert-y']");
+ const qualityPicker = uiDetails.querySelector("[data-ui-picker='quality']");
+ const resetUi = uiDetails.querySelector("[data-action='ui-reset-defaults']");
+ const setupUiPicker = (pickerEl, options, getValue, setValue) => {
+ const trigger = pickerEl.querySelector(".pano-picker-trigger");
+ const label = pickerEl.querySelector(".pano-picker-label");
+ const pop = pickerEl.querySelector(".pano-picker-pop");
+ const refresh = () => {
+ const cur = String(getValue());
+ const found = options.find((o) => String(o.value) === cur) || options[0];
+ label.textContent = found.label;
+ pop.innerHTML = "";
+ options.forEach((o) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = `pano-picker-item${String(o.value) === cur ? " active" : ""}`;
+ b.textContent = o.label;
+ b.onclick = () => {
+ setValue(o.value);
+ pop.hidden = true;
+ refresh();
+ persistUiSettings();
+ requestDraw();
+ };
+ pop.appendChild(b);
+ });
+ };
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ uiDetails.querySelectorAll(".pano-ui-picker .pano-picker-pop").forEach((el) => {
+ if (el !== pop) el.hidden = true;
+ });
+ pop.hidden = !pop.hidden;
+ };
+ refresh();
+ return refresh;
+ };
+ const setSegmentValue = (seg, on) => {
+ seg.setAttribute("data-selected", on ? "1" : "0");
+ seg.querySelectorAll(".pano-segment-btn").forEach((b) => {
+ b.setAttribute("aria-pressed", b.getAttribute("data-value") === (on ? "1" : "0") ? "true" : "false");
+ });
+ };
+ segX.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_x = on;
+ setSegmentValue(segX, on);
+ persistUiSettings();
+ requestDraw();
+ };
+ });
+ segY.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_y = on;
+ setSegmentValue(segY, on);
+ persistUiSettings();
+ requestDraw();
+ };
+ });
+ const refreshQuality = setupUiPicker(
+ qualityPicker,
+ [
+ { value: "draft", label: "Draft" },
+ { value: "balanced", label: "Balanced" },
+ { value: "high", label: "High" },
+ ],
+ () => String(state.ui_settings.preview_quality || "balanced"),
+ (v) => {
+ const q = String(v || "balanced");
+ state.ui_settings.preview_quality = (q === "draft" || q === "high") ? q : "balanced";
+ },
+ );
+ resetUi.onclick = () => {
+ state.ui_settings.invert_view_x = false;
+ state.ui_settings.invert_view_y = false;
+ state.ui_settings.preview_quality = "balanced";
+ setSegmentValue(segX, false);
+ setSegmentValue(segY, false);
+ refreshQuality();
+ persistUiSettings();
+ requestDraw();
+ };
+ inspector.appendChild(uiDetails);
+ side.appendChild(inspector);
+
+ const footer = document.createElement("div");
+ footer.className = "pano-side-footer";
+ footer.innerHTML = ``;
+ footer.querySelector("[data-action='close-preview']").onclick = () => closeEditor();
+ side.appendChild(footer);
+ installTooltipHandlers(inspector);
+ return;
+ }
+
+ const list = getList();
+ const selected = getSelected();
+ if (selected) {
+ editor.panelLastValues = {
+ yaw_deg: Number(selected.yaw_deg || 0),
+ pitch_deg: Number(selected.pitch_deg || 0),
+ hFOV_deg: Number(selected.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selected.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selected.rot_deg || 0),
+ roll_deg: Number(selected.roll_deg || 0),
+ out_w: Number(selected.out_w || 1024),
+ out_h: Number(selected.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selected),
+ };
+ }
+ const fallback = editor.panelLastValues || (type === "stickers"
+ ? { yaw_deg: 0, pitch_deg: 0, hFOV_deg: 30, vFOV_deg: 30, rot_deg: 0 }
+ : { yaw_deg: 0, pitch_deg: 0, hFOV_deg: 90, vFOV_deg: 60, roll_deg: 0, out_w: 1024, out_h: 1024, aspect_id: "1:1" });
+ const effective = selected || fallback;
+ const enabled = !!selected;
+
+ const inspector = document.createElement("div");
+ inspector.className = "pano-inspector";
+ inspector.innerHTML = `
+
+ Transform
+
+ `;
+ side.appendChild(inspector);
+
+ if (type === "stickers") {
+ const targetRow = document.createElement("div");
+ targetRow.className = "pano-field-wide pano-target-row";
+ targetRow.innerHTML = `
+
+
+ `;
+ const trigger = targetRow.querySelector(".pano-picker-trigger");
+ const labelEl = targetRow.querySelector(".pano-picker-label");
+ const pop = targetRow.querySelector(".pano-picker-pop");
+ const items = [{ id: "", label: "No image" }];
+ list.forEach((item, i) => {
+ const baseLabel = isExternalSticker(item)
+ ? String(item.id || EXTERNAL_STICKER_ID)
+ : String(state.assets?.[item.asset_id]?.name || item.asset_id || item.id);
+ const label = `${i + 1}. ${baseLabel}${isExternalSticker(item) && isStickerHidden(item) ? " (hidden)" : ""}`;
+ items.push({ id: item.id, label });
+ });
+ const currentId = selected?.id || "";
+ const currentLabel = (items.find((it) => it.id === currentId) || items[0]).label;
+ labelEl.textContent = currentLabel;
+ pop.innerHTML = "";
+ items.forEach((it) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `pano-picker-item${it.id === currentId ? " active" : ""}`;
+ btn.textContent = it.label;
+ btn.onclick = () => {
+ pop.hidden = true;
+ editor.selectedId = it.id || null;
+ state.active.selected_sticker_id = editor.selectedId;
+ const selectedNow = getSelected();
+ if (selectedNow) {
+ const targetYaw = wrapYaw(Number(selectedNow.yaw_deg || 0));
+ const targetPitch = clamp(Number(selectedNow.pitch_deg || 0), -89.9, 89.9);
+ startViewTween(targetYaw, targetPitch, editor.viewFov);
+ }
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ };
+ pop.appendChild(btn);
+ });
+ trigger.disabled = list.length === 0;
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ if (trigger.disabled) return;
+ pop.hidden = !pop.hidden;
+ };
+ inspector.appendChild(targetRow);
+ }
+
+ const toolsRow = document.createElement("div");
+ toolsRow.className = "pano-state-actions";
+ toolsRow.innerHTML = ``;
+ const copyInline = toolsRow.querySelector("[data-action='copy-state-inline']");
+ copyInline.disabled = !enabled;
+ copyInline.onclick = async () => {
+ if (!enabled) return;
+ const text = JSON.stringify(type === "cutout"
+ ? buildCanonicalCutoutStickerState(effective)
+ : buildCanonicalSelectedStickerState(selected));
+ try {
+ await navigator.clipboard.writeText(text);
+ const label = copyInline.querySelector("span");
+ if (label) {
+ label.textContent = "Copied";
+ window.setTimeout(() => {
+ label.textContent = "Copy State";
+ }, 900);
+ }
+ } catch {
+ // no-op fallback for environments without clipboard permission
+ }
+ };
+ inspector.appendChild(toolsRow);
+
+ const paramsWrap = document.createElement("div");
+ paramsWrap.className = `pano-params${editor.panelWasEnabled ? "" : " disabled"}`;
+ inspector.appendChild(paramsWrap);
+
+ if (type === "cutout") {
+ const aspectRow = document.createElement("div");
+ aspectRow.className = "pano-field-wide pano-aspect-row";
+ const aspectLabel = getCutoutAspectLabel(effective);
+ aspectRow.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ const trigger = aspectRow.querySelector(".pano-picker-trigger");
+ const pop = aspectRow.querySelector(".pano-cutout-aspect-pop");
+ const labelSpan = aspectRow.querySelector(".pano-cutout-aspect-label span");
+ const cw = aspectRow.querySelector("[data-role='custom-w']");
+ const ch = aspectRow.querySelector("[data-role='custom-h']");
+ const applyCustomBtn = aspectRow.querySelector("[data-action='aspect-custom-apply']");
+ const rotateBtn = aspectRow.querySelector("[data-action='rotate-90-side']");
+ const refreshAspectUi = () => {
+ const now = getSelected();
+ const text = getCutoutAspectLabel(now || effective);
+ if (labelSpan) labelSpan.textContent = text;
+ const presets = pop.querySelectorAll("[data-aspect]");
+ presets.forEach((btn) => {
+ btn.classList.toggle("active", String(btn.getAttribute("data-aspect")) === text);
+ });
+ };
+ if (enabled) {
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ pop.hidden = !pop.hidden;
+ };
+ pop.querySelectorAll("[data-aspect]").forEach((btn) => {
+ btn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ applyCutoutAspect(now, String(btn.getAttribute("data-aspect") || "1:1"));
+ pop.hidden = true;
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ });
+ applyCustomBtn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ const ok = applyCutoutAspectCustom(now, Number(cw.value), Number(ch.value));
+ if (!ok) return;
+ pop.hidden = true;
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ rotateBtn.onclick = () => {
+ const now = getSelected();
+ if (!now) return;
+ rotateCutoutAspect90(now);
+ refreshAspectUi();
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ requestDraw();
+ };
+ }
+ refreshAspectUi();
+ paramsWrap.appendChild(aspectRow);
+ }
+
+ addParamRow(paramsWrap, effective, "yaw_deg", "Yaw", -180, 180, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "pitch_deg", "Pitch", -90, 90, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "hFOV_deg", "H FOV", 1, 179, 0.1, enabled && !readOnly);
+ addParamRow(paramsWrap, effective, "vFOV_deg", "V FOV", 1, 179, 0.1, enabled && !readOnly);
+ if (type === "stickers") {
+ addParamRow(paramsWrap, effective, "rot_deg", "Rotation", -180, 180, 0.1, enabled && !readOnly);
+ } else {
+ addParamRow(paramsWrap, effective, "roll_deg", "Roll", -180, 180, 0.1, enabled && !readOnly);
+ }
+
+ if (enabled !== editor.panelWasEnabled) {
+ requestAnimationFrame(() => {
+ paramsWrap.classList.toggle("disabled", !enabled);
+ });
+ } else {
+ paramsWrap.classList.toggle("disabled", !enabled);
+ }
+ editor.panelWasEnabled = enabled;
+ syncLookAtFrameButtonState();
+
+ if (!readOnly) {
+ const uiDetails = document.createElement("details");
+ uiDetails.className = "pano-ui-settings";
+ uiDetails.open = false;
+ uiDetails.innerHTML = `
+
+ UI Settings
+ ${ICON.chevron}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ const segX = uiDetails.querySelector("[data-setting='invert-x']");
+ const segY = uiDetails.querySelector("[data-setting='invert-y']");
+ const qualityPicker = uiDetails.querySelector("[data-ui-picker='quality']");
+ const resetUi = uiDetails.querySelector("[data-action='ui-reset-defaults']");
+ const setupUiPicker = (pickerEl, options, getValue, setValue) => {
+ const trigger = pickerEl.querySelector(".pano-picker-trigger");
+ const label = pickerEl.querySelector(".pano-picker-label");
+ const pop = pickerEl.querySelector(".pano-picker-pop");
+ const refresh = () => {
+ const cur = String(getValue());
+ const found = options.find((o) => String(o.value) === cur) || options[0];
+ label.textContent = found.label;
+ pop.innerHTML = "";
+ options.forEach((o) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = `pano-picker-item${String(o.value) === cur ? " active" : ""}`;
+ b.textContent = o.label;
+ b.onclick = () => {
+ setValue(o.value);
+ pop.hidden = true;
+ refresh();
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ pop.appendChild(b);
+ });
+ };
+ trigger.onclick = (ev) => {
+ ev.stopPropagation();
+ uiDetails.querySelectorAll(".pano-ui-picker .pano-picker-pop").forEach((el) => {
+ if (el !== pop) el.hidden = true;
+ });
+ pop.hidden = !pop.hidden;
+ };
+ refresh();
+ return refresh;
+ };
+ const setSegmentValue = (seg, on) => {
+ seg.setAttribute("data-selected", on ? "1" : "0");
+ seg.querySelectorAll(".pano-segment-btn").forEach((b) => {
+ b.setAttribute("aria-pressed", b.getAttribute("data-value") === (on ? "1" : "0") ? "true" : "false");
+ });
+ };
+ segX.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_x = on;
+ setSegmentValue(segX, on);
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ });
+ segY.querySelectorAll(".pano-segment-btn").forEach((btn) => {
+ btn.onclick = () => {
+ const on = btn.getAttribute("data-value") === "1";
+ state.ui_settings.invert_view_y = on;
+ setSegmentValue(segY, on);
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ });
+ const refreshQuality = setupUiPicker(
+ qualityPicker,
+ [
+ { value: "draft", label: "Draft" },
+ { value: "balanced", label: "Balanced" },
+ { value: "high", label: "High" },
+ ],
+ () => String(state.ui_settings.preview_quality || "balanced"),
+ (v) => {
+ const q = String(v || "balanced");
+ state.ui_settings.preview_quality = (q === "draft" || q === "high") ? q : "balanced";
+ },
+ );
+ resetUi.onclick = () => {
+ state.ui_settings.invert_view_x = false;
+ state.ui_settings.invert_view_y = false;
+ state.ui_settings.preview_quality = "balanced";
+ setSegmentValue(segX, false);
+ setSegmentValue(segY, false);
+ refreshQuality();
+ persistUiSettings();
+ node.setDirtyCanvas(true, true);
+ requestDraw();
+ };
+ inspector.appendChild(uiDetails);
+ }
+
+ if (!hideFooter) {
+ const footer = document.createElement("div");
+ footer.className = "pano-side-footer";
+ footer.innerHTML = `
+
+
+ `;
+ footer.querySelector("[data-action='cancel-close']").onclick = () => closeEditor();
+ footer.querySelector("[data-action='save-close']").onclick = () => {
+ apply();
+ closeEditor();
+ };
+ side.appendChild(footer);
+ }
+ installTooltipHandlers(inspector);
+ }
+
+ function isImageFile(file) {
+ if (!file) return false;
+ const t = String(file.type || "").toLowerCase();
+ if (t.startsWith("image/")) return true;
+ const n = String(file.name || "").toLowerCase();
+ return n.endsWith(".png") || n.endsWith(".jpg") || n.endsWith(".jpeg") || n.endsWith(".webp") || n.endsWith(".gif") || n.endsWith(".bmp");
+ }
+
+ async function addImageStickerFromFile(file) {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ if (!isImageFile(file)) return;
+ const aid = uid("asset");
+ const tempUrl = URL.createObjectURL(file);
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const i = new Image();
+ i.onload = () => resolve(i);
+ i.onerror = () => reject(new Error("image load failed"));
+ i.src = tempUrl;
+ });
+ imageCache.set(aid, img);
+ const uploaded = await uploadStickerAssetFile(file, String(file.name || aid));
+ state.assets[aid] = uploaded;
+ const ar = (img.naturalHeight || img.height || 1) / (img.naturalWidth || img.width || 1);
+ const id = uid("st");
+ state.stickers.push({
+ id,
+ asset_id: aid,
+ yaw_deg: editor.viewYaw,
+ pitch_deg: editor.viewPitch,
+ hFOV_deg: 30,
+ vFOV_deg: clamp(30 * ar, 1, 179),
+ rot_deg: 0,
+ z_index: getNextStickerZIndex(),
+ });
+ editor.selectedId = id;
+ state.active.selected_sticker_id = id;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ } catch (err) {
+ console.error("[TY360] failed to add sticker asset", err);
+ delete state.assets[aid];
+ imageCache.delete(aid);
+ } finally {
+ URL.revokeObjectURL(tempUrl);
+ }
+ }
+
+ function addImageSticker() {
+ if (readOnly) return;
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "image/*";
+ input.onchange = () => {
+ const file = input.files?.[0];
+ if (!file) return;
+ void addImageStickerFromFile(file);
+ };
+ input.click();
+ }
+
+ async function migrateLegacyEmbeddedAssets() {
+ if (typeof options?.adapters?.saveAssetFile !== "function") return;
+ if (readOnly || type !== "stickers") return;
+ const assets = state.assets && typeof state.assets === "object" ? state.assets : {};
+ const entries = Object.entries(assets).filter(([, asset]) => {
+ const t = String(asset?.type || "").toLowerCase();
+ const v = String(asset?.value || "");
+ return t === "dataurl" && v.startsWith("data:image");
+ });
+ if (!entries.length) return;
+ let changed = false;
+ for (const [assetId, asset] of entries) {
+ try {
+ const dataUrl = String(asset?.value || "");
+ if (!dataUrl) continue;
+ const blob = await fetch(dataUrl).then((r) => r.blob());
+ const ext = String(blob.type || "image/png").split("/")[1] || "png";
+ const name = String(asset?.name || `${assetId}.${ext}`);
+ const file = new File([blob], name, { type: blob.type || "image/png" });
+ const uploaded = await uploadStickerAssetFile(file, name);
+ state.assets[assetId] = {
+ ...uploaded,
+ w: Number(asset?.w || 0),
+ h: Number(asset?.h || 0),
+ };
+ changed = true;
+ } catch (err) {
+ console.error("[TY360] failed to migrate embedded sticker asset", { assetId, err });
+ }
+ }
+ if (changed) {
+ commitAndRefreshNode();
+ requestDraw();
+ }
+ }
+
+ function addCutoutFrame() {
+ if (readOnly) return;
+ if (type !== "cutout") return;
+ state.shots = [{
+ id: uid("sh"),
+ yaw_deg: editor.viewYaw,
+ pitch_deg: editor.viewPitch,
+ hFOV_deg: 64,
+ vFOV_deg: 40,
+ roll_deg: 0,
+ out_w: 1024,
+ out_h: 1024,
+ aspect_id: "1:1",
+ }];
+ editor.selectedId = state.shots[0].id;
+ editor.cutoutAspectOpen = false;
+ state.active.selected_shot_id = editor.selectedId;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function clearCutoutFrame() {
+ if (readOnly) return;
+ if (type !== "cutout") return;
+ state.shots = [];
+ editor.selectedId = null;
+ editor.cutoutAspectOpen = false;
+ state.active.selected_shot_id = null;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function showCanvasConfirm(title, text, confirmLabel = "Clear") {
+ return new Promise((resolve) => {
+ const layer = document.createElement("div");
+ layer.className = "pano-canvas-confirm";
+ layer.innerHTML = `
+
+
${title}
+
${text}
+
+
+
+
+
+ `;
+ const close = (ok) => {
+ layer.remove();
+ resolve(!!ok);
+ };
+ layer.addEventListener("pointerdown", (ev) => {
+ if (ev.target === layer) close(false);
+ });
+ const cancelBtn = layer.querySelector("[data-action='cancel']");
+ const confirmBtn = layer.querySelector("[data-action='confirm']");
+ cancelBtn.onclick = () => close(false);
+ confirmBtn.onclick = () => close(true);
+ stageWrap.appendChild(layer);
+ confirmBtn.focus();
+ });
+ }
+
+ async function clearAll() {
+ if (readOnly) return;
+ const ok = await showCanvasConfirm(
+ type === "stickers" ? "Clear All Stickers" : "Remove Frame",
+ type === "stickers"
+ ? "This will remove all stickers from the current state."
+ : "This will remove the current cutout frame.",
+ type === "stickers" ? "Clear All" : "Remove",
+ );
+ if (!ok) return;
+ if (type === "stickers") {
+ state.stickers = [];
+ pruneUnusedAssets();
+ editor.selectedId = null;
+ state.active.selected_sticker_id = null;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ return;
+ }
+ clearCutoutFrame();
+ }
+
+ function duplicateSelected() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ if (isExternalSticker(selected)) return;
+ const copy = JSON.parse(JSON.stringify(selected));
+ copy.id = uid(type === "stickers" ? "st" : "sh");
+ copy.yaw_deg = wrapYaw((copy.yaw_deg || 0) + 8);
+ if (type === "stickers") {
+ copy.z_index = getNextStickerZIndex();
+ state.stickers.push(copy);
+ state.active.selected_sticker_id = copy.id;
+ } else {
+ state.shots = [copy];
+ state.active.selected_shot_id = copy.id;
+ }
+ editor.selectedId = copy.id;
+ pushHistory();
+ updateSidePanel();
+ requestDraw();
+ }
+
+ function deleteSelected() {
+ if (readOnly) return;
+ const selected = getSelected();
+ if (!selected) return;
+ if (type === "stickers") {
+ if (isExternalSticker(selected)) {
+ selected.visible = isStickerHidden(selected);
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ state.stickers = state.stickers.filter((s) => s.id !== selected.id);
+ pruneUnusedAssets();
+ editor.selectedId = state.stickers[0]?.id || null;
+ state.active.selected_sticker_id = editor.selectedId;
+ pushHistory();
+ commitAndRefreshNode();
+ updateSidePanel();
+ requestDraw();
+ return;
+ }
+ clearCutoutFrame();
+ }
+
+ function applyCutoutAspect(selected, aspect) {
+ if (!selected) return;
+ const pairs = {
+ "1:1": [1, 1],
+ "3:2": [3, 2],
+ "4:3": [4, 3],
+ "16:9": [16, 9],
+ };
+ const currentLandscape = (() => {
+ const hf = Number(selected.hFOV_deg || 64);
+ const vf = Number(selected.vFOV_deg || 40);
+ if (Math.abs(hf - vf) > 1e-6) return hf >= vf;
+ return Number(selected.out_w || 1024) >= Number(selected.out_h || 1024);
+ })();
+ let [aw, ah] = pairs[String(aspect)] || pairs["1:1"];
+ if ((aw >= ah) !== currentLandscape) [aw, ah] = [ah, aw];
+ const ratio = aw / ah;
+ const hf = clamp(Number(selected.hFOV_deg || 64), 1, 179);
+ const vf = clamp(Number(selected.vFOV_deg || 40), 1, 179);
+ const span = Math.sqrt(Math.max(1, hf * vf));
+ const targetHF = clamp(span * Math.sqrt(ratio), 1, 179);
+ const targetVF = clamp(span / Math.sqrt(ratio), 1, 179);
+ selected.hFOV_deg = targetHF;
+ selected.vFOV_deg = targetVF;
+ const base = Math.max(512, Number(selected.out_w || 1024), Number(selected.out_h || 1024));
+ const scale = base / Math.max(aw, ah);
+ const ow = Math.max(256, Math.round((aw * scale) / 8) * 8);
+ const oh = Math.max(256, Math.round((ah * scale) / 8) * 8);
+ selected.out_w = ow;
+ selected.out_h = oh;
+ selected.aspect_id = String(aspect);
+ }
+
+ function applyCutoutAspectCustom(selected, w, h) {
+ if (!selected) return false;
+ const rw = Math.max(1, Number(w));
+ const rh = Math.max(1, Number(h));
+ if (!Number.isFinite(rw) || !Number.isFinite(rh)) return false;
+ const currentLandscape = (() => {
+ const hf = Number(selected.hFOV_deg || 64);
+ const vf = Number(selected.vFOV_deg || 40);
+ if (Math.abs(hf - vf) > 1e-6) return hf >= vf;
+ return Number(selected.out_w || 1024) >= Number(selected.out_h || 1024);
+ })();
+ let aw = rw;
+ let ah = rh;
+ if ((aw >= ah) !== currentLandscape) [aw, ah] = [ah, aw];
+ const ratio = aw / ah;
+ const hf = clamp(Number(selected.hFOV_deg || 64), 1, 179);
+ const vf = clamp(Number(selected.vFOV_deg || 40), 1, 179);
+ const span = Math.sqrt(Math.max(1, hf * vf));
+ selected.hFOV_deg = clamp(span * Math.sqrt(ratio), 1, 179);
+ selected.vFOV_deg = clamp(span / Math.sqrt(ratio), 1, 179);
+ const base = Math.max(512, Number(selected.out_w || 1024), Number(selected.out_h || 1024));
+ const scale = base / Math.max(aw, ah);
+ selected.out_w = Math.max(256, Math.round((aw * scale) / 8) * 8);
+ selected.out_h = Math.max(256, Math.round((ah * scale) / 8) * 8);
+ selected.aspect_id = `${Math.round(rw)}:${Math.round(rh)}`;
+ return true;
+ }
+
+ function rotateCutoutAspect90(selected) {
+ if (!selected) return;
+ const ow = Math.max(8, Number(selected.out_w || 1024));
+ const oh = Math.max(8, Number(selected.out_h || 1024));
+ selected.out_w = oh;
+ selected.out_h = ow;
+ const hf = Math.max(1, Number(selected.hFOV_deg || 90));
+ const vf = Math.max(1, Number(selected.vFOV_deg || 60));
+ selected.hFOV_deg = vf;
+ selected.vFOV_deg = hf;
+ }
+
+ function normalizeStickerZIndices() {
+ state.stickers
+ .sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0))
+ .forEach((st, i) => { st.z_index = i; });
+ }
+
+ function bringSelectedToFront() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ normalizeStickerZIndices();
+ const sorted = [...state.stickers].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ const idx = sorted.findIndex((s) => s.id === selected.id);
+ if (idx < 0 || idx === sorted.length - 1) return;
+ const [item] = sorted.splice(idx, 1);
+ sorted.push(item);
+ sorted.forEach((s, i) => { s.z_index = i; });
+ pushHistory();
+ updateSelectionMenu();
+ requestDraw();
+ }
+
+ function sendSelectedToBack() {
+ if (readOnly) return;
+ if (type !== "stickers") return;
+ const selected = getSelected();
+ if (!selected) return;
+ normalizeStickerZIndices();
+ const sorted = [...state.stickers].sort((a, b) => Number(a.z_index || 0) - Number(b.z_index || 0));
+ const idx = sorted.findIndex((s) => s.id === selected.id);
+ if (idx <= 0) return;
+ const [item] = sorted.splice(idx, 1);
+ sorted.unshift(item);
+ sorted.forEach((s, i) => { s.z_index = i; });
+ pushHistory();
+ updateSelectionMenu();
+ requestDraw();
+ }
+
+ function apply() {
+ if (readOnly) return;
+ state.projection_model = "pinhole_rectilinear";
+ state.alpha_mode = "straight";
+ if (presetWidget) state.output_preset = parseOutputPresetValue(presetWidget.value, Number(state.output_preset || 2048));
+ if (bgWidget) state.bg_color = String(bgWidget.value || state.bg_color || "#00ff00");
+ commitState();
+ node.setDirtyCanvas(true, true);
+ }
+
+ function commitState() {
+ if (readOnly) return;
+ const text = JSON.stringify(state);
+ if (stateWidget) {
+ stateWidget.value = text;
+ stateWidget.callback?.(text);
+ }
+ }
+ function persistUiSettings() {
+ state.ui_settings = saveSharedUiSettings(state.ui_settings);
+ if (!readOnly) {
+ commitState();
+ }
+ }
+ function commitAndRefreshNode() {
+ if (readOnly) return;
+ commitState();
+ node.setDirtyCanvas?.(true, true);
+ }
+
+ function screenPos(evt) {
+ const r = canvas.getBoundingClientRect();
+ return {
+ x: ((evt.clientX - r.left) / r.width) * canvas.width,
+ y: ((evt.clientY - r.top) / r.height) * canvas.height,
+ };
+ }
+
+ function hitObjectAt(p) {
+ const items = [...getList()].sort((a, b) => Number(b.z_index || 0) - Number(a.z_index || 0));
+ for (const item of items) {
+ const g = objectGeom(item);
+ if (!g.visible) continue;
+ if (pointInPoly(p, g.corners)) return { item, geom: g };
+ }
+ return null;
+ }
+
+ function handleHit(geom, p) {
+ if (!geom || !geom.visible) return { kind: "none", cursor: editor.mode === "pano" ? "grab" : "default" };
+ if (type === "cutout" && Array.isArray(geom.edgeMidpoints)) {
+ const mid = geom.edgeMidpoints.find((m) => dist2(m, p) <= 13 * 13);
+ if (mid) {
+ const horiz = (mid.edge === "left" || mid.edge === "right");
+ return { kind: horiz ? "scale_x" : "scale_y", cursor: horiz ? "ew-resize" : "ns-resize", edge: mid.edge, mid };
+ }
+ }
+ const cornerIdx = geom.corners.findIndex((c) => dist2(c, p) <= 11 * 11);
+ if (cornerIdx >= 0) {
+ const c = geom.corners[cornerIdx];
+ const vx = c.x - geom.center.x;
+ const vy = c.y - geom.center.y;
+ const cursor = (vx * vy) >= 0 ? "nwse-resize" : "nesw-resize";
+ return { kind: "scale", cornerIdx, cursor };
+ }
+ if (dist2(geom.rotateHandle, p) <= 12 * 12) return { kind: "rotate", cursor: "grab" };
+ if (pointInPoly(p, geom.corners)) return { kind: "move", cursor: "move" };
+ return { kind: "none", cursor: editor.mode === "pano" ? "grab" : "default" };
+ }
+
+ function updateCursor(p) {
+ if (editor.interaction) {
+ if (editor.interaction.kind === "view") canvas.style.cursor = "grabbing";
+ else if (editor.interaction.kind === "move") canvas.style.cursor = "move";
+ else if (editor.interaction.kind === "scale" || editor.interaction.kind === "scale_x" || editor.interaction.kind === "scale_y") canvas.style.cursor = editor.interaction.cursor || "nwse-resize";
+ else if (editor.interaction.kind === "rotate") canvas.style.cursor = "grabbing";
+ else canvas.style.cursor = "default";
+ return;
+ }
+ const selected = getSelected();
+ const geom = selected ? objectGeom(selected) : null;
+ const h = handleHit(geom, p);
+ canvas.style.cursor = h.cursor;
+ }
+
+ function updateSelectionMenu() {
+ if (!selectionMenu) return;
+ const selected = getSelected();
+ if (!selected || editor.interaction) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ const menuMode = type === "stickers"
+ ? `stickers:${isExternalSticker(selected) ? "external" : "normal"}`
+ : `cutout:${editor.cutoutAspectOpen ? "open" : "closed"}`;
+ if (editor.menuMode !== menuMode) {
+ if (type === "stickers") {
+ selectionMenu.innerHTML = `
+
+
+ ${isExternalSticker(selected) ? "" : ``}
+ ${isExternalSticker(selected) ? `` : ""}
+ ${isExternalSticker(selected)
+ ? ``
+ : ``}
+ `;
+ } else {
+ const activeAspect = getCutoutAspectLabel(selected);
+ selectionMenu.innerHTML = `
+
+
+
+ `;
+ }
+ editor.menuMode = menuMode;
+ editor.menuSize.measured = false;
+ installTooltipHandlers(selectionMenu);
+ }
+ if (type === "stickers" && isExternalSticker(selected)) {
+ const backBtn = selectionMenu.querySelector("[data-action='back-initial']");
+ if (backBtn) {
+ const enabled = canRestoreSelectedToInitial();
+ backBtn.disabled = !enabled;
+ backBtn.setAttribute("aria-disabled", enabled ? "false" : "true");
+ backBtn.setAttribute("data-tip", enabled ? "Back to initial position" : "Already at initial position");
+ }
+ const toggleBtn = selectionMenu.querySelector("[data-action='toggle-visible']");
+ if (toggleBtn) {
+ const hidden = isStickerHidden(selected);
+ toggleBtn.innerHTML = hidden ? ICON.eye : ICON.eye_dashed;
+ toggleBtn.setAttribute("aria-label", hidden ? "Show" : "Hide");
+ toggleBtn.setAttribute("data-tip", hidden ? "Show input image" : "Hide input image");
+ }
+ }
+ const geom = objectGeom(selected);
+ if (!geom?.visible) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ const poly = geom.corners;
+ const xs = poly.map((p) => p.x);
+ const ys = poly.map((p) => p.y);
+ const minX = Math.min(...xs);
+ const maxX = Math.max(...xs);
+ const maxY = Math.max(...ys);
+ const prevDisplay = selectionMenu.style.display;
+ const prevVisibility = selectionMenu.style.visibility;
+ selectionMenu.style.display = "flex";
+ selectionMenu.style.visibility = "hidden";
+ const rect = selectionMenu.getBoundingClientRect();
+ const measuredW = Math.round(Number(rect?.width || 0)) || selectionMenu.offsetWidth || editor.menuSize.w || 220;
+ const measuredH = Math.round(Number(rect?.height || 0)) || selectionMenu.offsetHeight || editor.menuSize.h || 40;
+ editor.menuSize.w = Number.isFinite(measuredW) && measuredW > 0 ? measuredW : 220;
+ editor.menuSize.h = Number.isFinite(measuredH) && measuredH > 0 ? measuredH : 40;
+ selectionMenu.style.display = prevDisplay;
+ selectionMenu.style.visibility = prevVisibility;
+ editor.menuSize.measured = true;
+ const menuW = editor.menuSize.w;
+ const menuH = editor.menuSize.h;
+ const pad = 14;
+ selectionMenu.style.display = "flex";
+ let x = (minX + maxX) * 0.5 - menuW * 0.5;
+ let y = maxY + 22;
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ x = clamp(x, pad, canvas.width - menuW - pad);
+ if (y + menuH > canvas.height - pad) {
+ selectionMenu.style.display = "none";
+ return;
+ }
+ selectionMenu.style.left = `${x}px`;
+ selectionMenu.style.top = `${y}px`;
+ }
+
+ function hideTooltip() {
+ if (!tooltipEl) return;
+ if (tooltip.timer) {
+ clearTimeout(tooltip.timer);
+ tooltip.timer = 0;
+ }
+ tooltip.target = null;
+ tooltipEl.classList.remove("show");
+ }
+
+ function showTooltipFor(el) {
+ if (!tooltipEl || !el || !el.isConnected) return;
+ const text = String(el.getAttribute("data-tip") || "").trim();
+ if (!text) return;
+ tooltipEl.textContent = text;
+ const hostRect = stageWrap.getBoundingClientRect();
+ const rect = el.getBoundingClientRect();
+ const pad = 8;
+ const mw = tooltipEl.offsetWidth || 100;
+ const mh = tooltipEl.offsetHeight || 24;
+ let x = rect.left - hostRect.left + rect.width * 0.5 - mw * 0.5;
+ let y = rect.top - hostRect.top - mh - 8;
+ x = clamp(x, pad, Math.max(pad, hostRect.width - mw - pad));
+ y = Math.max(pad, y);
+ tooltipEl.style.left = `${x}px`;
+ tooltipEl.style.top = `${y}px`;
+ tooltipEl.classList.add("show");
+ }
+
+ function installTooltipHandlers(scope) {
+ scope.querySelectorAll("[data-tip]").forEach((el) => {
+ if (el.__panoTipBound) return;
+ el.__panoTipBound = true;
+ el.addEventListener("pointerenter", () => {
+ tooltip.target = el;
+ if (tooltip.timer) clearTimeout(tooltip.timer);
+ tooltip.timer = window.setTimeout(() => {
+ if (tooltip.target === el) showTooltipFor(el);
+ }, 1000);
+ });
+ el.addEventListener("pointerleave", () => {
+ if (tooltip.target === el) tooltip.target = null;
+ hideTooltip();
+ });
+ el.addEventListener("pointerdown", hideTooltip);
+ });
+ }
+
+ const viewController = createPanoInteractionController({
+ getView: () => ({ yaw: editor.viewYaw, pitch: editor.viewPitch, fov: editor.viewFov }),
+ setView: (next) => {
+ editor.viewYaw = wrapYaw(Number(next.yaw || 0));
+ editor.viewPitch = clamp(Number(next.pitch || 0), -89.9, 89.9);
+ editor.viewFov = clamp(Number(next.fov || editor.viewFov || 100), 35, 140);
+ },
+ getInvert: () => ({
+ x: state.ui_settings?.invert_view_x ? -1 : 1,
+ y: state.ui_settings?.invert_view_y ? -1 : 1,
+ }),
+ getUnwrapRect,
+ onInteraction: () => {
+ runtime.dirty = true;
+ },
+ });
+
+ canvas.onpointerdown = (e) => {
+ const p = screenPos(e);
+ editor.viewTween = null;
+ viewController.state.inertia.active = false;
+ viewController.state.inertia.vx = 0;
+ viewController.state.inertia.vy = 0;
+ if (e.button === 1) {
+ e.preventDefault();
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (e.button !== 0) return;
+ if (readOnly) {
+ if (editor.mode === "pano") {
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ }
+ return;
+ }
+
+ const selected = getSelected();
+ const selGeom = selected ? objectGeom(selected) : null;
+
+ if (selected && selGeom?.visible) {
+ const h = handleHit(selGeom, p);
+ if (h.kind === "scale") {
+ editor.interaction = {
+ kind: "scale",
+ item: selected,
+ center: selGeom.center,
+ startDist: Math.max(1, Math.hypot(p.x - selGeom.center.x, p.y - selGeom.center.y)),
+ startHFOV: Number(selected.hFOV_deg || 20),
+ startVFOV: Number(selected.vFOV_deg || 20),
+ cursor: h.cursor,
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "scale_x" || h.kind === "scale_y") {
+ editor.interaction = {
+ kind: h.kind,
+ item: selected,
+ center: selGeom.center,
+ startDist: Math.max(1, Math.hypot(p.x - selGeom.center.x, p.y - selGeom.center.y)),
+ startHFOV: Number(selected.hFOV_deg || 20),
+ startVFOV: Number(selected.vFOV_deg || 20),
+ cursor: h.cursor,
+ edge: h.edge,
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "rotate") {
+ editor.interaction = {
+ kind: "rotate",
+ item: selected,
+ center: selGeom.center,
+ startRot: Number(selected.rot_deg || selected.roll_deg || 0),
+ startAng: Math.atan2(p.y - selGeom.center.y, p.x - selGeom.center.x),
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ if (h.kind === "move") {
+ editor.interaction = {
+ kind: "move",
+ item: selected,
+ offset: { x: p.x - selGeom.center.x, y: p.y - selGeom.center.y },
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+ }
+
+ const hit = hitObjectAt(p);
+ if (hit) {
+ const isNewSelection = editor.selectedId !== hit.item.id;
+ if (isNewSelection && editor.selectedId) {
+ pushHistory();
+ commitState();
+ }
+ editor.selectedId = hit.item.id;
+ if (type === "cutout" && isNewSelection) editor.cutoutAspectOpen = false;
+ if (type === "stickers") state.active.selected_sticker_id = hit.item.id;
+ else state.active.selected_shot_id = hit.item.id;
+ if (isNewSelection) updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ if (isNewSelection) {
+ updateCursor(p);
+ return;
+ }
+ editor.interaction = {
+ kind: "move",
+ item: hit.item,
+ offset: { x: p.x - hit.geom.center.x, y: p.y - hit.geom.center.y },
+ };
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ return;
+ }
+
+ const selectedBeforeClear = getSelected();
+ if (selectedBeforeClear) {
+ editor.panelLastValues = {
+ yaw_deg: Number(selectedBeforeClear.yaw_deg || 0),
+ pitch_deg: Number(selectedBeforeClear.pitch_deg || 0),
+ hFOV_deg: Number(selectedBeforeClear.hFOV_deg || (type === "stickers" ? 30 : 90)),
+ vFOV_deg: Number(selectedBeforeClear.vFOV_deg || (type === "stickers" ? 30 : 60)),
+ rot_deg: Number(selectedBeforeClear.rot_deg || 0),
+ roll_deg: Number(selectedBeforeClear.roll_deg || 0),
+ out_w: Number(selectedBeforeClear.out_w || 1024),
+ out_h: Number(selectedBeforeClear.out_h || 1024),
+ aspect_id: getCutoutAspectLabel(selectedBeforeClear),
+ };
+ }
+ const hadSelection = !!editor.selectedId;
+ if (hadSelection) {
+ pushHistory();
+ commitState();
+ }
+ editor.selectedId = null;
+ if (type === "cutout") editor.cutoutAspectOpen = false;
+ if (type === "stickers") state.active.selected_sticker_id = null;
+ else state.active.selected_shot_id = null;
+ if (hadSelection) updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+
+ if (editor.mode === "pano") {
+ editor.interaction = { kind: "view", last: p, lastTs: performance.now() };
+ viewController.startDrag(p.x, p.y, e.pointerId, performance.now());
+ updateCursor(p);
+ canvas.setPointerCapture(e.pointerId);
+ }
+ };
+
+ canvas.onpointermove = (e) => {
+ if (!editor.interaction) return;
+ const p = screenPos(e);
+ updateCursor(p);
+ const it = editor.interaction;
+
+ if (it.kind === "view") {
+ const now = performance.now();
+ viewController.moveDrag(p.x, p.y, editor.mode === "unwrap" ? "unwrap" : "pano", now);
+ it.lastTs = now;
+ it.last = p;
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "move") {
+ const tx = p.x - it.offset.x;
+ const ty = p.y - it.offset.y;
+ if (editor.mode === "unwrap") {
+ const r = getUnwrapRect();
+ const nx = clamp((tx - r.x) / Math.max(r.w, 1), 0, 1);
+ const ny = clamp((ty - r.y) / Math.max(r.h, 1), 0, 1);
+ it.item.yaw_deg = wrapYaw(nx * 360 - 180);
+ it.item.pitch_deg = clamp(90 - ny * 180, -90, 90);
+ } else {
+ const dir = screenToWorldDir(tx, ty);
+ const yp = dirToYawPitch(dir);
+ it.item.yaw_deg = yp.yaw;
+ it.item.pitch_deg = yp.pitch;
+ }
+ requestDraw();
+ return;
+ }
+
+
+ if (it.kind === "scale") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.hFOV_deg = clamp(it.startHFOV * ratio, 1, 179);
+ it.item.vFOV_deg = clamp(it.startVFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "scale_x") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.hFOV_deg = clamp(it.startHFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "scale_y") {
+ const d = Math.max(1, Math.hypot(p.x - it.center.x, p.y - it.center.y));
+ const ratio = d / it.startDist;
+ it.item.vFOV_deg = clamp(it.startVFOV * ratio, 1, 179);
+ requestDraw();
+ return;
+ }
+
+ if (it.kind === "rotate") {
+ const a = Math.atan2(p.y - it.center.y, p.x - it.center.x);
+ let delta = (a - it.startAng) * RAD2DEG;
+ let out = it.startRot - delta;
+ if (e.shiftKey) out = Math.round(out / 45) * 45;
+ const key = type === "stickers" ? "rot_deg" : "roll_deg";
+ it.item[key] = out;
+ requestDraw();
+ }
+ };
+
+ canvas.onpointerup = () => {
+ const ended = editor.interaction;
+ if (editor.interaction && editor.interaction.kind !== "view") {
+ pushHistory();
+ commitState();
+ node.setDirtyCanvas(true, true);
+ syncSidePanelControls();
+ editor.hqFrames = 1;
+ updateSelectionMenu();
+ requestDraw();
+ }
+ editor.interaction = null;
+ if (ended && ended.kind === "view") {
+ viewController.endDrag(performance.now());
+ }
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ updateSelectionMenu();
+ requestDraw();
+ };
+
+ canvas.onpointercancel = () => {
+ if (editor.interaction?.kind === "view") {
+ viewController.endDrag(performance.now());
+ }
+ editor.interaction = null;
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ };
+
+ canvas.onauxclick = (e) => {
+ if (e.button === 1) e.preventDefault();
+ };
+
+ canvas.onmousemove = (e) => {
+ if (editor.interaction) return;
+ const p = screenPos(e);
+ updateCursor(p);
+ };
+
+ canvas.onwheel = (e) => {
+ if (editor.mode !== "pano") return;
+ if (viewController.applyWheelEvent(e)) requestDraw();
+ e.preventDefault();
+ };
+
+ canvas.ondragover = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ e.preventDefault();
+ setDropCue(true);
+ };
+
+ canvas.ondrop = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ e.preventDefault();
+ e.stopPropagation();
+ dragCue.depth = 0;
+ setDropCue(false);
+ const files = Array.from(e.dataTransfer?.files || []);
+ const file = files.find((f) => isImageFile(f));
+ if (!file) return;
+ void addImageStickerFromFile(file);
+ };
+
+ const onWindowDragEnter = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragHasImageFiles(e)) return;
+ dragCue.depth += 1;
+ setDropCue(true);
+ e.preventDefault();
+ };
+ const onWindowDragOver = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragCue.active && dragHasImageFiles(e)) setDropCue(true);
+ if (dragCue.active) e.preventDefault();
+ };
+ const onWindowDragLeave = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ if (!dragCue.active) return;
+ dragCue.depth = Math.max(0, dragCue.depth - 1);
+ const leftWindow = (Number(e.clientX) <= 0 && Number(e.clientY) <= 0);
+ if (dragCue.depth === 0 || leftWindow) setDropCue(false);
+ };
+ const onWindowDrop = (e) => {
+ if (type !== "stickers" || readOnly) return;
+ dragCue.depth = 0;
+ setDropCue(false);
+ if (dragHasImageFiles(e)) e.preventDefault();
+ };
+ window.addEventListener("dragenter", onWindowDragEnter, true);
+ window.addEventListener("dragover", onWindowDragOver, true);
+ window.addEventListener("dragleave", onWindowDragLeave, true);
+ window.addEventListener("drop", onWindowDrop, true);
+
+ viewBtns.forEach((btn) => {
+ btn.onclick = () => {
+ editor.mode = btn.dataset.view;
+ viewBtns.forEach((b) => b.setAttribute("aria-pressed", b === btn ? "true" : "false"));
+ if (viewToggle) viewToggle.setAttribute("data-selected", editor.mode === "unwrap" ? "unwrap" : "pano");
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ requestDraw();
+ };
+ });
+
+ const undoBtn = root.querySelector("[data-action='undo']");
+ if (undoBtn) {
+ undoBtn.onclick = () => {
+ if (readOnly) return;
+ restoreHistory(-1);
+ };
+ }
+ const redoBtn = root.querySelector("[data-action='redo']");
+ if (redoBtn) {
+ redoBtn.onclick = () => {
+ if (readOnly) return;
+ restoreHistory(1);
+ };
+ }
+ const addBtn = root.querySelector("[data-action='add']");
+ if (addBtn) {
+ addBtn.onclick = () => {
+ if (readOnly) return;
+ (type === "stickers" ? addImageSticker() : addCutoutFrame());
+ };
+ }
+ if (lookAtFrameBtn) {
+ lookAtFrameBtn.onclick = () => {
+ if (getList().length === 0) return;
+ const target = getSelected() || getList()[0];
+ if (!target) return;
+ editor.selectedId = target.id || null;
+ state.active.selected_shot_id = editor.selectedId;
+ const targetYaw = wrapYaw(Number(target.yaw_deg || 0));
+ const targetPitch = clamp(Number(target.pitch_deg || 0), -89.9, 89.9);
+ startViewTween(targetYaw, targetPitch, editor.viewFov);
+ updateSidePanel();
+ updateSelectionMenu();
+ requestDraw();
+ };
+ }
+ const clearBtn = root.querySelector("[data-action='clear']");
+ if (clearBtn) {
+ clearBtn.onclick = () => {
+ if (readOnly) return;
+ clearAll();
+ };
+ }
+ const applyBtn = root.querySelector("[data-action='save']");
+ if (applyBtn) applyBtn.onclick = () => {
+ if (readOnly) return;
+ apply();
+ };
+ root.querySelector("[data-action='reset-view']").onclick = () => {
+ startViewTween(0, 0, 100, 180, 680);
+ };
+ const gridBtn = root.querySelector("[data-action='toggle-grid']");
+ const syncGridToggleButton = () => {
+ if (!gridBtn) return;
+ const visible = !!editor.showGrid;
+ gridBtn.innerHTML = visible ? ICON.eye : ICON.eye_dashed;
+ gridBtn.setAttribute("aria-pressed", visible ? "true" : "false");
+ gridBtn.setAttribute("aria-label", visible ? "Hide Grid" : "Show Grid");
+ gridBtn.setAttribute("data-tip", visible ? "Hide grid" : "Show grid");
+ };
+ if (gridBtn) {
+ syncGridToggleButton();
+ gridBtn.onclick = () => {
+ editor.showGrid = !editor.showGrid;
+ setNodeGridVisibility(node?.id, editor.showGrid);
+ syncGridToggleButton();
+ requestDraw();
+ };
+ }
+ const syncFullscreenButton = () => {
+ if (!fullscreenBtn) return;
+ const active = !!editor.fullscreen;
+ fullscreenBtn.innerHTML = active ? ICON.fullscreen_close : ICON.fullscreen;
+ fullscreenBtn.setAttribute("aria-label", active ? "Exit Fullscreen" : "Fullscreen");
+ fullscreenBtn.setAttribute("data-tip", active ? "Exit fullscreen" : "Fullscreen");
+ };
+ const setFullscreenState = (active) => {
+ const on = !!active;
+ if (editor.fullscreen === on) return;
+ editor.fullscreen = on;
+ root.classList.toggle("pano-modal-fullscreen", on);
+ if (on) {
+ editor.fullscreenPrevShowGrid = !!editor.showGrid;
+ editor.showGrid = false;
+ } else if (editor.fullscreenPrevShowGrid !== null) {
+ editor.showGrid = !!editor.fullscreenPrevShowGrid;
+ editor.fullscreenPrevShowGrid = null;
+ }
+ syncGridToggleButton();
+ syncFullscreenButton();
+ requestDraw();
+ };
+ const fullscreenTarget = embedded ? root : overlay;
+ const isOverlayFullscreen = () => document.fullscreenElement === fullscreenTarget;
+ const toggleFullscreen = async () => {
+ try {
+ if (!document.fullscreenEnabled) {
+ setFullscreenState(!editor.fullscreen);
+ return;
+ }
+ if (!isOverlayFullscreen()) {
+ await fullscreenTarget?.requestFullscreen?.();
+ } else {
+ await document.exitFullscreen?.();
+ }
+ } catch {
+ setFullscreenState(!editor.fullscreen);
+ }
+ };
+ const onFullscreenChange = () => {
+ if (document.fullscreenEnabled) {
+ setFullscreenState(isOverlayFullscreen());
+ }
+ };
+ document.addEventListener("fullscreenchange", onFullscreenChange);
+ if (fullscreenBtn) {
+ syncFullscreenButton();
+ fullscreenBtn.onclick = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ toggleFullscreen();
+ };
+ }
+ const syncOutputPreviewToggleButton = () => {
+ if (!outputPreviewToggleBtn) return;
+ const expanded = !!editor.outputPreviewExpanded;
+ outputPreviewToggleBtn.innerHTML = expanded ? ICON.fullscreen_close : ICON.fullscreen;
+ outputPreviewToggleBtn.setAttribute("aria-label", expanded ? "Reduce Preview" : "Expand Preview");
+ outputPreviewToggleBtn.setAttribute("data-tip", expanded ? "Reduce preview" : "Expand preview");
+ };
+ if (outputPreviewToggleBtn) {
+ syncOutputPreviewToggleButton();
+ outputPreviewToggleBtn.onclick = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ const nextExpanded = !editor.outputPreviewExpanded;
+ editor.outputPreviewExpanded = nextExpanded;
+ editor.outputPreviewAnimFrom = editor.outputPreviewAnim;
+ editor.outputPreviewAnimTo = nextExpanded ? 1 : 0;
+ editor.outputPreviewAnimStartTs = performance.now();
+ syncOutputPreviewToggleButton();
+ requestDraw();
+ };
+ }
+ selectionMenu.addEventListener("click", (ev) => {
+ const target = ev.target.closest("[data-action]");
+ if (!target) return;
+ const action = target.getAttribute("data-action");
+ if (readOnly) return;
+ if (action === "aspect") {
+ editor.cutoutAspectOpen = !editor.cutoutAspectOpen;
+ editor.menuSize.measured = false;
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "aspect-set") {
+ const selected = getSelected();
+ if (!selected) return;
+ const aspect = String(target.getAttribute("data-aspect") || "1:1");
+ applyCutoutAspect(selected, aspect);
+ editor.cutoutAspectOpen = false;
+ editor.menuSize.measured = false;
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "rotate-90") {
+ const selected = getSelected();
+ if (!selected) return;
+ rotateCutoutAspect90(selected);
+ editor.cutoutAspectOpen = false;
+ editor.menuSize.measured = false;
+ syncSidePanelControls();
+ pushHistory();
+ commitAndRefreshNode();
+ updateSelectionMenu();
+ requestDraw();
+ return;
+ }
+ if (action === "bring-front") {
+ bringSelectedToFront();
+ return;
+ }
+ if (action === "send-back") {
+ sendSelectedToBack();
+ return;
+ }
+ if (action === "duplicate") {
+ duplicateSelected();
+ return;
+ }
+ if (action === "back-initial") {
+ restoreSelectedToInitialPose();
+ return;
+ }
+ if (action === "toggle-visible") {
+ deleteSelected();
+ return;
+ }
+ if (action === "delete") {
+ deleteSelected();
+ return;
+ }
+ requestDraw();
+ });
+
+ const modalPrevOnExecuted = node.onExecuted;
+ const modalPrevOnConnectionsChange = node.onConnectionsChange;
+ let modalOnExecuted = null;
+ let modalOnConnectionsChange = null;
+ let modalExternalStickerSync = null;
+ if (!readOnly && type === "stickers") {
+ modalExternalStickerSync = (reason = "sync") => {
+ reconcileExternalStickerFromInputs(reason);
+ };
+ node.__panoExternalStickerSync = modalExternalStickerSync;
+ modalOnExecuted = function onPanoEditorExecuted(...args) {
+ if (typeof modalPrevOnExecuted === "function") {
+ modalPrevOnExecuted.apply(this, args);
+ }
+ this.__panoExternalStickerSync?.("executed");
+ };
+ node.onExecuted = modalOnExecuted;
+ modalOnConnectionsChange = function onPanoEditorConnectionsChange(...args) {
+ if (typeof modalPrevOnConnectionsChange === "function") {
+ modalPrevOnConnectionsChange.apply(this, args);
+ }
+ this.__panoExternalStickerSync?.("connections");
+ };
+ node.onConnectionsChange = modalOnConnectionsChange;
+ }
+
+ const closeEditor = () => {
+ if (document.fullscreenElement === fullscreenTarget) {
+ document.exitFullscreen?.().catch(() => { });
+ }
+ document.removeEventListener("fullscreenchange", onFullscreenChange);
+ node.__panoLiveStateOverride = null;
+ node.__panoDomPreview?.requestDraw?.();
+ node.graph?.setDirtyCanvas?.(true, true);
+ app?.canvas?.setDirty?.(true, true);
+ hideTooltip();
+ stopRenderLoop();
+ setDropCue(false);
+ window.removeEventListener("keydown", onEscClose, true);
+ window.removeEventListener("keydown", onDeleteKey, true);
+ window.removeEventListener("keydown", onUndoRedoKey, true);
+ window.removeEventListener("dragenter", onWindowDragEnter, true);
+ window.removeEventListener("dragover", onWindowDragOver, true);
+ window.removeEventListener("dragleave", onWindowDragLeave, true);
+ window.removeEventListener("drop", onWindowDrop, true);
+ if (!readOnly && type === "stickers") {
+ if (node.onExecuted === modalOnExecuted) node.onExecuted = modalPrevOnExecuted;
+ if (node.onConnectionsChange === modalOnConnectionsChange) node.onConnectionsChange = modalPrevOnConnectionsChange;
+ if (node.__panoExternalStickerSync === modalExternalStickerSync) node.__panoExternalStickerSync = null;
+ }
+ if (!embedded) {
+ overlay?.remove();
+ } else if (destroyOnClose) {
+ root.remove();
+ }
+ options?.onClose?.({ node, type, root, overlay, embedded });
+ };
+ const onEscClose = (ev) => {
+ if (ev.key !== "Escape") return;
+ if (editor.fullscreen && document.fullscreenElement === fullscreenTarget) {
+ document.exitFullscreen?.().catch(() => { });
+ return;
+ }
+ if (editor.fullscreen) {
+ setFullscreenState(false);
+ return;
+ }
+ closeEditor();
+ };
+ const onDeleteKey = (ev) => {
+ const key = String(ev.key || "");
+ const code = String(ev.code || "");
+ const keyCode = Number(ev.keyCode || 0);
+ const isDeleteKey = key === "Delete" || code === "Delete" || keyCode === 46;
+ const isBackspaceKey = key === "Backspace" || code === "Backspace" || keyCode === 8;
+ if (!isDeleteKey && !isBackspaceKey) return;
+ const t = ev.target;
+ const tag = (t?.tagName || "").toUpperCase();
+ if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return;
+ const selected = getSelected();
+ if (!selected) return;
+ deleteSelected();
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ };
+ const onUndoRedoKey = (ev) => {
+ if (readOnly) return;
+ if (!ev.ctrlKey && !ev.metaKey) return;
+ const key = String(ev.key || "").toLowerCase();
+ const code = String(ev.code || "");
+ if (key !== "z" && code !== "KeyZ") return;
+ const t = ev.target;
+ const tag = (t?.tagName || "").toUpperCase();
+ if (tag === "INPUT" || tag === "TEXTAREA" || t?.isContentEditable) return;
+ restoreHistory(ev.shiftKey ? 1 : -1);
+ ev.preventDefault();
+ ev.stopImmediatePropagation();
+ };
+ window.addEventListener("keydown", onEscClose, true);
+ window.addEventListener("keydown", onDeleteKey, true);
+ window.addEventListener("keydown", onUndoRedoKey, true);
+ if (overlay) {
+ overlay.addEventListener("pointerdown", (ev) => {
+ if (ev.target === overlay) closeEditor();
+ });
+ }
+
+ installTooltipHandlers(root);
+ applyInitialCutoutFocus();
+ if (!readOnly && type === "stickers") {
+ reconcileExternalStickerFromInputs("open");
+ }
+ void migrateLegacyEmbeddedAssets();
+ pushHistory();
+ updateSidePanel();
+ syncLookAtFrameButtonState();
+ syncCanvasSize();
+ canvas.style.cursor = editor.mode === "pano" ? "grab" : "default";
+ requestDraw();
+ runtime.rafId = requestAnimationFrame(tick);
+ return { root, overlay, close: closeEditor, state, editor, requestDraw };
+}
+
+function createHostedWidget(name, value, onChange = null) {
+ return {
+ name,
+ value,
+ callback: onChange,
+ };
+}
+
+function parseInitialStateText(initialState) {
+ if (typeof initialState === "string") return initialState;
+ if (initialState && typeof initialState === "object") return JSON.stringify(initialState);
+ return "";
+}
+
+function createHostedNode(type, config = {}) {
+ const initialText = parseInitialStateText(config.initialState);
+ const parsed = parseState(initialText);
+ const outputPreset = Number(parsed?.output_preset || 2048);
+ const bgColor = String(parsed?.bg_color || "#00ff00");
+ const node = {
+ id: `embedded_${type}`,
+ comfyClass: type === "cutout" ? "TY360Roundtrip" : "TY360Preview",
+ title: config.title || (type === "cutout" ? "TY360 Roundtrip" : "TY360 Preview"),
+ inputs: [],
+ widgets: [],
+ graph: { setDirtyCanvas: () => {} },
+ setDirtyCanvas: () => {},
+ __panoHostedErpSrc: "",
+ };
+ const stateWidget = createHostedWidget(STATE_WIDGET, initialText, (text) => {
+ if (typeof config.onStateChange !== "function") return;
+ const next = parseState(String(text || "")) || parseState("{}") || {};
+ config.onStateChange(next, { source: "editor" });
+ });
+ node.widgets.push(createHostedWidget("output_preset", outputPreset));
+ node.widgets.push(createHostedWidget("bg_color", bgColor));
+ node.widgets.push(stateWidget);
+ return { node, stateWidget };
+}
+
+export function createEmbeddedEditor(hostOrNode, typeOrConfig, maybeOptions = {}) {
+ panoEditorDebug("embedded-editor.start", {
+ mode: typeof typeOrConfig === "string" ? "node" : "host",
+ type: typeof typeOrConfig === "string"
+ ? typeOrConfig
+ : String(typeOrConfig?.type || "stickers"),
+ });
+ if (typeof typeOrConfig === "string") {
+ return createNodeBackedEditor(hostOrNode, typeOrConfig, maybeOptions);
+ }
+
+ const host = hostOrNode instanceof HTMLElement ? hostOrNode : document.createElement("div");
+ const config = (typeOrConfig && typeof typeOrConfig === "object") ? typeOrConfig : {};
+ const type = String(config.type || "stickers") === "cutout" ? "cutout" : "stickers";
+ const { node, stateWidget } = createHostedNode(type, config);
+ const runtimeState = { erpSrc: "" };
+ const instance = createNodeBackedEditor(node, type, {
+ embedded: true,
+ mount: host,
+ readOnly: config.readOnly,
+ hideSidebar: config.hideSidebar,
+ hideFooter: config.hideFooter ?? true,
+ adapters: config.adapters,
+ onClose: config.onClose,
+ erpImageResolver: () => {
+ const custom = typeof config.erpImageResolver === "function" ? config.erpImageResolver() : null;
+ if (custom) return custom;
+ return runtimeState.erpSrc || null;
+ },
+ });
+
+ if (typeof config.onStateChange === "function") {
+ const initialParsed = parseState(String(stateWidget.value || "")) || parseState("{}") || {};
+ config.onStateChange(initialParsed, { source: "init" });
+ }
+ panoEditorDebug("embedded-editor.ready", {
+ type,
+ hasRoot: !!instance.root,
+ });
+
+ return {
+ root: instance.root,
+ getState() {
+ return JSON.parse(JSON.stringify(instance.state || {}));
+ },
+ setState(nextState) {
+ const text = parseInitialStateText(nextState);
+ stateWidget.value = text;
+ const parsed = parseState(
+ text,
+ Number(node.widgets.find((w) => w.name === "output_preset")?.value || 2048),
+ String(node.widgets.find((w) => w.name === "bg_color")?.value || "#00ff00"),
+ );
+ Object.keys(instance.state || {}).forEach((key) => {
+ delete instance.state[key];
+ });
+ Object.assign(instance.state, parsed);
+ stateWidget.callback?.(text);
+ instance.requestDraw();
+ },
+ setErpPreview(src) {
+ runtimeState.erpSrc = typeof src === "string" ? src : "";
+ instance.requestDraw();
+ },
+ requestDraw() {
+ instance.requestDraw();
+ },
+ destroy() {
+ instance.close();
+ },
+ };
+}
+
+function showEditor(node, type, options = {}) {
+ return createNodeBackedEditor(node, type, { ...options, embedded: false });
+}
+
+function installEditorButton(nodeType, nodeData, matchType, buttonText) {
+ const cleanupPreviewBindings = (node) => {
+ try { node.__panoDomRestore?.(); } catch { }
+ try { node.__panoLegacyRestore?.(); } catch { }
+ node.__panoDomPreview = null;
+ node.__panoLegacyPreviewHooked = false;
+ node.__panoPreviewHooked = false;
+ node.__panoPreviewAttached = false;
+ node.__panoPreviewMountKey = null;
+ };
+
+ function installOrUpdate(node) {
+ const mountKey = `editor_btn|${matchType}`;
+ const alreadyAttached = node.__panoPreviewAttached === true && node.__panoPreviewMountKey === mountKey;
+
+ // Avoid redundant cleanup/re-attach if already attached with the same key
+ if (alreadyAttached) return;
+
+ cleanupPreviewBindings(node);
+ hideWidget(node, STATE_WIDGET);
+
+ const sw = getWidget(node, STATE_WIDGET);
+ if (sw && !sw.__panoPreviewPatchedCb) {
+ sw.__panoPreviewPatchedCb = true;
+ const prevCb = sw.callback;
+ sw.callback = (v) => {
+ const r = prevCb ? prevCb(v) : undefined;
+ // Only trigger soft repaint, avoid forcing a full workflow persistence on every move if storage is tight
+ node.setDirtyCanvas?.(true, false);
+ return r;
+ };
+ }
+ const bg = getWidget(node, "bg_color");
+ if (bg && (bg.value == null || String(bg.value).trim() === "" || String(bg.value).toLowerCase() === "#000000")) {
+ bg.value = "#00ff00";
+ bg.callback?.("#00ff00");
+ }
+
+ if (matchType === "PanoramaStickers") {
+ ensureActionButtonWidget(node, buttonText, () => showEditor(node, "stickers"));
+ if (ENABLE_STICKERS_NODE_PREVIEW) {
+ attachStickersNodePreview(node, {
+ enabled: true,
+ buttonText,
+ onOpen: () => showEditor(node, "stickers"),
+ });
+ // Respect user-sized nodes; initialize only when preview is enabled and size is invalid.
+ if (!Array.isArray(node.size) || node.size[0] < 10 || node.size[1] < 10) {
+ node.size = [360, 260];
+ }
+ } else {
+ // Without node preview, let LiteGraph size the node from widgets only.
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ return;
+ }
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ return;
+ }
+
+ ensureActionButtonWidget(node, buttonText, () => showEditor(node, "cutout"));
+ attachCutoutPreview(node, {
+ buttonText,
+ onOpen: () => showEditor(node, "cutout"),
+ });
+
+ if (!Array.isArray(node.size) || node.size[0] < 10 || node.size[1] < 10) {
+ node.size = [360, 260];
+ }
+
+ node.__panoPreviewAttached = true;
+ node.__panoPreviewMountKey = mountKey;
+ }
+
+ const onNodeCreated = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
+ installOrUpdate(this);
+ return r;
+ };
+
+ const onConfigure = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function () {
+ const r = onConfigure ? onConfigure.apply(this, arguments) : undefined;
+ if (this.widgets) installOrUpdate(this);
+ return r;
+ };
+
+ const onAdded = nodeType.prototype.onAdded;
+ nodeType.prototype.onAdded = function () {
+ const r = onAdded ? onAdded.apply(this, arguments) : undefined;
+ if (this.widgets) installOrUpdate(this);
+ return r;
+ };
+}
+
+function installStandalonePreviewNode(nodeType) {
+ panoEditorDebug("install.standalone_preview_node", {
+ nodeTypeTitle: String(nodeType?.title || ""),
+ nodeTypeComfyClass: String(nodeType?.comfyClass || nodeType?.prototype?.comfyClass || ""),
+ prototypeSize: Array.isArray(nodeType?.prototype?.size) ? [...nodeType.prototype.size] : null,
+ });
+ if (!Array.isArray(nodeType?.prototype?.size) || nodeType.prototype.size[0] < 100 || nodeType.prototype.size[1] < 100) {
+ nodeType.prototype.size = [360, 260];
+ }
+}
+
+function installStandalonePreviewInstance(node) {
+ if (!node) return;
+ if (node.__panoStandaloneInstallDone) return;
+ if (node.__panoStandaloneInstallProbeActive) return;
+ node.__panoStandaloneInstallProbeActive = true;
+
+ const tryInstall = () => {
+ const nodeId = Number(node?.id ?? -1);
+ const ready = nodeId >= 0 && !!node?.graph;
+ const tries = Number(node.__panoStandaloneInstallProbeTries || 0) + 1;
+ node.__panoStandaloneInstallProbeTries = tries;
+
+ panoEditorDebug("install.standalone_preview_instance.probe", {
+ tries,
+ nodeId,
+ ready,
+ hasGraph: !!node?.graph,
+ size: Array.isArray(node?.size) ? [...node.size] : null,
+ });
+
+ if (!ready && tries < 40) {
+ requestAnimationFrame(tryInstall);
+ return;
+ }
+
+ panoEditorDebug("install.standalone_preview_instance", {
+ nodeId: node?.id ?? null,
+ nodeType: String(node?.comfyClass || node?.type || node?.title || ""),
+ size: Array.isArray(node?.size) ? [...node.size] : null,
+ deferred: !ready,
+ tries,
+ });
+ ensureActionButtonWidget(node, "Open Preview", () => showEditor(node, "stickers", { readOnly: true, hideSidebar: false }));
+ attachPreviewNode(node, {
+ buttonText: "Open Preview",
+ modalTitle: "TY360 Preview",
+ imageInputName: "erp_image",
+ onOpen: (n) => showEditor(n, "stickers", { readOnly: true, hideSidebar: false }),
+ });
+ node.__panoStandaloneInstallDone = true;
+ node.__panoStandaloneInstallProbeActive = false;
+ };
+
+ requestAnimationFrame(tryInstall);
+}
+
+// Legacy file intentionally does not register the active editor extension.
+// The modern WebGL-first path is registered from pano_editor.js only.
diff --git a/web/pano_gl_renderer.js b/web/pano_gl_renderer.js
new file mode 100644
index 0000000..8781bc6
--- /dev/null
+++ b/web/pano_gl_renderer.js
@@ -0,0 +1,808 @@
+const DEG2RAD = Math.PI / 180;
+const TWO_PI = Math.PI * 2.0;
+const MAX_RENDER_SIDE = 2048;
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+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 len = Math.hypot(a.x, a.y, a.z) || 1e-8;
+ return vec3(a.x / len, a.y / len, a.z / len);
+}
+
+function yawPitchToDir(yawDeg, pitchDeg) {
+ const yaw = Number(yawDeg || 0) * DEG2RAD;
+ const pitch = Number(pitchDeg || 0) * 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);
+ let worldUp = vec3(0, 1, 0);
+ if (Math.abs(dot(fwd, worldUp)) > 0.999) worldUp = vec3(0, 0, 1);
+ let right = norm(cross(worldUp, fwd));
+ let up = norm(cross(fwd, right));
+ const rr = Number(rollDeg || 0) * 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));
+ right = norm(r2);
+ up = norm(u2);
+ return { fwd, right, up };
+}
+
+function lonLatToDir(u, v) {
+ const lon = (Number(u || 0) - 0.5) * TWO_PI;
+ const lat = (0.5 - Number(v || 0)) * Math.PI;
+ const cp = Math.cos(lat);
+ return vec3(cp * Math.sin(lon), Math.sin(lat), cp * Math.cos(lon));
+}
+
+function deriveViewport(width, height, dpr = 1) {
+ const srcWidth = Math.max(1, Math.round(Number(width || 1)));
+ const srcHeight = Math.max(1, Math.round(Number(height || 1)));
+ const sourceDpr = Math.max(1, Number(dpr || 1));
+ const maxSide = Math.max(512, MAX_RENDER_SIDE);
+ const scale = Math.min(1, maxSide / Math.max(srcWidth, srcHeight));
+ return {
+ sourceWidth: srcWidth,
+ sourceHeight: srcHeight,
+ sourceDpr,
+ renderScale: scale,
+ width: Math.max(1, Math.round(srcWidth * scale)),
+ height: Math.max(1, Math.round(srcHeight * scale)),
+ };
+}
+
+function compileShader(gl, type, source) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const log = gl.getShaderInfoLog(shader) || "shader compile failed";
+ gl.deleteShader(shader);
+ throw new Error(log);
+ }
+ return shader;
+}
+
+function createProgram(gl, vertexSource, fragmentSource) {
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
+ let fragmentShader;
+ try {
+ fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
+ } catch (e) {
+ gl.deleteShader(vertexShader);
+ throw e;
+ }
+ const program = gl.createProgram();
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+ gl.deleteShader(vertexShader);
+ gl.deleteShader(fragmentShader);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const log = gl.getProgramInfoLog(program) || "program link failed";
+ gl.deleteProgram(program);
+ throw new Error(log);
+ }
+ return program;
+}
+
+const QUAD_VERTEX_SHADER = `#version 300 es
+precision highp float;
+layout(location = 0) in vec2 a_position;
+out vec2 v_uv;
+void main() {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+ v_uv = vec2(a_position.x * 0.5 + 0.5, 1.0 - (a_position.y * 0.5 + 0.5));
+}`;
+
+const BACKGROUND_FRAGMENT_SHADER = `#version 300 es
+precision highp float;
+in vec2 v_uv;
+out vec4 outColor;
+
+uniform sampler2D u_background;
+uniform sampler2D u_paint;
+uniform sampler2D u_mask;
+uniform int u_mode;
+uniform float u_yaw;
+uniform float u_pitch;
+uniform float u_roll;
+uniform float u_hfov;
+uniform float u_vfov;
+uniform vec2 u_viewport;
+uniform float u_opacity;
+uniform float u_paintOpacity;
+uniform float u_maskOpacity;
+uniform int u_hasPaint;
+uniform int u_hasMask;
+uniform int u_showMaskTint;
+uniform vec3 u_maskTint;
+
+const float PI = 3.1415926535897932384626433832795;
+const float TWO_PI = 6.283185307179586476925286766559;
+
+vec3 rotateCameraForward(float yaw, float pitch) {
+ float cy = cos(yaw);
+ float sy = sin(yaw);
+ float cp = cos(pitch);
+ float sp = sin(pitch);
+ return vec3(cp * sy, sp, cp * cy);
+}
+
+mat3 cameraBasis(float yaw, float pitch, float roll) {
+ vec3 fwd = normalize(rotateCameraForward(yaw, pitch));
+ vec3 worldUp = vec3(0.0, 1.0, 0.0);
+ if (abs(dot(fwd, worldUp)) > 0.999) worldUp = vec3(0.0, 0.0, 1.0);
+ vec3 right = normalize(cross(worldUp, fwd));
+ vec3 up = normalize(cross(fwd, right));
+ float cr = cos(roll);
+ float sr = sin(roll);
+ vec3 r2 = normalize(right * cr + up * sr);
+ vec3 u2 = normalize(right * (-sr) + up * cr);
+ return mat3(r2, u2, fwd);
+}
+
+vec2 unwrapUv() {
+ return vec2(v_uv.x, clamp(v_uv.y, 0.0, 1.0));
+}
+
+vec2 projectCameraUv(float yaw, float pitch, float roll, float hfov, float vfov) {
+ mat3 basis = cameraBasis(yaw, pitch, roll);
+ float nx = (v_uv.x * 2.0 - 1.0) * tan(hfov * 0.5);
+ float ny = (1.0 - v_uv.y * 2.0) * tan(vfov * 0.5);
+ vec3 dir = normalize(basis[2] + basis[0] * nx + basis[1] * ny);
+ float lon = atan(dir.x, dir.z);
+ float lat = asin(clamp(dir.y, -1.0, 1.0));
+ return vec2(lon / TWO_PI + 0.5, clamp(0.5 - lat / PI, 0.0, 1.0));
+}
+
+void main() {
+ vec2 sampleUv = unwrapUv();
+ if (u_mode == 1 || u_mode == 2) {
+ sampleUv = projectCameraUv(u_yaw, u_pitch, u_roll, u_hfov, u_vfov);
+ }
+ vec4 bg = texture(u_background, sampleUv);
+ vec4 paint = u_hasPaint == 1 ? texture(u_paint, sampleUv) : vec4(0.0);
+ vec4 mask = u_hasMask == 1 ? texture(u_mask, sampleUv) : vec4(0.0);
+
+ float bgAlpha = clamp(bg.a * u_opacity, 0.0, 1.0);
+ vec3 premul = bg.rgb * bgAlpha;
+ float alpha = bgAlpha;
+ if (u_hasPaint == 1) {
+ float paintAlpha = clamp(paint.a * u_paintOpacity, 0.0, 1.0);
+ premul = paint.rgb * paintAlpha + premul * (1.0 - paintAlpha);
+ alpha = paintAlpha + alpha * (1.0 - paintAlpha);
+ }
+ if (u_hasMask == 1 && u_showMaskTint == 1) {
+ float maskAlpha = clamp(mask.a * u_maskOpacity, 0.0, 1.0);
+ premul = u_maskTint * maskAlpha + premul * (1.0 - maskAlpha);
+ alpha = maskAlpha + alpha * (1.0 - maskAlpha);
+ }
+ outColor = vec4(premul, alpha);
+}`;
+
+const STICKER_FRAGMENT_SHADER = `#version 300 es
+precision highp float;
+in vec2 v_uv;
+out vec4 outColor;
+
+uniform sampler2D u_texture;
+uniform int u_mode;
+
+uniform vec3 u_viewRight;
+uniform vec3 u_viewUp;
+uniform vec3 u_viewFwd;
+uniform float u_viewHfov;
+uniform float u_viewVfov;
+
+uniform vec3 u_stickerRight;
+uniform vec3 u_stickerUp;
+uniform vec3 u_stickerFwd;
+uniform float u_stickerTanX;
+uniform float u_stickerTanY;
+uniform vec4 u_crop;
+uniform float u_opacity;
+
+const float PI = 3.1415926535897932384626433832795;
+const float TWO_PI = 6.283185307179586476925286766559;
+
+vec3 dirFromUnwrapUv(vec2 uv) {
+ float lon = (uv.x - 0.5) * TWO_PI;
+ float lat = (0.5 - uv.y) * PI;
+ float cp = cos(lat);
+ return vec3(cp * sin(lon), sin(lat), cp * cos(lon));
+}
+
+vec3 dirFromProjectedView(vec2 uv) {
+ float nx = (uv.x * 2.0 - 1.0) * tan(u_viewHfov * 0.5);
+ float ny = (1.0 - uv.y * 2.0) * tan(u_viewVfov * 0.5);
+ return normalize(u_viewFwd + u_viewRight * nx + u_viewUp * ny);
+}
+
+void main() {
+ vec3 dir = (u_mode == 0) ? dirFromUnwrapUv(v_uv) : dirFromProjectedView(v_uv);
+ float dz = dot(dir, u_stickerFwd);
+ if (dz <= 1e-5) discard;
+
+ float px = dot(dir, u_stickerRight) / dz;
+ float py = dot(dir, u_stickerUp) / dz;
+ float fullU = px / max(u_stickerTanX, 1e-6) * 0.5 + 0.5;
+ float fullV = 0.5 - py / max(u_stickerTanY, 1e-6) * 0.5;
+
+ if (fullU < u_crop.x || fullU > u_crop.z || fullV < u_crop.y || fullV > u_crop.w) discard;
+
+ vec2 cropSize = max(u_crop.zw - u_crop.xy, vec2(1e-4));
+ vec2 texUv = vec2((fullU - u_crop.x) / cropSize.x, (fullV - u_crop.y) / cropSize.y);
+ vec4 texel = texture(u_texture, texUv);
+ float alpha = clamp(texel.a * u_opacity, 0.0, 1.0);
+ outColor = vec4(texel.rgb * alpha, alpha);
+}`;
+
+function getViewAngles(view, width, height) {
+ if (!view || view.mode === "unwrap") return null;
+ const hFovDeg = Number(view.mode === "cutout" ? view.hFovDeg || 90 : view.fovDeg || 100);
+ const vFovDeg = Number(view.mode === "cutout"
+ ? view.vFovDeg || 60
+ : (2 * Math.atan(Math.tan(clamp(hFovDeg, 1, 179) * DEG2RAD * 0.5) * (height / Math.max(width, 1)))) / DEG2RAD);
+ return {
+ yawDeg: Number(view.yawDeg || 0),
+ pitchDeg: Number(view.pitchDeg || 0),
+ rollDeg: Number(view.mode === "cutout" ? view.rollDeg || 0 : 0),
+ hFovDeg: clamp(hFovDeg, 1, 179),
+ vFovDeg: clamp(vFovDeg, 0.1, 179),
+ };
+}
+
+function buildStickerBasis(sticker) {
+ const basis = cameraBasis(
+ Number(sticker.yawDeg || 0),
+ Number(sticker.pitchDeg || 0),
+ Number(sticker.rollDeg || 0),
+ );
+ return {
+ right: basis.right,
+ up: basis.up,
+ fwd: basis.fwd,
+ tanX: Math.tan(clamp(Number(sticker.hFovDeg || 30), 0.1, 179) * 0.5 * DEG2RAD),
+ tanY: Math.tan(clamp(Number(sticker.vFovDeg || 30), 0.1, 179) * 0.5 * DEG2RAD),
+ crop: sticker.crop || { x0: 0, y0: 0, x1: 1, y1: 1 },
+ opacity: clamp(Number(sticker.opacity ?? 1), 0, 1),
+ };
+}
+
+export function createPanoGlRenderer() {
+ const surface = document.createElement("canvas");
+ let gl = null;
+ let backgroundProgram = null;
+ let stickerProgram = null;
+ let quadBuffer = null;
+ let backgroundTexture = null;
+ let paintTexture = null;
+ let maskTexture = null;
+ let backgroundRevision = null;
+ let paintRevision = null;
+ let maskRevision = null;
+ let initialized = false;
+ let viewport = deriveViewport(1, 1, 1);
+ let backgroundUniforms = null;
+ let stickerUniforms = null;
+ let uploadScratchCanvas = null;
+ let uploadScratchCtx = null;
+ const stickerTextureRegistry = new Map();
+ let textureMeta = {
+ background: { width: 0, height: 0 },
+ paint: { width: 0, height: 0 },
+ mask: { width: 0, height: 0 },
+ };
+
+ function createLayerTexture(wrapS = null, wrapT = null) {
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS ?? gl.REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrapT ?? gl.CLAMP_TO_EDGE);
+ return texture;
+ }
+
+ function init() {
+ if (initialized) return true;
+ try {
+ gl = surface.getContext("webgl2", {
+ alpha: true,
+ antialias: false,
+ depth: false,
+ stencil: false,
+ premultipliedAlpha: true,
+ preserveDrawingBuffer: false,
+ });
+ if (!gl) return false;
+ backgroundProgram = createProgram(gl, QUAD_VERTEX_SHADER, BACKGROUND_FRAGMENT_SHADER);
+ stickerProgram = createProgram(gl, QUAD_VERTEX_SHADER, STICKER_FRAGMENT_SHADER);
+ backgroundUniforms = {
+ mode: gl.getUniformLocation(backgroundProgram, "u_mode"),
+ yaw: gl.getUniformLocation(backgroundProgram, "u_yaw"),
+ pitch: gl.getUniformLocation(backgroundProgram, "u_pitch"),
+ roll: gl.getUniformLocation(backgroundProgram, "u_roll"),
+ hFov: gl.getUniformLocation(backgroundProgram, "u_hfov"),
+ vFov: gl.getUniformLocation(backgroundProgram, "u_vfov"),
+ viewport: gl.getUniformLocation(backgroundProgram, "u_viewport"),
+ opacity: gl.getUniformLocation(backgroundProgram, "u_opacity"),
+ paintOpacity: gl.getUniformLocation(backgroundProgram, "u_paintOpacity"),
+ maskOpacity: gl.getUniformLocation(backgroundProgram, "u_maskOpacity"),
+ hasPaint: gl.getUniformLocation(backgroundProgram, "u_hasPaint"),
+ hasMask: gl.getUniformLocation(backgroundProgram, "u_hasMask"),
+ showMaskTint: gl.getUniformLocation(backgroundProgram, "u_showMaskTint"),
+ maskTint: gl.getUniformLocation(backgroundProgram, "u_maskTint"),
+ background: gl.getUniformLocation(backgroundProgram, "u_background"),
+ paint: gl.getUniformLocation(backgroundProgram, "u_paint"),
+ mask: gl.getUniformLocation(backgroundProgram, "u_mask"),
+ };
+ stickerUniforms = {
+ texture: gl.getUniformLocation(stickerProgram, "u_texture"),
+ mode: gl.getUniformLocation(stickerProgram, "u_mode"),
+ viewRight: gl.getUniformLocation(stickerProgram, "u_viewRight"),
+ viewUp: gl.getUniformLocation(stickerProgram, "u_viewUp"),
+ viewFwd: gl.getUniformLocation(stickerProgram, "u_viewFwd"),
+ viewHfov: gl.getUniformLocation(stickerProgram, "u_viewHfov"),
+ viewVfov: gl.getUniformLocation(stickerProgram, "u_viewVfov"),
+ stickerRight: gl.getUniformLocation(stickerProgram, "u_stickerRight"),
+ stickerUp: gl.getUniformLocation(stickerProgram, "u_stickerUp"),
+ stickerFwd: gl.getUniformLocation(stickerProgram, "u_stickerFwd"),
+ stickerTanX: gl.getUniformLocation(stickerProgram, "u_stickerTanX"),
+ stickerTanY: gl.getUniformLocation(stickerProgram, "u_stickerTanY"),
+ crop: gl.getUniformLocation(stickerProgram, "u_crop"),
+ opacity: gl.getUniformLocation(stickerProgram, "u_opacity"),
+ };
+ quadBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
+ -1, -1,
+ 1, -1,
+ -1, 1,
+ -1, 1,
+ 1, -1,
+ 1, 1,
+ ]), gl.STATIC_DRAW);
+ backgroundTexture = createLayerTexture(gl.REPEAT, gl.CLAMP_TO_EDGE);
+ paintTexture = createLayerTexture(gl.REPEAT, gl.CLAMP_TO_EDGE);
+ maskTexture = createLayerTexture(gl.REPEAT, gl.CLAMP_TO_EDGE);
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
+ initialized = true;
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function dispose() {
+ if (!gl) return;
+ try {
+ if (quadBuffer) gl.deleteBuffer(quadBuffer);
+ if (backgroundTexture) gl.deleteTexture(backgroundTexture);
+ if (paintTexture) gl.deleteTexture(paintTexture);
+ if (maskTexture) gl.deleteTexture(maskTexture);
+ stickerTextureRegistry.forEach((entry) => {
+ disposeStickerTextureEntry(entry);
+ });
+ if (backgroundProgram) gl.deleteProgram(backgroundProgram);
+ if (stickerProgram) gl.deleteProgram(stickerProgram);
+ gl.getExtension("WEBGL_lose_context")?.loseContext();
+ } catch {
+ // best effort
+ }
+ gl = null;
+ backgroundProgram = null;
+ stickerProgram = null;
+ quadBuffer = null;
+ backgroundTexture = null;
+ paintTexture = null;
+ maskTexture = null;
+ stickerTextureRegistry.clear();
+ backgroundRevision = null;
+ paintRevision = null;
+ maskRevision = null;
+ textureMeta = {
+ background: { width: 0, height: 0 },
+ paint: { width: 0, height: 0 },
+ mask: { width: 0, height: 0 },
+ };
+ uploadScratchCanvas = null;
+ uploadScratchCtx = null;
+ initialized = false;
+ }
+
+ function setViewport(width, height, dpr = 1) {
+ viewport = deriveViewport(width, height, dpr);
+ if (surface.width !== viewport.width || surface.height !== viewport.height) {
+ surface.width = viewport.width;
+ surface.height = viewport.height;
+ }
+ }
+
+ function uploadPartialTexture(texture, source, rects = [], meta = { width: 0, height: 0 }) {
+ if (!gl || !texture || !source) return false;
+ const sourceWidth = Math.max(1, Number(source.width || source.videoWidth || source.naturalWidth || 0));
+ const sourceHeight = Math.max(1, Number(source.height || source.videoHeight || source.naturalHeight || 0));
+ const validRects = Array.isArray(rects) ? rects.filter((rect) => rect && rect.w > 0 && rect.h > 0) : [];
+ if (!validRects.length) return false;
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ if (meta.width !== sourceWidth || meta.height !== sourceHeight) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ meta.width = sourceWidth;
+ meta.height = sourceHeight;
+ return true;
+ }
+ if (!uploadScratchCanvas) {
+ uploadScratchCanvas = document.createElement("canvas");
+ uploadScratchCtx = uploadScratchCanvas.getContext("2d");
+ }
+ if (!uploadScratchCtx) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ return true;
+ }
+ for (const rect of validRects) {
+ const rx = Math.max(0, Math.floor(Number(rect.x || 0)));
+ const ry = Math.max(0, Math.floor(Number(rect.y || 0)));
+ const rw = Math.min(sourceWidth - rx, Math.ceil(Number(rect.w || 0)));
+ const rh = Math.min(sourceHeight - ry, Math.ceil(Number(rect.h || 0)));
+ if (rw <= 0 || rh <= 0) continue;
+ if (uploadScratchCanvas.width !== rw || uploadScratchCanvas.height !== rh) {
+ uploadScratchCanvas.width = rw;
+ uploadScratchCanvas.height = rh;
+ uploadScratchCtx = uploadScratchCanvas.getContext("2d");
+ if (!uploadScratchCtx) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ return true;
+ }
+ } else {
+ uploadScratchCtx.clearRect(0, 0, rw, rh);
+ }
+ uploadScratchCtx.drawImage(source, rx, ry, rw, rh, 0, 0, rw, rh);
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, rx, ry, gl.RGBA, gl.UNSIGNED_BYTE, uploadScratchCanvas);
+ }
+ return true;
+ }
+
+ function setLayerTexture(which, texture, source, revision, dirtyRects = null) {
+ if (!init()) return false;
+ if (!source) {
+ if (which === "background") backgroundRevision = null;
+ else if (which === "paint") paintRevision = null;
+ else maskRevision = null;
+ return false;
+ }
+ const nextRevision = String(revision ?? "");
+ const prevRevision = which === "background" ? backgroundRevision : (which === "paint" ? paintRevision : maskRevision);
+ const meta = textureMeta[which];
+ const sourceWidth = Math.max(1, Number(source.width || source.videoWidth || source.naturalWidth || 0));
+ const sourceHeight = Math.max(1, Number(source.height || source.videoHeight || source.naturalHeight || 0));
+ const sizeChanged = meta.width !== sourceWidth || meta.height !== sourceHeight;
+ if (prevRevision === nextRevision && !sizeChanged && !(Array.isArray(dirtyRects) && dirtyRects.length)) return true;
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ const didPartial = Array.isArray(dirtyRects) && dirtyRects.length
+ ? uploadPartialTexture(texture, source, dirtyRects, meta)
+ : false;
+ if (!didPartial) {
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ meta.width = sourceWidth;
+ meta.height = sourceHeight;
+ }
+ if (which === "background") backgroundRevision = nextRevision;
+ else if (which === "paint") paintRevision = nextRevision;
+ else maskRevision = nextRevision;
+ return true;
+ }
+
+ function setBackgroundErp(source, revision) {
+ return setLayerTexture("background", backgroundTexture, source, revision, null);
+ }
+
+ function setPaintErp(source, revision, dirtyRects = null) {
+ return setLayerTexture("paint", paintTexture, source, revision, dirtyRects);
+ }
+
+ function setMaskErp(source, revision, dirtyRects = null) {
+ return setLayerTexture("mask", maskTexture, source, revision, dirtyRects);
+ }
+
+ function disposeStickerTextureEntry(entry) {
+ if (entry?.texture && gl) gl.deleteTexture(entry.texture);
+ }
+
+ function ensureStickerTexture(input) {
+ if (!gl || !input?.assetId || !input?.source) return null;
+ const assetId = String(input.assetId);
+ const revision = String(input.revision ?? "");
+ const source = input.source;
+ const width = Math.max(1, Number(source.width || source.naturalWidth || source.videoWidth || 0));
+ const height = Math.max(1, Number(source.height || source.naturalHeight || source.videoHeight || 0));
+ if (width <= 0 || height <= 0) return null;
+ let entry = stickerTextureRegistry.get(assetId);
+ if (!entry) {
+ entry = {
+ texture: createLayerTexture(gl.CLAMP_TO_EDGE, gl.CLAMP_TO_EDGE),
+ revision: "",
+ width: 0,
+ height: 0,
+ };
+ stickerTextureRegistry.set(assetId, entry);
+ }
+ if (entry.revision !== revision || entry.width !== width || entry.height !== height) {
+ gl.bindTexture(gl.TEXTURE_2D, entry.texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
+ entry.revision = revision;
+ entry.width = width;
+ entry.height = height;
+ }
+ return entry.texture;
+ }
+
+ function syncStickerTextures(textures = []) {
+ if (!init()) return false;
+ const validAssetIds = new Set();
+ textures.forEach((input) => {
+ if (!input?.assetId || !input?.source) return;
+ validAssetIds.add(String(input.assetId));
+ ensureStickerTexture(input);
+ });
+ stickerTextureRegistry.forEach((entry, assetId) => {
+ if (validAssetIds.has(assetId)) return;
+ disposeStickerTextureEntry(entry);
+ stickerTextureRegistry.delete(assetId);
+ });
+ return true;
+ }
+
+ function setupFrame() {
+ if (!init()) return false;
+ gl.viewport(0, 0, surface.width, surface.height);
+ gl.disable(gl.DEPTH_TEST);
+ gl.disable(gl.CULL_FACE);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ return true;
+ }
+
+ function bindQuad(program) {
+ gl.useProgram(program);
+ gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer);
+ gl.enableVertexAttribArray(0);
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
+ }
+
+ function drawBackground(view, params = {}) {
+ if (!backgroundRevision) return null;
+ gl.disable(gl.BLEND);
+ bindQuad(backgroundProgram);
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, backgroundTexture);
+ gl.uniform1i(backgroundUniforms.background, 0);
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, paintTexture);
+ gl.uniform1i(backgroundUniforms.paint, 1);
+ gl.activeTexture(gl.TEXTURE2);
+ gl.bindTexture(gl.TEXTURE_2D, maskTexture);
+ gl.uniform1i(backgroundUniforms.mask, 2);
+ gl.uniform2f(backgroundUniforms.viewport, Math.max(1, viewport.width), Math.max(1, viewport.height));
+ gl.uniform1i(backgroundUniforms.mode, view?.mode === "unwrap" ? 0 : (view?.mode === "cutout" ? 2 : 1));
+ const angles = getViewAngles(view, viewport.width, viewport.height) || {
+ yawDeg: 0,
+ pitchDeg: 0,
+ rollDeg: 0,
+ hFovDeg: 90,
+ vFovDeg: 90,
+ };
+ gl.uniform1f(backgroundUniforms.yaw, Number(angles.yawDeg || 0) * DEG2RAD);
+ gl.uniform1f(backgroundUniforms.pitch, Number(angles.pitchDeg || 0) * DEG2RAD);
+ gl.uniform1f(backgroundUniforms.roll, Number(angles.rollDeg || 0) * DEG2RAD);
+ gl.uniform1f(backgroundUniforms.hFov, clamp(Number(angles.hFovDeg || 90), 0.1, 179) * DEG2RAD);
+ gl.uniform1f(backgroundUniforms.vFov, clamp(Number(angles.vFovDeg || 60), 0.1, 179) * DEG2RAD);
+ gl.uniform1f(backgroundUniforms.opacity, clamp(Number(params.backgroundOpacity ?? 1), 0, 1));
+ gl.uniform1f(backgroundUniforms.paintOpacity, clamp(Number(params.paintOpacity ?? 1), 0, 1));
+ gl.uniform1f(backgroundUniforms.maskOpacity, clamp(Number(params.maskOpacity ?? 0.55), 0, 1));
+ gl.uniform1i(backgroundUniforms.hasPaint, paintRevision != null ? 1 : 0);
+ gl.uniform1i(backgroundUniforms.hasMask, maskRevision != null ? 1 : 0);
+ gl.uniform1i(backgroundUniforms.showMaskTint, params.showMaskTint === false ? 0 : 1);
+ gl.uniform3f(backgroundUniforms.maskTint, 34 / 255, 197 / 255, 94 / 255);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ return surface;
+ }
+
+ function drawStickerScene(scene, textures, view) {
+ if (!scene?.stickers?.length || !textures?.length) return;
+ const mode = view?.mode === "unwrap" ? 0 : (view?.mode === "cutout" ? 2 : 1);
+ const viewAngles = getViewAngles(view, viewport.width, viewport.height) || {
+ yawDeg: 0,
+ pitchDeg: 0,
+ rollDeg: 0,
+ hFovDeg: 90,
+ vFovDeg: 90,
+ };
+ const viewBasis = cameraBasis(viewAngles.yawDeg, viewAngles.pitchDeg, viewAngles.rollDeg);
+ const textureMap = new Map();
+ textures.forEach((input) => {
+ const texture = ensureStickerTexture(input);
+ if (texture) textureMap.set(String(input.assetId || ""), texture);
+ });
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ bindQuad(stickerProgram);
+ gl.uniform1i(stickerUniforms.texture, 0);
+ gl.uniform1i(stickerUniforms.mode, mode);
+ gl.uniform3f(stickerUniforms.viewRight, viewBasis.right.x, viewBasis.right.y, viewBasis.right.z);
+ gl.uniform3f(stickerUniforms.viewUp, viewBasis.up.x, viewBasis.up.y, viewBasis.up.z);
+ gl.uniform3f(stickerUniforms.viewFwd, viewBasis.fwd.x, viewBasis.fwd.y, viewBasis.fwd.z);
+ gl.uniform1f(stickerUniforms.viewHfov, clamp(Number(viewAngles.hFovDeg || 90), 0.1, 179) * DEG2RAD);
+ gl.uniform1f(stickerUniforms.viewVfov, clamp(Number(viewAngles.vFovDeg || 60), 0.1, 179) * DEG2RAD);
+
+ const stickers = [...scene.stickers].sort((a, b) => Number(a?.zIndex || 0) - Number(b?.zIndex || 0));
+ stickers.forEach((sticker) => {
+ const texture = textureMap.get(String(sticker?.assetId || ""));
+ if (!texture) return;
+ const basis = buildStickerBasis(sticker);
+ const crop = basis.crop;
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.uniform3f(stickerUniforms.stickerRight, basis.right.x, basis.right.y, basis.right.z);
+ gl.uniform3f(stickerUniforms.stickerUp, basis.up.x, basis.up.y, basis.up.z);
+ gl.uniform3f(stickerUniforms.stickerFwd, basis.fwd.x, basis.fwd.y, basis.fwd.z);
+ gl.uniform1f(stickerUniforms.stickerTanX, Math.max(1e-6, basis.tanX));
+ gl.uniform1f(stickerUniforms.stickerTanY, Math.max(1e-6, basis.tanY));
+ gl.uniform4f(
+ stickerUniforms.crop,
+ clamp(Number(crop.x0 ?? 0), 0, 1),
+ clamp(Number(crop.y0 ?? 0), 0, 1),
+ clamp(Number(crop.x1 ?? 1), 0, 1),
+ clamp(Number(crop.y1 ?? 1), 0, 1),
+ );
+ gl.uniform1f(stickerUniforms.opacity, basis.opacity);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ });
+ gl.disable(gl.BLEND);
+ }
+
+ function renderPanorama(params) {
+ if (!setupFrame() || !backgroundRevision) return null;
+ drawBackground({
+ mode: "panorama",
+ yawDeg: params?.yawDeg || 0,
+ pitchDeg: params?.pitchDeg || 0,
+ fovDeg: params?.fovDeg || 100,
+ }, params);
+ return surface;
+ }
+
+ function renderUnwrap(params) {
+ if (!setupFrame() || !backgroundRevision) return null;
+ drawBackground({ mode: "unwrap" }, params);
+ return surface;
+ }
+
+ function renderCutout(params) {
+ if (!setupFrame() || !backgroundRevision) return null;
+ drawBackground({
+ mode: "cutout",
+ yawDeg: params?.yawDeg || 0,
+ pitchDeg: params?.pitchDeg || 0,
+ rollDeg: params?.rollDeg || 0,
+ hFovDeg: params?.hFovDeg || 90,
+ vFovDeg: params?.vFovDeg || 60,
+ }, params);
+ return surface;
+ }
+
+ function renderScene(input = {}) {
+ if (!init()) return null;
+ setViewport(input.width, input.height, input.dpr || 1);
+ if (!setupFrame()) return null;
+ if (input.backgroundSource) {
+ setBackgroundErp(input.backgroundSource, input.backgroundRevision ?? "");
+ drawBackground(input.view, input);
+ }
+ syncStickerTextures(input.textures || []);
+ drawStickerScene(
+ input.scene || { stickers: [], selectedId: null, hoveredId: null },
+ input.textures || [],
+ input.view || { mode: "panorama", yawDeg: 0, pitchDeg: 0, fovDeg: 100 },
+ );
+ return surface;
+ }
+
+ function screenToErpUv(params, x, y) {
+ const width = Math.max(1, Number(params?.viewportWidth || viewport.sourceWidth || viewport.width || 1));
+ const height = Math.max(1, Number(params?.viewportHeight || viewport.sourceHeight || viewport.height || 1));
+ const px = Number(x);
+ const py = Number(y);
+ if (!Number.isFinite(px) || !Number.isFinite(py)) return null;
+ if (params?.mode === "unwrap") {
+ return { u: ((px / width) % 1 + 1) % 1, v: clamp(py / height, 0, 1) };
+ }
+ const angles = getViewAngles(params, width, height);
+ if (!angles) return null;
+ const basis = cameraBasis(angles.yawDeg, angles.pitchDeg, angles.rollDeg);
+ const nx = ((px - width * 0.5) / (width * 0.5)) * Math.tan(clamp(angles.hFovDeg, 1, 179) * DEG2RAD * 0.5);
+ const ny = ((height * 0.5 - py) / (height * 0.5)) * Math.tan(clamp(angles.vFovDeg, 0.1, 179) * DEG2RAD * 0.5);
+ const dir = norm(add(add(mul(basis.right, nx), mul(basis.up, ny)), basis.fwd));
+ return {
+ u: ((Math.atan2(dir.x, dir.z) / TWO_PI) + 0.5 + 1.0) % 1.0,
+ v: clamp(0.5 - Math.asin(clamp(dir.y, -1, 1)) / Math.PI, 0, 1),
+ };
+ }
+
+ function erpUvToScreen(params, u, v) {
+ const width = Math.max(1, Number(params?.viewportWidth || viewport.sourceWidth || viewport.width || 1));
+ const height = Math.max(1, Number(params?.viewportHeight || viewport.sourceHeight || viewport.height || 1));
+ if (params?.mode === "unwrap") {
+ return {
+ x: ((Number(u || 0) % 1 + 1) % 1) * width,
+ y: clamp(Number(v || 0), 0, 1) * height,
+ visible: true,
+ };
+ }
+ const angles = getViewAngles(params, width, height);
+ const basis = cameraBasis(angles.yawDeg, angles.pitchDeg, angles.rollDeg);
+ const dir = lonLatToDir(u, v);
+ const cx = dot(dir, basis.right);
+ const cy = dot(dir, basis.up);
+ const cz = dot(dir, basis.fwd);
+ if (cz <= 1e-5) return { x: 0, y: 0, visible: false };
+ const x = width * 0.5 + (cx / cz) * ((width * 0.5) / Math.tan(clamp(angles.hFovDeg, 1, 179) * DEG2RAD * 0.5));
+ const y = height * 0.5 - (cy / cz) * ((height * 0.5) / Math.tan(clamp(angles.vFovDeg, 0.1, 179) * DEG2RAD * 0.5));
+ return { x, y, visible: x >= 0 && x <= width && y >= 0 && y <= height };
+ }
+
+ return {
+ init,
+ dispose,
+ setViewport,
+ setBackgroundErp,
+ setPaintErp,
+ setMaskErp,
+ renderPanorama,
+ renderUnwrap,
+ renderCutout,
+ renderScene,
+ screenToErpUv,
+ erpUvToScreen,
+ getCanvas() {
+ return surface;
+ },
+ isSupported() {
+ return init();
+ },
+ getViewport() {
+ return { ...viewport };
+ },
+ };
+}
diff --git a/web/pano_gl_scene.js b/web/pano_gl_scene.js
new file mode 100644
index 0000000..d878e2e
--- /dev/null
+++ b/web/pano_gl_scene.js
@@ -0,0 +1,117 @@
+import { getCutoutShotParams } from "./pano_cutout_projection.js";
+import { clamp } from "./pano_math.js";
+
+function normalizeCrop(crop) {
+ const raw = (crop && typeof crop === "object") ? crop : {};
+ const x0 = clamp(Number(raw.x0 ?? 0), 0, 1);
+ const y0 = clamp(Number(raw.y0 ?? 0), 0, 1);
+ const x1 = clamp(Number(raw.x1 ?? 1), 0, 1);
+ const y1 = clamp(Number(raw.y1 ?? 1), 0, 1);
+ return {
+ x0: Math.min(x0, x1),
+ y0: Math.min(y0, y1),
+ x1: Math.max(x0, x1),
+ y1: Math.max(y0, y1),
+ };
+}
+
+export function normalizeStickerItem(rawSticker) {
+ if (!rawSticker || typeof rawSticker !== "object") return null;
+ const assetId = String(rawSticker.asset_id || rawSticker.assetId || "").trim();
+ return {
+ id: String(rawSticker.id || ""),
+ assetId,
+ zIndex: Number(rawSticker.z_index || rawSticker.zIndex || 0),
+ yawDeg: Number(rawSticker.yaw_deg || rawSticker.yawDeg || 0),
+ pitchDeg: Number(rawSticker.pitch_deg || rawSticker.pitchDeg || 0),
+ rollDeg: Number(rawSticker.rot_deg ?? rawSticker.roll_deg ?? rawSticker.rollDeg ?? 0),
+ hFovDeg: clamp(Number(rawSticker.hFOV_deg || rawSticker.hFovDeg || 30), 1, 179),
+ vFovDeg: clamp(Number(rawSticker.vFOV_deg || rawSticker.vFovDeg || 30), 1, 179),
+ crop: normalizeCrop(rawSticker.crop),
+ opacity: clamp(Number(rawSticker.opacity ?? 1), 0, 1),
+ visible: rawSticker.visible !== false,
+ external: rawSticker.type === "external_image" || rawSticker.source_kind === "external_image",
+ };
+}
+
+export function buildStickerSceneFromState(state, options = {}) {
+ const stickers = Array.isArray(options.stickers)
+ ? options.stickers
+ : Array.isArray(state?.stickers) ? state.stickers : [];
+ const includeHidden = options.includeHidden === true;
+ const normalized = stickers
+ .map((item) => normalizeStickerItem(item))
+ .filter((item) => item && (includeHidden || item.visible !== false))
+ .sort((a, b) => Number(a.zIndex || 0) - Number(b.zIndex || 0));
+ return {
+ stickers: normalized,
+ selectedId: options.selectedId ?? state?.active?.selected_sticker_id ?? null,
+ hoveredId: options.hoveredId ?? null,
+ };
+}
+
+export function buildStickerTexturesFromState(state, assetLoader, options = {}) {
+ if (typeof assetLoader !== "function") return [];
+ const scene = options.scene || buildStickerSceneFromState(state, options);
+ const assets = (state && typeof state === "object" && state.assets && typeof state.assets === "object")
+ ? state.assets
+ : {};
+ const textures = [];
+ const seen = new Set();
+ scene.stickers.forEach((item) => {
+ const assetId = String(item?.assetId || "").trim();
+ if (!assetId || seen.has(assetId)) return;
+ const asset = assets[assetId];
+ const source = assetLoader(assetId, asset, item);
+ const width = Number(source?.naturalWidth || source?.videoWidth || source?.width || 0);
+ const height = Number(source?.naturalHeight || source?.videoHeight || source?.height || 0);
+ if (!source || width <= 0 || height <= 0) return;
+ seen.add(assetId);
+ textures.push({
+ assetId,
+ source,
+ revision: String(options.revisionFor?.(assetId, asset, source) ?? [
+ assetId,
+ Number(source.naturalWidth || source.videoWidth || source.width || 0),
+ Number(source.naturalHeight || source.videoHeight || source.height || 0),
+ String(source.currentSrc || source.src || ""),
+ ].join("|")),
+ });
+ });
+ return textures;
+}
+
+export function buildPanoramaViewParamsFromEditor(editor) {
+ return {
+ mode: "panorama",
+ yawDeg: Number(editor?.viewYaw || 0),
+ pitchDeg: Number(editor?.viewPitch || 0),
+ fovDeg: clamp(Number(editor?.viewFov || 100), 1, 179),
+ };
+}
+
+export function buildPanoramaViewParamsFromRuntime(runtimeState) {
+ return {
+ mode: "panorama",
+ yawDeg: Number(runtimeState?.yaw || 0),
+ pitchDeg: Number(runtimeState?.pitch || 0),
+ fovDeg: clamp(Number(runtimeState?.fov || 100), 1, 179),
+ };
+}
+
+export function buildPreviewNodeViewParams(nodeState) {
+ return buildPanoramaViewParamsFromRuntime(nodeState);
+}
+
+export function buildCutoutViewParamsFromShot(shot) {
+ const params = getCutoutShotParams(shot || {});
+ return {
+ mode: "cutout",
+ yawDeg: Number(shot?.yaw_deg || 0),
+ pitchDeg: Number(shot?.pitch_deg || 0),
+ rollDeg: Number(params?.roll ?? shot?.roll_deg ?? shot?.rot_deg ?? 0),
+ hFovDeg: clamp(Number(shot?.hFOV_deg || 90), 1, 179),
+ vFovDeg: clamp(Number(shot?.vFOV_deg || 60), 1, 179),
+ aspect: Number(params?.aspect || 1),
+ };
+}
diff --git a/web/pano_gl_viewport.js b/web/pano_gl_viewport.js
new file mode 100644
index 0000000..f565622
--- /dev/null
+++ b/web/pano_gl_viewport.js
@@ -0,0 +1,122 @@
+import { createPanoGlRenderer } from "./pano_gl_renderer.js";
+
+const SHARED_RENDERER_KEY = "__shared_renderer";
+
+function getRendererCache(owner) {
+ if (!owner) return null;
+ if (!owner.__panoGlViewportCache) owner.__panoGlViewportCache = new Map();
+ return owner.__panoGlViewportCache;
+}
+
+function getRendererEntry(owner, key) {
+ const cache = getRendererCache(owner);
+ if (!cache) return null;
+ let shared = cache.get(SHARED_RENDERER_KEY);
+ if (!shared) {
+ const renderer = createPanoGlRenderer();
+ if (!renderer?.isSupported?.()) return null;
+ shared = { renderer };
+ cache.set(SHARED_RENDERER_KEY, shared);
+ }
+ let entry = cache.get(key);
+ if (!entry) {
+ entry = { renderer: shared.renderer };
+ cache.set(key, entry);
+ }
+ return entry;
+}
+
+function imageRevisionKey(img) {
+ if (!img) return "";
+ return [
+ String(img.currentSrc || img.src || ""),
+ Number(img.naturalWidth || img.videoWidth || img.width || 0),
+ Number(img.naturalHeight || img.videoHeight || img.height || 0),
+ ].join("|");
+}
+
+function resolveRect(options = {}) {
+ if (options.rect) return options.rect;
+ return {
+ x: 0,
+ y: 0,
+ w: Math.max(1, Number(options.width || 1)),
+ h: Math.max(1, Number(options.height || 1)),
+ };
+}
+
+export function renderSceneToContext2D(options = {}) {
+ const owner = options.owner || null;
+ const ctx = options.ctx || null;
+ const rect = resolveRect(options);
+ if (!owner || !ctx || !rect?.w || !rect?.h) return false;
+
+ const cacheKey = String(options.cacheKey || "scene");
+ const entry = getRendererEntry(owner, cacheKey);
+ const backgroundSource = options.backgroundSource || options.img || null;
+ const scene = options.scene || { stickers: [], selectedId: null, hoveredId: null };
+ const textures = Array.isArray(options.textures) ? options.textures : [];
+ const view = options.view || { mode: "panorama", yawDeg: 0, pitchDeg: 0, fovDeg: 100 };
+ if (!entry?.renderer) return false;
+ const renderer = entry.renderer;
+ const dpr = Math.max(1, Number(options.dpr || window.devicePixelRatio || 1));
+ const backgroundRevision = options.backgroundRevision ?? imageRevisionKey(backgroundSource);
+ const surface = renderer.renderScene({
+ width: rect.w,
+ height: rect.h,
+ dpr,
+ backgroundSource,
+ backgroundRevision,
+ textures,
+ scene,
+ view,
+ backgroundOpacity: Number(options.backgroundOpacity ?? 1),
+ });
+ if (!surface) return false;
+ ctx.drawImage(surface, rect.x, rect.y, rect.w, rect.h);
+ return true;
+}
+
+export function renderErpViewToContext2D(options = {}) {
+ return renderSceneToContext2D({
+ ...options,
+ cacheKey: options.cacheKey || options.mode || "erp_view",
+ scene: { stickers: [], selectedId: null, hoveredId: null },
+ textures: [],
+ view: options.mode === "cutout"
+ ? {
+ mode: "cutout",
+ yawDeg: Number(options.yawDeg || 0),
+ pitchDeg: Number(options.pitchDeg || 0),
+ rollDeg: Number(options.rollDeg || 0),
+ hFovDeg: Number(options.hFovDeg || 90),
+ vFovDeg: Number(options.vFovDeg || 60),
+ }
+ : options.mode === "unwrap"
+ ? { mode: "unwrap" }
+ : {
+ mode: "panorama",
+ yawDeg: Number(options.yawDeg || 0),
+ pitchDeg: Number(options.pitchDeg || 0),
+ fovDeg: Number(options.fovDeg || 100),
+ },
+ });
+}
+
+export function renderCutoutViewToContext2D(options = {}) {
+ const view = options.view || {
+ mode: "cutout",
+ yawDeg: Number(options.yawDeg || 0),
+ pitchDeg: Number(options.pitchDeg || 0),
+ rollDeg: Number(options.rollDeg || 0),
+ hFovDeg: Number(options.hFovDeg || 90),
+ vFovDeg: Number(options.vFovDeg || 60),
+ };
+ return renderSceneToContext2D({
+ ...options,
+ cacheKey: options.cacheKey || "cutout_view",
+ scene: { stickers: [], selectedId: null, hoveredId: null },
+ textures: [],
+ view,
+ });
+}
diff --git a/web/pano_interaction_controller.js b/web/pano_interaction_controller.js
new file mode 100644
index 0000000..f10b157
--- /dev/null
+++ b/web/pano_interaction_controller.js
@@ -0,0 +1,180 @@
+import { clamp, wrapYaw } from "./pano_math.js";
+
+const DEG2RAD = Math.PI / 180;
+
+export const PANO_DRAG_SENSITIVITY = 0.12;
+export const PANO_WHEEL_STEP = 3;
+export const PANO_FOV_MIN = 35;
+export const PANO_FOV_MAX = 140;
+export const PANO_INITIAL_FOV = 100;
+export const PANO_INERTIA_BLEND_OLD = 0.4;
+export const PANO_INERTIA_BLEND_INST = 0.6;
+export const PANO_INERTIA_DAMPING = 5.5;
+export const PANO_INERTIA_START_SPEED = 20;
+export const PANO_INERTIA_STOP_SPEED = 0.8;
+
+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 createPanoInteractionController(options = {}) {
+ const getView = typeof options.getView === "function" ? options.getView : (() => ({ yaw: 0, pitch: 0, fov: PANO_INITIAL_FOV }));
+ const setView = typeof options.setView === "function" ? options.setView : (() => {});
+ const getInvert = typeof options.getInvert === "function" ? options.getInvert : (() => ({ x: 1, y: 1 }));
+ const getUnwrapRect = typeof options.getUnwrapRect === "function" ? options.getUnwrapRect : (() => ({ w: 1, h: 1 }));
+ const onInteraction = typeof options.onInteraction === "function" ? options.onInteraction : (() => {});
+ const onDebug = typeof options.onDebug === "function" ? options.onDebug : null;
+
+ const state = {
+ drag: { active: false, lastX: 0, lastY: 0, lastTs: 0, pointerId: null },
+ inertia: { vx: 0, vy: 0, active: false, lastTs: 0 },
+ };
+
+ function log(tag, payload = null) {
+ if (!onDebug) return;
+ onDebug(tag, payload);
+ }
+
+ function startDrag(x, y, pointerId = null, ts = performance.now()) {
+ state.drag.active = true;
+ state.drag.lastX = Number(x || 0);
+ state.drag.lastY = Number(y || 0);
+ state.drag.lastTs = Number(ts || performance.now());
+ state.drag.pointerId = pointerId;
+ state.inertia.active = false;
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ state.inertia.lastTs = state.drag.lastTs;
+ log("drag", { phase: "start", x: state.drag.lastX, y: state.drag.lastY, pointerId });
+ return true;
+ }
+
+ function moveDrag(x, y, mode = "pano", ts = performance.now()) {
+ if (!state.drag.active) return false;
+ const now = Number(ts || performance.now());
+ const px = Number(x);
+ const py = Number(y);
+ const dt = Math.max(0.001, (now - (state.drag.lastTs || now)) / 1000);
+ const dx = px - state.drag.lastX;
+ const dy = py - state.drag.lastY;
+ state.drag.lastX = px;
+ state.drag.lastY = py;
+ state.drag.lastTs = now;
+
+ const inv = getInvert() || { x: 1, y: 1 };
+ const invX = Number(inv.x || 1);
+ const invY = Number(inv.y || 1);
+ const view = { ...getView() };
+
+ let dYaw = 0;
+ let dPitch = 0;
+ if (mode === "unwrap") {
+ const r = getUnwrapRect() || { w: 1, h: 1 };
+ const rw = Math.max(1, Number(r.w || 1));
+ const rh = Math.max(1, Number(r.h || 1));
+ const nx = dx / rw;
+ const ny = dy / rh;
+ dYaw = -nx * 360 * invX;
+ dPitch = ny * 180 * invY;
+ } else {
+ dYaw = -dx * PANO_DRAG_SENSITIVITY * invX;
+ dPitch = dy * PANO_DRAG_SENSITIVITY * invY;
+ }
+
+ view.yaw = wrapYaw(Number(view.yaw || 0) + dYaw);
+ view.pitch = clamp(Number(view.pitch || 0) + dPitch, -89.9, 89.9);
+ setView(view);
+
+ state.inertia.vx = state.inertia.vx * PANO_INERTIA_BLEND_OLD + (dYaw / dt) * PANO_INERTIA_BLEND_INST;
+ state.inertia.vy = state.inertia.vy * PANO_INERTIA_BLEND_OLD + (dPitch / dt) * PANO_INERTIA_BLEND_INST;
+ state.inertia.active = false;
+ state.inertia.lastTs = now;
+ onInteraction();
+ log("drag", { phase: "move", dx, dy, dYaw, dPitch });
+ return true;
+ }
+
+ function endDrag(ts = performance.now()) {
+ if (!state.drag.active) return false;
+ state.drag.active = false;
+ state.drag.lastTs = Number(ts || performance.now());
+ const speed = Math.hypot(state.inertia.vx || 0, state.inertia.vy || 0);
+ state.inertia.active = speed > PANO_INERTIA_START_SPEED;
+ state.inertia.lastTs = state.drag.lastTs;
+ log("drag", { phase: "end", speed, inertiaActive: state.inertia.active });
+ return true;
+ }
+
+ function stepInertia(ts = performance.now()) {
+ if (!state.inertia.active) return false;
+ const now = Number(ts || performance.now());
+ const dt = Math.max(0.001, (now - (state.inertia.lastTs || now)) / 1000);
+ state.inertia.lastTs = now;
+ const view = { ...getView() };
+ view.yaw = wrapYaw(Number(view.yaw || 0) + state.inertia.vx * dt);
+ view.pitch = clamp(Number(view.pitch || 0) + state.inertia.vy * dt, -89.9, 89.9);
+ const damping = Math.exp(-PANO_INERTIA_DAMPING * dt);
+ state.inertia.vx *= damping;
+ state.inertia.vy *= damping;
+ if (Math.abs(state.inertia.vx) < PANO_INERTIA_STOP_SPEED && Math.abs(state.inertia.vy) < PANO_INERTIA_STOP_SPEED) {
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ state.inertia.active = false;
+ }
+ setView(view);
+ onInteraction();
+ return state.inertia.active;
+ }
+
+ function applyWheel(deltaSign) {
+ const sign = Math.sign(Number(deltaSign || 0));
+ if (!sign) return false;
+ const view = { ...getView() };
+ const before = Number(view.fov || PANO_INITIAL_FOV);
+ view.fov = clamp(before + sign * PANO_WHEEL_STEP, PANO_FOV_MIN, PANO_FOV_MAX);
+ setView(view);
+ onInteraction();
+ log("wheel", { deltaSign: sign, fovBefore: before, fovAfter: view.fov });
+ return true;
+ }
+
+ function applyWheelEvent(ev) {
+ return applyWheel(Math.sign(readWheelDelta(ev)));
+ }
+
+ function resetView() {
+ setView({ yaw: 0, pitch: 0, fov: PANO_INITIAL_FOV });
+ state.inertia.active = false;
+ state.inertia.vx = 0;
+ state.inertia.vy = 0;
+ onInteraction();
+ }
+
+ return {
+ state,
+ startDrag,
+ moveDrag,
+ endDrag,
+ stepInertia,
+ applyWheel,
+ applyWheelEvent,
+ resetView,
+ };
+}
+
+export const PANO_MODAL_PARITY_CONSTANTS = {
+ dragSensitivity: PANO_DRAG_SENSITIVITY,
+ wheelStep: PANO_WHEEL_STEP,
+ fovMin: PANO_FOV_MIN,
+ fovMax: PANO_FOV_MAX,
+ initialFov: PANO_INITIAL_FOV,
+ inertiaBlendOld: PANO_INERTIA_BLEND_OLD,
+ inertiaBlendInst: PANO_INERTIA_BLEND_INST,
+ inertiaDamping: PANO_INERTIA_DAMPING,
+ inertiaStartSpeed: PANO_INERTIA_START_SPEED,
+ inertiaStopSpeed: PANO_INERTIA_STOP_SPEED,
+ rad2deg: 1 / DEG2RAD,
+};
diff --git a/web/pano_math.js b/web/pano_math.js
new file mode 100644
index 0000000..1e14c11
--- /dev/null
+++ b/web/pano_math.js
@@ -0,0 +1,20 @@
+export function clamp(v, lo, hi) {
+ return Math.max(lo, Math.min(hi, v));
+}
+
+export function clampSafe(v, lo, hi) {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return Number(lo);
+ return Math.max(Number(lo), Math.min(Number(hi), n));
+}
+
+export function wrapYaw(y) {
+ return ((y + 180) % 360 + 360) % 360 - 180;
+}
+
+export function shortestYawDelta(from, to) {
+ let d = wrapYaw(to) - wrapYaw(from);
+ if (d > 180) d -= 360;
+ if (d < -180) d += 360;
+ return d;
+}
diff --git a/web/pano_node_preview.js b/web/pano_node_preview.js
new file mode 100644
index 0000000..b033d63
--- /dev/null
+++ b/web/pano_node_preview.js
@@ -0,0 +1,50 @@
+import {
+ attachPanoramaPreview as runtimeAttachPanoramaPreview,
+ attachCutoutPreview as runtimeAttachCutoutPreview,
+} from "./pano_preview_runtime.js";
+import { attachPreviewNodeRuntime } from "./pano_preview_previewnode.js";
+import { patchNodeLifecycle } from "./pano_preview_hooks.js";
+
+function isNodeType(value) {
+ return !!value?.prototype;
+}
+
+export function attachPreviewNode(target, options = {}) {
+ if (isNodeType(target)) return;
+ attachPreviewNodeRuntime(target, {
+ ...options,
+ onOpen: typeof options.onOpen === "function" ? () => options.onOpen(target) : options.onOpen,
+ });
+}
+
+export function attachStickersNodePreview(nodeType, options = {}) {
+ const enabled = options?.enabled === true;
+ const runtimeOptions = {
+ ...options,
+ mode: "stickers",
+ noPreview: !enabled,
+ };
+ if (!isNodeType(nodeType)) {
+ runtimeAttachPanoramaPreview(nodeType, runtimeOptions);
+ return;
+ }
+ patchNodeLifecycle(nodeType, "stickers_node_preview", (node) => {
+ runtimeAttachPanoramaPreview(node, {
+ ...runtimeOptions,
+ onOpen: typeof options.onOpen === "function" ? () => options.onOpen(node) : options.onOpen,
+ });
+ });
+}
+
+export function attachCutoutPreview(nodeType, options = {}) {
+ if (!isNodeType(nodeType)) {
+ runtimeAttachCutoutPreview(nodeType, options);
+ return;
+ }
+ patchNodeLifecycle(nodeType, "cutout_preview", (node) => {
+ runtimeAttachCutoutPreview(node, {
+ ...options,
+ onOpen: typeof options.onOpen === "function" ? () => options.onOpen(node) : options.onOpen,
+ });
+ });
+}
diff --git a/web/pano_preview_hooks.js b/web/pano_preview_hooks.js
new file mode 100644
index 0000000..88b6d0b
--- /dev/null
+++ b/web/pano_preview_hooks.js
@@ -0,0 +1,40 @@
+export function patchNodeLifecycle(nodeType, key, applyToNode) {
+ if (!nodeType?.prototype || typeof applyToNode !== "function") return;
+ const marker = `__panoLifecyclePatched_${String(key || "default")}`;
+ if (nodeType[marker]) return;
+ nodeType[marker] = true;
+ const logPrefix = `[TY360Preview:${String(key || "default")}]`;
+
+ const onNodeCreatedPrev = nodeType.prototype.onNodeCreated;
+ nodeType.prototype.onNodeCreated = function () {
+ const r = onNodeCreatedPrev ? onNodeCreatedPrev.apply(this, arguments) : undefined;
+ try {
+ applyToNode(this);
+ } catch (err) {
+ try { console.warn(logPrefix, "onNodeCreated wiring skipped:", err); } catch { }
+ }
+ return r;
+ };
+
+ const onConfigurePrev = nodeType.prototype.onConfigure;
+ nodeType.prototype.onConfigure = function () {
+ const r = onConfigurePrev ? onConfigurePrev.apply(this, arguments) : undefined;
+ try {
+ applyToNode(this);
+ } catch (err) {
+ try { console.warn(logPrefix, "onConfigure wiring skipped:", err); } catch { }
+ }
+ return r;
+ };
+
+ const onAddedPrev = nodeType.prototype.onAdded;
+ nodeType.prototype.onAdded = function () {
+ const r = onAddedPrev ? onAddedPrev.apply(this, arguments) : undefined;
+ try {
+ applyToNode(this);
+ } catch (err) {
+ try { console.warn(logPrefix, "onAdded wiring skipped:", err); } catch { }
+ }
+ return r;
+ };
+}
diff --git a/web/pano_preview_identity.js b/web/pano_preview_identity.js
new file mode 100644
index 0000000..7fd1ceb
--- /dev/null
+++ b/web/pano_preview_identity.js
@@ -0,0 +1,9 @@
+export function isPanoramaPreviewNodeName(nameRaw) {
+ const name = String(nameRaw || "").trim();
+ if (!name) return false;
+
+ if (name === "TY360Preview" || name === "TY360 Preview") return true;
+
+ return /^ty360[\s_]*preview(?:\s*\(node\s*2\.0\)|_legacy)?$/i.test(name);
+}
+
diff --git a/web/pano_preview_previewnode.js b/web/pano_preview_previewnode.js
new file mode 100644
index 0000000..59d61c8
--- /dev/null
+++ b/web/pano_preview_previewnode.js
@@ -0,0 +1,617 @@
+import { app } from "../../scripts/app.js";
+import { api } from "../../scripts/api.js";
+import { cameraBasis, DEG2RAD, clamp, wrapYaw } from "./pano_preview_render.js";
+import { createPanoInteractionController } from "./pano_interaction_controller.js";
+import { renderSceneToContext2D } from "./pano_gl_viewport.js";
+import { buildPreviewNodeViewParams, buildStickerSceneFromState } from "./pano_gl_scene.js";
+import {
+ drawErpBackground,
+ STANDALONE_MESH_LOW,
+ localPosFromEvent,
+} from "./pano_preview_runtime.js";
+
+const PREVIEW_MIN_HEIGHT = 140;
+const PREVIEW_MIN_WIDTH = 180;
+const LEGACY_TOP_OFFSET = 40;
+const LEGACY_MARGIN = 10;
+
+function normalizeExecutionId(value) {
+ const raw = String(value ?? "").trim();
+ if (!raw) return { full: "", tail: "" };
+ const parts = raw.split(":");
+ return { full: raw, tail: String(parts[parts.length - 1] || "").trim() };
+}
+
+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 comfyImageEntryToUrl(entry) {
+ if (!entry || typeof entry !== "object") return "";
+ const filename = String(entry.filename || "").trim();
+ 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 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 = typeof candidate[0] === "string" ? String(candidate[0] || "").trim() : "";
+ if (filename) {
+ return comfyImageEntryToUrl({
+ filename,
+ subfolder: String(candidate[1] || "").trim(),
+ type: String(candidate[2] || "output").trim() || "output",
+ });
+ }
+ 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 lookupNodeOutputEntry(nodeId) {
+ const store = app?.nodeOutputs;
+ if (!store || nodeId == null) return null;
+ 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;
+ if (store instanceof Map) {
+ for (const key of store.keys()) {
+ if (executionIdMatches(key, raw)) return store.get(key);
+ }
+ } else {
+ for (const key of Object.keys(store)) {
+ if (executionIdMatches(key, raw)) return store[key];
+ }
+ }
+ return null;
+}
+
+function getLinkedImageUrl(node, imageInputName = "erp_image") {
+ const inputs = Array.isArray(node?.inputs) ? node.inputs : [];
+ let linkId = null;
+
+ const preferred = inputs.find((input) => String(input?.name || "") === String(imageInputName));
+ if (preferred?.link != null) linkId = preferred.link;
+ if (linkId == null) {
+ const anyImg = inputs.find((input) => String(input?.type || "").toUpperCase() === "IMAGE" && input?.link != null);
+ if (anyImg?.link != null) linkId = anyImg.link;
+ }
+ if (linkId == null) return "";
+
+ const link = node?.graph?.links?.[linkId] || app?.graph?.links?.[linkId];
+ if (!link) return "";
+ const originId = Number(link.origin_id);
+ if (!Number.isFinite(originId)) return "";
+ const originNode = app?.graph?.getNodeById?.(originId);
+
+ const outputs = lookupNodeOutputEntry(originId);
+ const selfOutput = lookupNodeOutputEntry(node?.id);
+ const candidateGroups = [
+ outputs?.ui?.pano_input_images,
+ outputs?.pano_input_images,
+ selfOutput?.ui?.pano_input_images,
+ selfOutput?.pano_input_images,
+ outputs?.images,
+ ];
+ for (const group of candidateGroups) {
+ if (!Array.isArray(group)) continue;
+ for (const cand of group) {
+ const src = imageSourceFromCandidate(cand);
+ if (src) return src;
+ }
+ }
+
+ let appNodeImageUrls = [];
+ try {
+ appNodeImageUrls = typeof app?.getNodeImageUrls === "function" ? (app.getNodeImageUrls(originNode) || []) : [];
+ } catch {
+ appNodeImageUrls = [];
+ }
+ if (Array.isArray(appNodeImageUrls) && appNodeImageUrls.length > 0) {
+ for (const cand of appNodeImageUrls) {
+ const src = imageSourceFromCandidate(cand);
+ if (src) return src;
+ }
+ }
+
+ if (originNode) {
+ const nodeImgs = Array.isArray(originNode?.imgs) ? originNode.imgs : [];
+ for (const cand of nodeImgs) {
+ const src = imageSourceFromCandidate(cand);
+ if (src) return src;
+ }
+ }
+
+ const imageWidget = originNode?.widgets?.find((widget) => String(widget?.name || "").toLowerCase() === "image");
+ const imageName = String(imageWidget?.value || "").trim();
+ if (!imageName) return "";
+ return api.apiURL(`/view?filename=${encodeURIComponent(imageName)}&type=input&subfolder=`);
+}
+
+function drawGrid(ctx, width, height) {
+ ctx.strokeStyle = "rgba(255,255,255,0.16)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(width * 0.5, 0);
+ ctx.lineTo(width * 0.5, height);
+ ctx.moveTo(0, height * 0.5);
+ ctx.lineTo(width, height * 0.5);
+ ctx.stroke();
+}
+
+function drawPreview(node, ctx, width, height, view, img) {
+ ctx.clearRect(0, 0, width, height);
+ ctx.fillStyle = "#070707";
+ ctx.fillRect(0, 0, width, height);
+
+ if (!img || !img.complete || !(img.naturalWidth || img.width) || width <= 1 || height <= 1) {
+ drawGrid(ctx, width, height);
+ return;
+ }
+ const basis = cameraBasis(Number(view.yaw || 0), Number(view.pitch || 0), 0);
+ const tanHalfY = Math.tan((Number(view.fov || 100) * DEG2RAD) * 0.5);
+ const drawn = renderSceneToContext2D({
+ owner: node,
+ cacheKey: "standalone_preview_scene",
+ ctx,
+ width,
+ height,
+ backgroundSource: img,
+ backgroundRevision: [
+ String(img.currentSrc || img.src || ""),
+ Number(img.naturalWidth || img.width || 0),
+ Number(img.naturalHeight || img.height || 0),
+ ].join("|"),
+ textures: [],
+ scene: buildStickerSceneFromState(null, {}),
+ view: buildPreviewNodeViewParams(view),
+ });
+ if (!drawn) {
+ drawErpBackground(node, ctx, { x: 0, y: 0, w: width, h: height }, basis, tanHalfY, img, STANDALONE_MESH_LOW);
+ drawGrid(ctx, width, height);
+ }
+}
+
+function isPointInRect(x, y, rect) {
+ return x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
+}
+
+class PreviewNodeRuntime {
+ constructor(node, options = {}) {
+ this.node = node;
+ this.options = options;
+ this.imageInputName = String(options.imageInputName || "erp_image");
+ this.buttonText = String(options.buttonText || "Open Preview");
+ this.errorText = "";
+ this.root = null;
+ this.canvas = null;
+ this.ctx = null;
+ this.widget = null;
+ this.resizeObserver = null;
+ this.rafId = 0;
+ this.needsDraw = false;
+ this.inTick = false;
+ this.queuedDuringTick = false;
+ this.img = null;
+ this.imgSrc = "";
+ this.view = { yaw: 0, pitch: 0, fov: 100 };
+ this.controller = createPanoInteractionController({
+ getView: () => this.view,
+ setView: (next) => {
+ this.view = {
+ yaw: wrapYaw(Number(next?.yaw || 0)),
+ pitch: clamp(Number(next?.pitch || 0), -89.9, 89.9),
+ fov: clamp(Number(next?.fov || 100), 35, 140),
+ };
+ },
+ onInteraction: () => this.requestDraw(),
+ });
+ this.orig = {
+ onDrawForeground: node.onDrawForeground,
+ onMouseDown: node.onMouseDown,
+ onMouseMove: node.onMouseMove,
+ onMouseUp: node.onMouseUp,
+ onMouseWheel: node.onMouseWheel,
+ onExecuted: node.onExecuted,
+ onConnectionsChange: node.onConnectionsChange,
+ onResize: node.onResize,
+ onRemoved: node.onRemoved,
+ };
+ this.legacyDragPointer = false;
+ this.tick = this.tick.bind(this);
+ this.onResizeDom = this.onResizeDom.bind(this);
+ }
+
+ attach() {
+ if (typeof this.node?.addDOMWidget === "function") this.attachDom();
+ else this.attachLegacy();
+ this.installCommonHooks();
+ this.refreshImage();
+ this.requestDraw();
+ }
+
+ installCommonHooks() {
+ const self = this;
+ this.node.onExecuted = function () {
+ const out = self.orig.onExecuted ? self.orig.onExecuted.apply(this, arguments) : undefined;
+ self.refreshImage();
+ return out;
+ };
+ this.node.onConnectionsChange = function () {
+ const out = self.orig.onConnectionsChange ? self.orig.onConnectionsChange.apply(this, arguments) : undefined;
+ self.refreshImage();
+ return out;
+ };
+ this.node.onResize = function () {
+ const out = self.orig.onResize ? self.orig.onResize.apply(this, arguments) : undefined;
+ self.requestDraw();
+ return out;
+ };
+ this.node.onRemoved = function () {
+ const out = self.orig.onRemoved ? self.orig.onRemoved.apply(this, arguments) : undefined;
+ self.teardown();
+ return out;
+ };
+ }
+
+ installErrorForeground() {
+ const self = this;
+ this.node.onDrawForeground = function (ctx) {
+ const out = self.orig.onDrawForeground ? self.orig.onDrawForeground.apply(this, arguments) : undefined;
+ if (!ctx || this.flags?.collapsed || !self.errorText) return out;
+ const rect = self.getLegacyPreviewRect();
+ ctx.save();
+ ctx.fillStyle = "rgba(18,18,22,0.92)";
+ ctx.strokeStyle = "rgba(82,82,89,1)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(rect.x, rect.y, rect.w, rect.h, 8);
+ ctx.fill();
+ ctx.stroke();
+ ctx.fillStyle = "rgba(236,236,242,0.92)";
+ ctx.font = "600 12px sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(self.errorText, rect.x + rect.w * 0.5, rect.y + rect.h * 0.5);
+ ctx.restore();
+ return out;
+ };
+ }
+
+ attachDom() {
+ try {
+ this.root = document.createElement("div");
+ this.root.className = "pano-node-preview-dom pano-node-preview--stickers";
+ this.root.setAttribute("data-capture-wheel", "true");
+ this.root.setAttribute("tabindex", "0");
+ this.root.style.cssText = [
+ "width:100%",
+ "height:100%",
+ `min-height:${PREVIEW_MIN_HEIGHT}px`,
+ "position:relative",
+ "display:block",
+ "overflow:hidden",
+ "background:#070707",
+ "border-radius:8px",
+ "border:1px solid rgba(63,63,70,1)",
+ ].join(";");
+
+ this.canvas = document.createElement("canvas");
+ this.canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:none;cursor:grab;";
+ this.root.appendChild(this.canvas);
+
+ const widgetOptions = {
+ serialize: false,
+ hideOnZoom: false,
+ getValue() { return ""; },
+ setValue() {},
+ getMinHeight() { return PREVIEW_MIN_HEIGHT; },
+ getHeight() { return PREVIEW_MIN_HEIGHT; },
+ onRemove: () => this.teardown(),
+ afterResize: () => this.requestDraw(),
+ };
+
+ this.widget = this.node.addDOMWidget("preview", "preview", this.root, widgetOptions);
+ if (this.widget) {
+ this.widget.serialize = false;
+ const prevComputeLayoutSize = typeof this.widget.computeLayoutSize === "function"
+ ? this.widget.computeLayoutSize.bind(this.widget)
+ : null;
+ this.widget.computeLayoutSize = (targetNode) => {
+ const prev = prevComputeLayoutSize ? (prevComputeLayoutSize(targetNode) || {}) : {};
+ return {
+ ...prev,
+ minHeight: Math.max(PREVIEW_MIN_HEIGHT, Number(prev.minHeight || 0)),
+ minWidth: Math.max(PREVIEW_MIN_WIDTH, Number(prev.minWidth || 0)),
+ };
+ };
+ }
+
+ this.ctx = this.canvas.getContext("2d");
+ this.resizeObserver = typeof ResizeObserver !== "undefined"
+ ? new ResizeObserver(() => this.onResizeDom())
+ : null;
+ this.resizeObserver?.observe(this.root);
+ this.bindDomInput(this.canvas, this.root);
+ this.onResizeDom();
+ this.logProbeFrames();
+ } catch (err) {
+ this.errorText = "Preview mount failed";
+ this.installErrorForeground();
+ }
+ }
+
+ bindDomInput(canvas, root) {
+ canvas.addEventListener("pointerdown", (ev) => {
+ if (ev.button !== 0) return;
+ root.focus?.({ preventScroll: true });
+ canvas.setPointerCapture?.(ev.pointerId);
+ canvas.style.cursor = "grabbing";
+ this.controller.startDrag(ev.clientX, ev.clientY, ev.pointerId);
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.stopImmediatePropagation?.();
+ });
+ canvas.addEventListener("pointermove", (ev) => {
+ if (!this.controller.state.drag.active) return;
+ this.controller.moveDrag(ev.clientX, ev.clientY, "pano");
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.stopImmediatePropagation?.();
+ });
+ const end = (ev) => {
+ if (!this.controller.state.drag.active) return;
+ canvas.releasePointerCapture?.(ev.pointerId);
+ canvas.style.cursor = "grab";
+ this.controller.endDrag();
+ this.requestDraw();
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.stopImmediatePropagation?.();
+ };
+ canvas.addEventListener("pointerup", end);
+ canvas.addEventListener("pointercancel", end);
+ root.addEventListener("wheel", (ev) => {
+ const before = Number(this.view?.fov || 100);
+ const changed = this.controller.applyWheelEvent(ev);
+ const after = Number(this.view?.fov || 100);
+ ev.preventDefault();
+ ev.stopPropagation();
+ ev.stopImmediatePropagation?.();
+ }, { passive: false, capture: true });
+ }
+
+ attachLegacy() {
+ const self = this;
+ this.node.onDrawForeground = function (ctx) {
+ const out = self.orig.onDrawForeground ? self.orig.onDrawForeground.apply(this, arguments) : undefined;
+ self.drawLegacy(ctx);
+ return out;
+ };
+ this.node.onMouseDown = function (e, localPos) {
+ const p = self.localPoint(localPos);
+ if (!p) return self.orig.onMouseDown ? self.orig.onMouseDown.apply(this, arguments) : undefined;
+ const previewRect = self.getLegacyPreviewRect();
+ if (!isPointInRect(p.x, p.y, previewRect)) {
+ return self.orig.onMouseDown ? self.orig.onMouseDown.apply(this, arguments) : undefined;
+ }
+ self.legacyDragPointer = true;
+ self.controller.startDrag(p.x - previewRect.x, p.y - previewRect.y);
+ this.setDirtyCanvas?.(true, false);
+ return true;
+ };
+ this.node.onMouseMove = function (e, localPos) {
+ if (!self.legacyDragPointer || !self.controller.state.drag.active) {
+ return self.orig.onMouseMove ? self.orig.onMouseMove.apply(this, arguments) : undefined;
+ }
+ const p = self.localPoint(localPos);
+ if (!p) return true;
+ const previewRect = self.getLegacyPreviewRect();
+ self.controller.moveDrag(p.x - previewRect.x, p.y - previewRect.y, "pano");
+ this.setDirtyCanvas?.(true, false);
+ return true;
+ };
+ this.node.onMouseUp = function () {
+ if (!self.legacyDragPointer && !self.controller.state.drag.active) {
+ return self.orig.onMouseUp ? self.orig.onMouseUp.apply(this, arguments) : undefined;
+ }
+ self.legacyDragPointer = false;
+ self.controller.endDrag();
+ this.setDirtyCanvas?.(true, false);
+ return true;
+ };
+ this.node.onMouseWheel = function (e, arg2, canvasRef) {
+ const p = localPosFromEvent(this, e, arg2, canvasRef);
+ const previewRect = self.getLegacyPreviewRect();
+ if (!p || !isPointInRect(p.x, p.y, previewRect)) {
+ return self.orig.onMouseWheel ? self.orig.onMouseWheel.apply(this, arguments) : undefined;
+ }
+ const raw = Number(e?.deltaY ?? e?.wheelDeltaY ?? (typeof arg2 === "number" ? arg2 : 0));
+ const before = Number(self.view?.fov || 100);
+ const changed = self.controller.applyWheel(Math.sign(raw));
+ const after = Number(self.view?.fov || 100);
+ if (changed) this.setDirtyCanvas?.(true, false);
+ e?.preventDefault?.();
+ e?.stopPropagation?.();
+ e?.stopImmediatePropagation?.();
+ return true;
+ };
+ }
+
+ localPoint(localPos) {
+ if (!Array.isArray(localPos) || localPos.length < 2) return null;
+ return { x: Number(localPos[0] || 0), y: Number(localPos[1] || 0) };
+ }
+
+ getLegacyPreviewRect() {
+ const width = Math.max(80, Number(this.node?.size?.[0] || 0) - LEGACY_MARGIN * 2);
+ const height = Math.max(60, Number(this.node?.size?.[1] || 0) - LEGACY_TOP_OFFSET - LEGACY_MARGIN);
+ return { x: LEGACY_MARGIN, y: LEGACY_TOP_OFFSET, w: width, h: height };
+ }
+
+ drawLegacy(ctx) {
+ if (!ctx || this.node?.flags?.collapsed) return;
+ const rect = this.getLegacyPreviewRect();
+ if (rect.w <= 2 || rect.h <= 2) return;
+
+ ctx.save();
+ ctx.translate(rect.x, rect.y);
+ drawPreview(this.node, ctx, rect.w, rect.h, this.view, this.img);
+
+ if (this.errorText) {
+ ctx.fillStyle = "rgba(18,18,22,0.92)";
+ ctx.fillRect(0, 0, rect.w, rect.h);
+ ctx.fillStyle = "rgba(236,236,242,0.92)";
+ ctx.font = "600 12px sans-serif";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ ctx.fillText(this.errorText, rect.w * 0.5, rect.h * 0.5);
+ }
+ ctx.restore();
+ }
+
+ onResizeDom() {
+ if (!this.root || !this.canvas) return;
+ const rect = this.root.getBoundingClientRect();
+ const dpr = 1;
+ const width = Math.max(1, Math.round(rect.width * dpr));
+ const height = Math.max(1, Math.round(rect.height * dpr));
+ if (this.canvas.width !== width || this.canvas.height !== height) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ this.requestDraw();
+ }
+ }
+
+ refreshImage() {
+ const nextSrc = getLinkedImageUrl(this.node, this.imageInputName);
+ if (!nextSrc) {
+ this.img = null;
+ this.imgSrc = "";
+ this.requestDraw();
+ return;
+ }
+ if (nextSrc === this.imgSrc && this.img) return;
+ this.imgSrc = nextSrc;
+ const img = new Image();
+ img.onload = () => {
+ if (this.imgSrc !== nextSrc) return;
+ this.img = img;
+ this.requestDraw();
+ };
+ img.onerror = () => {
+ if (this.imgSrc !== nextSrc) return;
+ this.img = null;
+ this.requestDraw();
+ };
+ img.src = nextSrc;
+ }
+
+ requestDraw() {
+ this.needsDraw = true;
+ if (this.inTick) {
+ this.queuedDuringTick = true;
+ return;
+ }
+ if (!this.rafId) this.rafId = requestAnimationFrame(this.tick);
+ if (!this.canvas) this.node.setDirtyCanvas?.(true, false);
+ }
+
+ tick(ts = performance.now()) {
+ this.rafId = 0;
+ this.inTick = true;
+ this.queuedDuringTick = false;
+ this.needsDraw = false;
+ const moving = this.controller.stepInertia(ts);
+ if (this.canvas && this.ctx) {
+ drawPreview(this.node, this.ctx, this.canvas.width, this.canvas.height, this.view, this.img);
+ if (this.errorText) {
+ this.ctx.fillStyle = "rgba(18,18,22,0.92)";
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+ this.ctx.fillStyle = "rgba(236,236,242,0.92)";
+ this.ctx.font = "600 14px sans-serif";
+ this.ctx.textAlign = "center";
+ this.ctx.textBaseline = "middle";
+ this.ctx.fillText(this.errorText, this.canvas.width * 0.5, this.canvas.height * 0.5);
+ }
+ } else {
+ this.node.setDirtyCanvas?.(true, false);
+ }
+ this.inTick = false;
+ const shouldContinue = moving || this.needsDraw || this.queuedDuringTick;
+ if (shouldContinue && !this.rafId) this.rafId = requestAnimationFrame(this.tick);
+ }
+
+ logProbeFrames() {
+ if (!this.root || !this.canvas) return;
+ let frame = 0;
+ const probe = () => {
+ if (!this.root || !this.canvas || frame >= 3) return;
+ frame += 1;
+ requestAnimationFrame(probe);
+ };
+ requestAnimationFrame(probe);
+ }
+
+ teardown() {
+ if (this.node?.__panoPreviewNodeRuntime !== this) return;
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = 0;
+ }
+ this.resizeObserver?.disconnect?.();
+ this.resizeObserver = null;
+ try {
+ this.root?.remove?.();
+ } catch {
+ // ignore
+ }
+ if (Array.isArray(this.node?.widgets) && this.widget) {
+ try {
+ this.node.widgets = this.node.widgets.filter((widget) => widget !== this.widget);
+ } catch {
+ // ignore
+ }
+ }
+ this.node.onDrawForeground = this.orig.onDrawForeground;
+ this.node.onMouseDown = this.orig.onMouseDown;
+ this.node.onMouseMove = this.orig.onMouseMove;
+ this.node.onMouseUp = this.orig.onMouseUp;
+ this.node.onMouseWheel = this.orig.onMouseWheel;
+ this.node.onExecuted = this.orig.onExecuted;
+ this.node.onConnectionsChange = this.orig.onConnectionsChange;
+ this.node.onResize = this.orig.onResize;
+ this.node.onRemoved = this.orig.onRemoved;
+ this.node.__panoPreviewNodeRuntime = null;
+ }
+}
+
+export function attachPreviewNodeRuntime(node, options = {}) {
+ if (!node || node.__panoPreviewNodeRuntime) return;
+ const runtime = new PreviewNodeRuntime(node, options);
+ node.__panoPreviewNodeRuntime = runtime;
+ runtime.attach();
+}
diff --git a/web/pano_preview_render.js b/web/pano_preview_render.js
new file mode 100644
index 0000000..0917abf
--- /dev/null
+++ b/web/pano_preview_render.js
@@ -0,0 +1,93 @@
+// Shared preview render-facing primitives.
+// Kept intentionally small in this refactor phase; runtime still hosts the
+// full draw pipeline and can be incrementally migrated here.
+import { getCutoutShotParams } from "./pano_cutout_projection.js";
+import { clamp, wrapYaw, shortestYawDelta } from "./pano_math.js";
+
+export const DEG2RAD = Math.PI / 180;
+export const RAD2DEG = 180 / Math.PI;
+
+export { clamp, wrapYaw, shortestYawDelta };
+
+export function vec3(x, y, z) {
+ return { x, y, z };
+}
+
+export function add(a, b) {
+ return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
+}
+
+export function mul(a, s) {
+ return vec3(a.x * s, a.y * s, a.z * s);
+}
+
+export function dot(a, b) {
+ return a.x * b.x + a.y * b.y + a.z * b.z;
+}
+
+export 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,
+ );
+}
+
+export 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);
+}
+
+export 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));
+}
+
+export 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);
+ const up0 = 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(up0, sr));
+ const u2 = add(mul(right, -sr), mul(up0, cr));
+ return { fwd, right: norm(r2), up: norm(u2) };
+}
+
+export 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 },
+ };
+ const trimmed = String(text || "").trim();
+ if (!trimmed) return base;
+ try {
+ const p = JSON.parse(trimmed);
+ if (!p || typeof p !== "object") return base;
+ return {
+ ...base,
+ ...p,
+ assets: p.assets && typeof p.assets === "object" && !Array.isArray(p.assets) ? p.assets : {},
+ stickers: Array.isArray(p.stickers) ? p.stickers : [],
+ shots: Array.isArray(p.shots) ? p.shots : [],
+ active: p.active && typeof p.active === "object" && !Array.isArray(p.active) ? p.active : base.active,
+ };
+ } catch {
+ return base;
+ }
+}
+
+export { getCutoutShotParams };
diff --git a/web/pano_preview_runtime.js b/web/pano_preview_runtime.js
new file mode 100644
index 0000000..8dbd86f
--- /dev/null
+++ b/web/pano_preview_runtime.js
@@ -0,0 +1,3920 @@
+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);
+}