commit a04108def5db540bd529ea5fd5a4e953b6b00a52 Author: supart <617811605@qq.com> Date: Sun Mar 8 14:18:36 2026 +0800 Initial TY360 Photo Edit release 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 = `${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 = ` +
+ + +
+
+ + +
+
+ ${bottomToolbarHtml} +
+ FOV + 100.0 + + + ${fullscreenBtnHtml} +
+
+
+ +
+
+
+
+
${sideTitleHtml}
+
+
+
+
+ `; + + 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 + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ `; + 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 + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ `; + 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 = ` + + `; + 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 = `${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 = ` +
+ + +
+
+ + +
+
+ ${bottomToolbarHtml} +
+ FOV + 100.0 + + + ${fullscreenBtnHtml} +
+
+
+ +
+
+
+
+
${sideTitleHtml}
+
+
+
+
+ `; + 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 + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ `; + 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 + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +
+
+ `; + 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 = ` + + `; + 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); +}