Initial TY360 Photo Edit release
This commit is contained in:
@@ -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
@@ -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-*/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
# TY360 Photo Edit package.
|
||||
@@ -0,0 +1 @@
|
||||
# Core package for panorama math and rendering.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user