Initial TY360 Photo Edit release

This commit is contained in:
supart
2026-03-08 14:18:36 +08:00
commit a04108def5
33 changed files with 21594 additions and 0 deletions
+10
View File
@@ -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
+37
View File
@@ -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-*/
+21
View File
@@ -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.
+38
View File
@@ -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
+9
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
# TY360 Photo Edit package.
@@ -0,0 +1 @@
# Core package for panorama math and rendering.
+48
View File
@@ -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)
+245
View File
@@ -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)
+231
View File
@@ -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"),
}
+281
View File
@@ -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)
+318
View File
@@ -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",
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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
+288
View File
@@ -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
+131
View File
@@ -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()
+85
View File
@@ -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}")
+87
View File
@@ -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
+223
View File
@@ -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;
}
+991
View File
@@ -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;
}
+4778
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+808
View File
@@ -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 };
},
};
}
+117
View File
@@ -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),
};
}
+122
View File
@@ -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,
});
}
+180
View File
@@ -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,
};
+20
View File
@@ -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;
}
+50
View File
@@ -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,
});
});
}
+40
View File
@@ -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;
};
}
+9
View File
@@ -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);
}
+617
View File
@@ -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();
}
+93
View File
@@ -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 };
File diff suppressed because it is too large Load Diff