Initial release

This commit is contained in:
YUAN_Workstation
2026-07-05 12:41:32 +08:00
commit f7ff96c305
8 changed files with 1438 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
.DS_Store
Thumbs.db
+13
View File
@@ -0,0 +1,13 @@
# Changelog
## 2026-07-05
- Created the standalone `ComfyUI-TY-OrbitSphereLight` custom node.
- Added `TY Orbit Sphere Light` / `TYOrbitSphereLightNode` under `TY/Relight`.
- Kept final render output aligned with the original `Sphere-Light-Render-ComfyUI` look.
- Added a node-only gizmo preview with orbit handles for `azimuth`, `elevation`, and `intensity`.
- Hid `render_b64` from the ComfyUI sidebar and workflow serialization.
- Added backend fallback rendering for API or headless use when the frontend preview buffer is unavailable.
- Bundled `three.min.js` locally for offline installation.
- Added `README.zh-CN.md` and a minimal example workflow.
- Added paired frontend support through the TY light reference page and fixed clean download filenames.
+94
View File
@@ -0,0 +1,94 @@
# ComfyUI-TY-OrbitSphereLight
`TY Orbit Sphere Light` is an independent ComfyUI custom node for generating clean sphere-based relighting reference images.
## Overview
This node combines:
- the clean output style of `Sphere-Light-Render-ComfyUI`
- a more intuitive orbital gizmo interaction style inspired by `comfyui_gaussian_splat`
The result is a new standalone TY node with:
- a sphere-centered preview
- draggable handles for `azimuth`, `elevation`, and `intensity`
- no handles, rings, or guides in the final output image
## Final Output Rules
- The exported `IMAGE` stays clean.
- The gizmo is visible only inside the ComfyUI node preview.
- The output keeps the original gray background, sphere, floor, and shadow look.
## Inputs
- `azimuth`: `0..360`
- `elevation`: `-30..90`
- `intensity`: `0.2..3.0`
- `render_b64`: internal hidden buffer used by the node preview render path
## Output
- `render`: clean `IMAGE` reference without preview controls
## Render Paths
- ComfyUI canvas mode:
- the frontend preview renders the sphere and writes a hidden render buffer
- API or headless workflow mode:
- the Python fallback renderer is used automatically when `render_b64` is empty
## Workflow Serialization
- `render_b64` is hidden in the ComfyUI sidebar
- `render_b64` is not saved into workflow `widgets_values`
- saved workflows only keep the numeric controls needed to reopen the node cleanly
## Install
### Option 1: Folder Install
1. Copy `ComfyUI-TY-OrbitSphereLight` into `ComfyUI/custom_nodes/`
2. Restart ComfyUI
### Option 2: Zip Install
1. Use the packaged file `ComfyUI-TY-OrbitSphereLight.zip`
2. Extract it into `ComfyUI/custom_nodes/`
3. Restart ComfyUI
## Verify
After ComfyUI starts:
1. Add `TY Orbit Sphere Light`
2. Confirm the node appears under category `TY/Relight`
3. Confirm the node preview shows the sphere and the three colored handles
4. Confirm the right sidebar only shows `azimuth`, `elevation`, and `intensity`
5. Confirm the final output image does not contain any gizmo graphics
## Files
- `__init__.py`: node registration and backend fallback renderer
- `js/ty_orbit_sphere_light_widget.js`: ComfyUI frontend preview and gizmo logic
- `js/vendor/three.min.js`: bundled Three.js runtime for offline use
## Bundled Extras
- `README.zh-CN.md`: Chinese install and usage guide
- `examples/ty_orbit_sphere_light_basic_workflow.json`: minimal workflow example with `SaveImage`
## Frontend Pairing
This node can also be used from the paired TY frontend page at:
- `http://127.0.0.1:3000/ty-light-ref.html`
That page calls the `ty-orbit-sphere-light` workflow app and keeps the final render clean, matching the node output rules.
## Notes
- No extra Python packages are required beyond the normal ComfyUI image stack.
- The original `Sphere-Light-Render-ComfyUI` plugin is not modified.
- This plugin is designed to coexist with the original node as a separate TY node.
+118
View File
@@ -0,0 +1,118 @@
# ComfyUI-TY-OrbitSphereLight
`TY Orbit Sphere Light` 是一个独立的 ComfyUI 自定义节点,用来生成干净的球体打光参考图。
## 节点定位
这个节点把两类能力结合到了一起:
- 保留 `Sphere-Light-Render-ComfyUI` 的最终出图风格
- 引入更直观的环绕式手柄交互体验
最终效果是:
- 节点内部可见球体预览
- 可拖拽控制 `azimuth`、`elevation`、`intensity`
- 最终输出图像中不包含手柄、轨道、辅助线
## 最终输出约束
- 输出图必须是干净球体图
- 手柄只在 ComfyUI 节点预览中显示
- 背景、球体、地面、阴影风格保持与原球体打光插件一致
## 输入参数
- `azimuth`: `0..360`
- `elevation`: `-30..90`
- `intensity`: `0.2..3.0`
- `render_b64`: 内部隐藏缓冲字段,仅用于节点前端预览
## 输出参数
- `render`: 干净的 `IMAGE` 输出
## 运行模式
### 1. ComfyUI 画布模式
- 节点前端会在预览区渲染球体和手柄
- 同步回写隐藏的 `render_b64`
### 2. API / 无前端模式
- 当 `render_b64` 为空时,自动走 Python 后端 fallback 渲染
- 适合外部前端、自动化流程、headless 调用
## Workflow 保存行为
- `render_b64` 在 ComfyUI 右侧参数栏中隐藏
- `render_b64` 不会写入 workflow 的 `widgets_values`
- 保存后的 workflow 只保留数值参数,避免带入大段 base64
## 安装方法
### 方式一:文件夹安装
1. 把 `ComfyUI-TY-OrbitSphereLight` 复制到 `ComfyUI/custom_nodes/`
2. 重启 ComfyUI
### 方式二:压缩包安装
1. 使用打包文件 `ComfyUI-TY-OrbitSphereLight.zip`
2. 解压到 `ComfyUI/custom_nodes/`
3. 重启 ComfyUI
## 使用步骤
1. 在 ComfyUI 中添加 `TY Orbit Sphere Light`
2. 在分类 `TY/Relight` 下找到节点
3. 通过滑条或节点预览中的三色手柄调节参数
4. 将 `render` 输出连接到后续节点或 `SaveImage`
## 验证要点
启动后建议检查:
1. 节点预览区是否显示球体和三色手柄
2. 右侧栏是否只显示 `azimuth`、`elevation`、`intensity`
3. 输出图里是否完全没有手柄和轨道
4. 保存并重开 workflow 后参数是否恢复正常
## 示例 Workflow
可直接参考:
- [examples/ty_orbit_sphere_light_basic_workflow.json](examples/ty_orbit_sphere_light_basic_workflow.json)
这个示例包含:
- `TY Orbit Sphere Light`
- `SaveImage`
- 一条最小可运行连接
## 文件说明
- `__init__.py`: 节点注册与后端 fallback 渲染
- `js/ty_orbit_sphere_light_widget.js`: 节点前端预览与手柄逻辑
- `js/vendor/three.min.js`: 内置 Three.js,支持离线使用
## 附带资源
- `README.md`: 英文安装与说明
- `CHANGELOG.md`: 当前版本改动记录
- `examples/ty_orbit_sphere_light_basic_workflow.json`: 最小可运行 workflow 示例
## 配套前端页
如果你同时使用这套 TY 前端工具,也可以直接打开:
- `http://127.0.0.1:3000/ty-light-ref.html`
这个独立页会调用 `ty-orbit-sphere-light` workflow app,生成与节点输出规则一致的干净球体参考图。
## 备注
- 不会覆盖或修改原 `Sphere-Light-Render-ComfyUI`
- 可与原插件同时安装
- 如果你同时使用配套前端工具,可以直接通过独立页生成参考图
+247
View File
@@ -0,0 +1,247 @@
import base64
import io
import numpy as np
import torch
from PIL import Image, ImageFilter
RENDER_SIZE = 512
OUTPUT_SIZE = 1024
BACKGROUND_RGB = np.array([138, 138, 138], dtype=np.float32) / 255.0
SPHERE_RGB = np.array([204, 204, 204], dtype=np.float32) / 255.0
FALLBACK_BRIGHTNESS_OFFSET = 40
FALLBACK_BLUR_RADIUS = 1.5
def _normalize(vec):
vec = np.asarray(vec, dtype=np.float32)
norm = np.linalg.norm(vec, axis=-1, keepdims=True)
norm = np.maximum(norm, 1e-6)
return vec / norm
def _build_camera_rays(size):
cam_pos = np.array([0.0, 6.0, 8.0], dtype=np.float32)
cam_target = np.array([0.0, -0.5, 0.0], dtype=np.float32)
world_up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
forward = _normalize(cam_target - cam_pos)
right = _normalize(np.cross(forward, world_up))
up = _normalize(np.cross(right, forward))
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
nx = ((xx + 0.5) / size) * 2.0 - 1.0
ny = 1.0 - ((yy + 0.5) / size) * 2.0
scale = np.tan(np.deg2rad(35.0).astype(np.float32) / 2.0)
dirs = (
forward[None, None, :]
+ nx[..., None] * scale * right[None, None, :]
+ ny[..., None] * scale * up[None, None, :]
)
return cam_pos, _normalize(dirs)
def _intersect_sphere(ray_origin, ray_dirs, center, radius):
oc = ray_origin[None, None, :] - center[None, None, :]
half_b = np.sum(ray_dirs * oc, axis=-1)
c = np.sum(oc * oc, axis=-1) - radius * radius
disc = half_b * half_b - c
valid = disc > 0.0
sqrt_disc = np.zeros_like(disc, dtype=np.float32)
sqrt_disc[valid] = np.sqrt(disc[valid])
t = np.full(disc.shape, np.inf, dtype=np.float32)
near = -half_b - sqrt_disc
far = -half_b + sqrt_disc
valid_near = valid & (near > 1e-4)
valid_far = valid & ~valid_near & (far > 1e-4)
t[valid_near] = near[valid_near]
t[valid_far] = far[valid_far]
return t
def _intersect_plane_y(ray_origin, ray_dirs, plane_y):
dy = ray_dirs[..., 1]
t = np.full(dy.shape, np.inf, dtype=np.float32)
valid = np.abs(dy) > 1e-6
raw_t = (plane_y - ray_origin[1]) / dy
t[valid & (raw_t > 1e-4)] = raw_t[valid & (raw_t > 1e-4)]
return t
def _batched_directional_shadow(points, light_dir, sphere_radius):
if points.size == 0:
return np.zeros((0,), dtype=np.float32)
helper = np.array([0.0, 1.0, 0.0], dtype=np.float32)
if abs(np.dot(helper, light_dir)) > 0.95:
helper = np.array([1.0, 0.0, 0.0], dtype=np.float32)
tangent_a = _normalize(np.cross(light_dir, helper)).reshape(3)
tangent_b = _normalize(np.cross(light_dir, tangent_a)).reshape(3)
offsets = [
(0.0, 0.0),
(0.055, 0.0),
(-0.055, 0.0),
(0.0, 0.055),
(0.0, -0.055),
]
occlusion = np.zeros((points.shape[0],), dtype=np.float32)
for off_a, off_b in offsets:
sample_dir = _normalize(light_dir + tangent_a * off_a + tangent_b * off_b).reshape(3)
origin = points + sample_dir[None, :] * 0.03
proj = np.sum(origin * sample_dir[None, :], axis=1)
c = np.sum(origin * origin, axis=1) - sphere_radius * sphere_radius
disc = proj * proj - c
hit = disc > 0.0
sqrt_disc = np.zeros_like(disc, dtype=np.float32)
sqrt_disc[hit] = np.sqrt(disc[hit])
t = -proj - sqrt_disc
occlusion += (hit & (t > 1e-4)).astype(np.float32)
return occlusion / float(len(offsets))
def _render_backend_fallback(azimuth, elevation, intensity):
cam_pos, ray_dirs = _build_camera_rays(RENDER_SIZE)
sphere_center = np.array([0.0, 0.0, 0.0], dtype=np.float32)
sphere_radius = 1.0
plane_y = -1.0
az = np.deg2rad(np.float32(azimuth))
el = np.deg2rad(np.float32(elevation))
light_pos = np.array(
[
10.0 * np.cos(el) * np.sin(az),
10.0 * np.sin(el),
10.0 * np.cos(el) * np.cos(az),
],
dtype=np.float32,
)
light_dir = _normalize(light_pos).reshape(3)
sphere_t = _intersect_sphere(cam_pos, ray_dirs, sphere_center, sphere_radius)
plane_t = _intersect_plane_y(cam_pos, ray_dirs, plane_y)
image = np.broadcast_to(BACKGROUND_RGB, (RENDER_SIZE, RENDER_SIZE, 3)).copy()
sphere_hit = np.isfinite(sphere_t) & (sphere_t <= plane_t)
plane_hit = np.isfinite(plane_t) & ~sphere_hit
if np.any(sphere_hit):
sphere_points = cam_pos[None, None, :] + ray_dirs * sphere_t[..., None]
sphere_points_hit = sphere_points[sphere_hit]
normals_hit = _normalize(sphere_points_hit - sphere_center[None, :])
view_dir_hit = _normalize(cam_pos[None, :] - sphere_points_hit)
diffuse = np.clip(np.sum(normals_hit * light_dir[None, :], axis=1), 0.0, 1.0)
half_vec = _normalize(light_dir[None, :] + view_dir_hit)
specular = np.clip(np.sum(normals_hit * half_vec, axis=1), 0.0, 1.0) ** 28.0
ambient = 0.2
diffuse_term = 0.68 * diffuse * float(intensity)
specular_term = 0.14 * specular * min(float(intensity), 2.0)
sphere_color = SPHERE_RGB[None, :] * np.clip((ambient + diffuse_term)[:, None], 0.0, 1.35)
sphere_color = np.clip(sphere_color + specular_term[:, None], 0.0, 1.0)
image[sphere_hit] = sphere_color
if np.any(plane_hit):
plane_points = cam_pos[None, None, :] + ray_dirs * plane_t[..., None]
plane_points_hit = plane_points[plane_hit]
occlusion = _batched_directional_shadow(plane_points_hit, light_dir, sphere_radius)
shadow_strength = 0.42 + 0.18 * min(float(intensity) / 3.0, 1.0)
plane_color = BACKGROUND_RGB[None, :] * (1.0 - shadow_strength * occlusion[:, None])
image[plane_hit] = np.clip(plane_color, 0.0, 1.0)
rgb = np.clip(image * 255.0, 0.0, 255.0).astype(np.uint8)
img = Image.fromarray(rgb, mode="RGB").resize((OUTPUT_SIZE, OUTPUT_SIZE), Image.LANCZOS)
# Match the original Sphere-Light-Render output feel more closely:
# keep the backend fallback slightly brighter and softer than the raw analytic render.
img = img.filter(ImageFilter.GaussianBlur(radius=FALLBACK_BLUR_RADIUS))
img = img.point(lambda value: min(255, value + FALLBACK_BRIGHTNESS_OFFSET))
return img
class TYOrbitSphereLightNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"azimuth": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 360.0,
"step": 1.0,
"display": "slider",
},
),
"elevation": (
"FLOAT",
{
"default": 45.0,
"min": -30.0,
"max": 90.0,
"step": 1.0,
"display": "slider",
},
),
"intensity": (
"FLOAT",
{
"default": 1.5,
"min": 0.2,
"max": 3.0,
"step": 0.1,
"display": "slider",
},
),
},
"optional": {
"render_b64": ("STRING", {"default": "", "multiline": False}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("render",)
FUNCTION = "execute"
CATEGORY = "TY/Relight"
OUTPUT_NODE = False
DESCRIPTION = (
"Interactive TY sphere light controller. "
"The node preview shows draggable gizmos, but the output image stays clean."
)
def execute(self, azimuth, elevation, intensity, render_b64=""):
if render_b64 and render_b64.startswith("data:image"):
try:
_, data = render_b64.split(",", 1)
img_bytes = base64.b64decode(data)
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
img = img.resize((OUTPUT_SIZE, OUTPUT_SIZE), Image.LANCZOS)
except Exception as exc:
print(f"[TYOrbitSphereLightNode] Error decoding render image: {exc}")
img = _render_backend_fallback(azimuth, elevation, intensity)
else:
img = _render_backend_fallback(azimuth, elevation, intensity)
arr = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(arr).unsqueeze(0)
return (tensor,)
NODE_CLASS_MAPPINGS = {
"TYOrbitSphereLightNode": TYOrbitSphereLightNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TYOrbitSphereLightNode": "TY Orbit Sphere Light",
}
WEB_DIRECTORY = "./js"
@@ -0,0 +1,166 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 1,
"type": "TYOrbitSphereLightNode",
"pos": [
120,
120
],
"size": [
340,
446
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"label": "azimuth",
"localized_name": "azimuth",
"name": "azimuth",
"type": "FLOAT",
"widget": {
"name": "azimuth"
},
"link": null
},
{
"label": "elevation",
"localized_name": "elevation",
"name": "elevation",
"type": "FLOAT",
"widget": {
"name": "elevation"
},
"link": null
},
{
"label": "intensity",
"localized_name": "intensity",
"name": "intensity",
"type": "FLOAT",
"widget": {
"name": "intensity"
},
"link": null
},
{
"label": "render_b64",
"localized_name": "render_b64",
"name": "render_b64",
"type": "STRING",
"widget": {
"name": "render_b64"
},
"link": null
}
],
"outputs": [
{
"label": "render",
"localized_name": "render",
"name": "render",
"type": "IMAGE",
"links": [
1
]
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "TYOrbitSphereLightNode"
},
"widgets_values": [
0,
45,
1.5
]
},
{
"id": 2,
"type": "SaveImage",
"pos": [
520,
160
],
"size": [
270,
58
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"label": "images",
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 1
},
{
"label": "filename_prefix",
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"label": "images",
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.27.0",
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"Node name for S&R": "SaveImage"
},
"widgets_values": [
"TY_Orbit_Sphere_Light"
]
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ue_links": [],
"ds": {
"scale": 0.9,
"offset": [
416,
110
]
}
},
"version": 0.4
}
+790
View File
@@ -0,0 +1,790 @@
import { app } from "../../scripts/app.js";
const LOCAL_THREE_URL = new URL("./vendor/three.min.js", import.meta.url).href;
const INTENSITY_MIN = 0.2;
const INTENSITY_MAX = 3.0;
const INTENSITY_T_MIN = 0.22;
const INTENSITY_T_MAX = 0.88;
const INTENSITY_AXIS_SCALE = 1.85;
const AZIMUTH_RING_RX = 0.3;
const AZIMUTH_RING_RY = 0.18;
const OVERLAY = {
center: { x: 0, y: 0, z: 0 },
ringY: 0,
azimuthRadius: 1.7,
elevationRadius: 1.55,
elevationPlaneX: -1.35,
lightVisualRadius: 2.55,
cameraPos: { x: 4.2, y: 3.2, z: 4.2 },
cameraTarget: { x: 0, y: -0.2, z: 0 },
fov: 45,
};
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function normalizeAzimuth(value) {
let angle = Number(value);
angle = ((angle % 360) + 360) % 360;
return angle;
}
function toRadians(deg) {
return (deg * Math.PI) / 180;
}
function toDegrees(rad) {
return (rad * 180) / Math.PI;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function vec3Add(a, b) {
return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z };
}
function vec3Sub(a, b) {
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
}
function vec3Mul(v, scalar) {
return { x: v.x * scalar, y: v.y * scalar, z: v.z * scalar };
}
function vec3Dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function vec3Cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
};
}
function vec3Norm(v) {
const len = Math.hypot(v.x, v.y, v.z);
if (len < 1e-6) {
return { x: 0, y: 0, z: 0 };
}
return { x: v.x / len, y: v.y / len, z: v.z / len };
}
function loadThree() {
return new Promise((resolve, reject) => {
if (window.THREE) {
resolve();
return;
}
const script = document.createElement("script");
script.src = LOCAL_THREE_URL;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
function buildCleanRenderScene() {
const R = window.THREE;
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
const renderer = new R.WebGLRenderer({
canvas,
antialias: true,
preserveDrawingBuffer: true,
});
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = R.PCFSoftShadowMap;
renderer.setSize(512, 512, false);
renderer.setClearColor(0x8a8a8a);
renderer.outputEncoding = R.sRGBEncoding;
const scene = new R.Scene();
scene.background = new R.Color(0x8a8a8a);
const camera = new R.PerspectiveCamera(35, 1, 0.1, 200);
camera.position.set(0, 6, 8);
camera.lookAt(0, -0.5, 0);
const plane = new R.Mesh(
new R.PlaneGeometry(100, 100),
new R.MeshStandardMaterial({ color: 0x8a8a8a, roughness: 1, metalness: 0 })
);
plane.rotation.x = -Math.PI / 2;
plane.position.y = -1;
plane.receiveShadow = true;
scene.add(plane);
const sphere = new R.Mesh(
new R.SphereGeometry(1, 64, 64),
new R.MeshStandardMaterial({ color: 0xcccccc, roughness: 0.8, metalness: 0 })
);
sphere.position.y = 0;
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);
scene.add(new R.AmbientLight(0xffffff, 0.2));
const dirLight = new R.DirectionalLight(0xffffff, 1.5);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 50;
dirLight.shadow.camera.left = -8;
dirLight.shadow.camera.right = 8;
dirLight.shadow.camera.top = 8;
dirLight.shadow.camera.bottom = -8;
dirLight.shadow.bias = -0.0005;
dirLight.shadow.radius = 2;
scene.add(dirLight);
// This scene intentionally stays "clean" so the exported IMAGE matches
// the original Sphere-Light-Render visual style without gizmos.
return { renderer, scene, camera, dirLight, canvas };
}
function findWidget(node, name) {
return node.widgets?.find((widget) => widget?.name === name) || null;
}
function getWidgetValue(node, name, fallback) {
const widget = findWidget(node, name);
if (!widget) return fallback;
const value = Number(widget.value);
return Number.isFinite(value) ? value : fallback;
}
function setWidgetValue(node, name, value) {
const widget = findWidget(node, name);
if (!widget) return;
widget.value = value;
widget.callback?.(value);
}
function requestNodeRedraw(node) {
node?.setDirtyCanvas?.(true, true);
app?.graph?.setDirtyCanvas?.(true, true);
}
function getVisibleWidgetHeight(node, widget) {
const width = node.size?.[0] || 320;
if (widget.hidden || widget.type === "hidden") return 0;
if (typeof widget.computeSize === "function") {
const size = widget.computeSize(width);
if (Array.isArray(size) && Number.isFinite(size[1])) {
return size[1];
}
}
return globalThis.LiteGraph?.NODE_WIDGET_HEIGHT ?? 20;
}
function getTopWidgetsHeight(node) {
let height = (globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30) + 8;
for (const widget of node.widgets ?? []) {
if (!widget) continue;
height += getVisibleWidgetHeight(node, widget) + 4;
}
return height;
}
function getPreviewRect(node) {
const nodeWidth = Math.max(node.size?.[0] || 320, 320);
const nodeHeight = Math.max(node.size?.[1] || 420, 420);
const topHeight = getTopWidgetsHeight(node);
const availableWidth = nodeWidth - 24;
const availableHeight = nodeHeight - topHeight - 16;
const side = Math.max(Math.min(availableWidth, availableHeight), 160);
return {
x: (nodeWidth - side) / 2,
y: topHeight,
side,
};
}
function getNodePose(node) {
return {
azimuth: normalizeAzimuth(getWidgetValue(node, "azimuth", 0)),
elevation: clamp(getWidgetValue(node, "elevation", 45), -30, 90),
intensity: clamp(getWidgetValue(node, "intensity", 1.5), INTENSITY_MIN, INTENSITY_MAX),
};
}
function projectPoint(projection, point) {
const view = vec3Sub(point, projection.camPos);
const xCam = vec3Dot(view, projection.right);
const yCam = vec3Dot(view, projection.up);
const zCam = vec3Dot(view, projection.forward);
if (zCam <= 0.05) return null;
return {
x: projection.cx + (xCam / zCam) * projection.focal,
y: projection.cy - (yCam / zCam) * projection.focal,
z: zCam,
};
}
function getAzimuthHandlePoint(rect, center2D, azimuthDeg) {
const azimuthRad = toRadians(azimuthDeg);
if (!rect || !center2D) return null;
const rx = rect.side * AZIMUTH_RING_RX;
const ry = rect.side * AZIMUTH_RING_RY;
return {
// azimuth = 0 starts at the bottom-center of the ring.
x: center2D.x + rx * Math.sin(azimuthRad),
y: center2D.y + ry * Math.cos(azimuthRad),
};
}
function getIntensityAxisEndPoint(center2D, elevationArcCenter2D) {
if (!center2D || !elevationArcCenter2D) return null;
return {
x: center2D.x + (elevationArcCenter2D.x - center2D.x) * INTENSITY_AXIS_SCALE,
y: center2D.y + (elevationArcCenter2D.y - center2D.y) * INTENSITY_AXIS_SCALE,
};
}
function solveAzimuthFromHandlePosition(state, localPos) {
if (!state || !Array.isArray(localPos) || localPos.length < 2) {
return null;
}
const [targetX, targetY] = localPos;
let bestAzimuth = state.pose.azimuth;
let bestDistanceSq = Infinity;
for (let azimuth = 0; azimuth < 360; azimuth += 1) {
const handle = getAzimuthHandlePoint(state.rect, state.center2D, azimuth);
if (!handle) continue;
const dx = handle.x - targetX;
const dy = handle.y - targetY;
const distanceSq = dx * dx + dy * dy;
if (distanceSq < bestDistanceSq) {
bestDistanceSq = distanceSq;
bestAzimuth = azimuth;
}
}
return bestAzimuth;
}
function screenPointToRay(projection, localPos) {
if (!projection || !Array.isArray(localPos) || localPos.length < 2) return null;
const [x, y] = localPos;
const nx = (x - projection.cx) / projection.focal;
const ny = (projection.cy - y) / projection.focal;
const dir = vec3Norm(
vec3Add(
vec3Add(vec3Mul(projection.right, nx), vec3Mul(projection.up, ny)),
projection.forward
)
);
return { origin: projection.camPos, dir };
}
function rayPlaneIntersection(ray, planeNormal, planePoint) {
if (!ray) return null;
const denom = vec3Dot(ray.dir, planeNormal);
if (Math.abs(denom) < 1e-6) return null;
const t = vec3Dot(vec3Sub(planePoint, ray.origin), planeNormal) / denom;
if (!Number.isFinite(t) || t <= 0) return null;
return vec3Add(ray.origin, vec3Mul(ray.dir, t));
}
function drawRoundClip(ctx, x, y, width, height, radius) {
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, width, height, radius);
} else {
ctx.rect(x, y, width, height);
}
}
function drawHandle(ctx, x, y, radius, fill, glow, isActive = false) {
ctx.save();
const glowRadius = isActive ? radius * 2.2 : radius * 1.7;
const gradient = ctx.createRadialGradient(x, y, radius * 0.25, x, y, glowRadius);
gradient.addColorStop(0, glow);
gradient.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(x, y, isActive ? radius * 1.08 : radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.beginPath();
ctx.arc(x - radius * 0.25, y - radius * 0.3, radius * 0.3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawPolyline(ctx, points, color, width) {
const valid = points.filter(Boolean);
if (valid.length < 2) return;
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(valid[0].x, valid[0].y);
for (let i = 1; i < valid.length; i += 1) {
ctx.lineTo(valid[i].x, valid[i].y);
}
ctx.stroke();
}
function buildOverlayState(node, rect) {
const pose = getNodePose(node);
const center = OVERLAY.center;
const azimuthRad = toRadians(pose.azimuth);
const elevationRad = toRadians(pose.elevation);
const cameraPos = OVERLAY.cameraPos;
const cameraTarget = OVERLAY.cameraTarget;
const forward = vec3Norm(vec3Sub(cameraTarget, cameraPos));
const right = vec3Norm(vec3Cross(forward, { x: 0, y: 1, z: 0 }));
const up = vec3Cross(right, forward);
const previewCx = rect.x + rect.side * 0.5;
const previewCy = rect.y + rect.side * 0.5;
const focal = (rect.side * 0.5) / Math.tan(toRadians(OVERLAY.fov) * 0.5);
const projection = {
camPos: cameraPos,
forward,
right,
up,
focal,
cx: previewCx,
cy: previewCy,
};
const lightWorld = {
x: OVERLAY.lightVisualRadius * Math.cos(elevationRad) * Math.sin(azimuthRad),
y: OVERLAY.lightVisualRadius * Math.sin(elevationRad),
z: OVERLAY.lightVisualRadius * Math.cos(elevationRad) * Math.cos(azimuthRad),
};
const elevationHandleWorld = {
x: OVERLAY.elevationPlaneX,
y: OVERLAY.elevationRadius * Math.sin(elevationRad),
z: OVERLAY.elevationRadius * Math.cos(elevationRad),
};
const intensityNorm = (pose.intensity - INTENSITY_MIN) / (INTENSITY_MAX - INTENSITY_MIN);
const intensityT =
INTENSITY_T_MIN + (1 - intensityNorm) * (INTENSITY_T_MAX - INTENSITY_T_MIN);
const ringPoints = [];
const elevationArcPoints = [];
for (let i = 0; i <= 48; i += 1) {
const angle = toRadians(-30 + (120 * i) / 48);
elevationArcPoints.push(
projectPoint(projection, {
x: OVERLAY.elevationPlaneX,
y: OVERLAY.elevationRadius * Math.sin(angle),
z: OVERLAY.elevationRadius * Math.cos(angle),
})
);
}
const center2D = projectPoint(projection, center);
const light2D = projectPoint(projection, lightWorld);
const elevationHandle2D = projectPoint(projection, elevationHandleWorld);
const elevationArcCenter2D = projectPoint(projection, {
x: OVERLAY.elevationPlaneX,
y: center.y,
z: center.z,
});
if (!center2D || !light2D || !elevationHandle2D || !elevationArcCenter2D) {
return null;
}
const intensityAxisEnd2D = getIntensityAxisEndPoint(center2D, elevationArcCenter2D);
if (!intensityAxisEnd2D) {
return null;
}
const intensityHandle2D = {
x: lerp(center2D.x, intensityAxisEnd2D.x, intensityT),
y: lerp(center2D.y, intensityAxisEnd2D.y, intensityT),
};
const azimuthHandle2D = getAzimuthHandlePoint(rect, center2D, pose.azimuth);
if (!azimuthHandle2D) {
return null;
}
ringPoints.length = 0;
for (let i = 0; i <= 80; i += 1) {
const point = getAzimuthHandlePoint(rect, center2D, (360 * i) / 80);
if (point) {
ringPoints.push(point);
}
}
return {
rect,
pose,
centerWorld: center,
projection,
center2D,
light2D,
elevationArcCenter2D,
intensityAxisEnd2D,
ringPoints,
elevationArcPoints,
handles: {
azimuth: { x: azimuthHandle2D.x, y: azimuthHandle2D.y, r: 9 },
elevation: { x: elevationHandle2D.x, y: elevationHandle2D.y, r: 9 },
intensity: { x: intensityHandle2D.x, y: intensityHandle2D.y, r: 9 },
},
};
}
function drawOverlay(ctx, node, state) {
const isActive = node._tyOrbitDragMode;
const rect = state.rect;
// The gizmo overlay is preview-only. Nothing drawn here is written into render_b64.
ctx.save();
drawRoundClip(ctx, rect.x, rect.y, rect.side, rect.side, 8);
ctx.clip();
drawPolyline(ctx, state.ringPoints, "rgba(233, 61, 130, 0.88)", 3.2);
drawPolyline(ctx, state.elevationArcPoints, "rgba(0, 255, 208, 0.88)", 3.2);
ctx.strokeStyle = "rgba(255, 184, 0, 0.88)";
ctx.lineWidth = 2.8;
ctx.beginPath();
ctx.moveTo(state.center2D.x, state.center2D.y);
ctx.lineTo(state.intensityAxisEnd2D.x, state.intensityAxisEnd2D.y);
ctx.stroke();
drawHandle(
ctx,
state.handles.azimuth.x,
state.handles.azimuth.y,
state.handles.azimuth.r,
"#E93D82",
"rgba(233, 61, 130, 0.36)",
isActive === "azimuth"
);
drawHandle(
ctx,
state.handles.elevation.x,
state.handles.elevation.y,
state.handles.elevation.r,
"#00FFD0",
"rgba(0, 255, 208, 0.36)",
isActive === "elevation"
);
drawHandle(
ctx,
state.handles.intensity.x,
state.handles.intensity.y,
state.handles.intensity.r,
"#FFB800",
"rgba(255, 184, 0, 0.38)",
isActive === "intensity"
);
ctx.restore();
}
function drawPreview(node, ctx) {
const rect = getPreviewRect(node);
node._tyOrbitOverlayState = buildOverlayState(node, rect);
ctx.save();
drawRoundClip(ctx, rect.x, rect.y, rect.side, rect.side, 8);
ctx.clip();
if (node._tyOrbitReady && node._tyOrbitCanvas) {
ctx.drawImage(node._tyOrbitCanvas, rect.x, rect.y, rect.side, rect.side);
}
ctx.restore();
if (node._tyOrbitOverlayState) {
drawOverlay(ctx, node, node._tyOrbitOverlayState);
}
}
function hitTest(node, localPos) {
const state = node._tyOrbitOverlayState;
if (!state || !Array.isArray(localPos) || localPos.length < 2) return null;
const [x, y] = localPos;
for (const [name, handle] of Object.entries(state.handles)) {
const dx = x - handle.x;
const dy = y - handle.y;
if (dx * dx + dy * dy <= handle.r * handle.r) {
return name;
}
}
const rect = state.rect;
if (x >= rect.x && x <= rect.x + rect.side && y >= rect.y && y <= rect.y + rect.side) {
return "preview";
}
return null;
}
function writePose(node, nextPose) {
setWidgetValue(node, "azimuth", Math.round(normalizeAzimuth(nextPose.azimuth)));
setWidgetValue(node, "elevation", Math.round(clamp(nextPose.elevation, -30, 90)));
setWidgetValue(
node,
"intensity",
Math.round(clamp(nextPose.intensity, INTENSITY_MIN, INTENSITY_MAX) * 10) / 10
);
requestNodeRedraw(node);
}
function updateAzimuth(node, localPos) {
const state = node._tyOrbitOverlayState;
const azimuth = solveAzimuthFromHandlePosition(state, localPos);
if (azimuth === null) return;
const pose = getNodePose(node);
writePose(node, { ...pose, azimuth });
}
function updateElevation(node, localPos) {
const state = node._tyOrbitOverlayState;
if (!state) return;
let elevation = null;
const ray = screenPointToRay(state.projection, localPos);
const hit = rayPlaneIntersection(
ray,
{ x: 1, y: 0, z: 0 },
{ x: OVERLAY.elevationPlaneX, y: 0, z: 0 }
);
if (hit) {
elevation = clamp(toDegrees(Math.atan2(hit.y, hit.z)), -30, 90);
}
if (elevation === null && Array.isArray(localPos) && localPos.length >= 2) {
const [x, y] = localPos;
const dx = x - state.elevationArcCenter2D.x;
const dy = state.elevationArcCenter2D.y - y;
elevation = clamp(toDegrees(Math.atan2(dy, dx)), -30, 90);
}
if (elevation === null) return;
const pose = getNodePose(node);
writePose(node, { ...pose, elevation });
}
function updateIntensity(node, localPos) {
const state = node._tyOrbitOverlayState;
if (!state || !Array.isArray(localPos) || localPos.length < 2) return;
const [x, y] = localPos;
const axisX = state.intensityAxisEnd2D.x - state.center2D.x;
const axisY = state.intensityAxisEnd2D.y - state.center2D.y;
const axisLenSq = axisX * axisX + axisY * axisY;
if (axisLenSq < 1) return;
const proj =
((x - state.center2D.x) * axisX + (y - state.center2D.y) * axisY) / axisLenSq;
const t = clamp(proj, INTENSITY_T_MIN, INTENSITY_T_MAX);
const norm = 1 - (t - INTENSITY_T_MIN) / (INTENSITY_T_MAX - INTENSITY_T_MIN);
const intensity = INTENSITY_MIN + norm * (INTENSITY_MAX - INTENSITY_MIN);
const pose = getNodePose(node);
writePose(node, { ...pose, intensity });
}
app.registerExtension({
name: "Comfy.TYOrbitSphereLight",
async nodeCreated(node) {
if (node.comfyClass !== "TYOrbitSphereLightNode") return;
await loadThree();
const renderCtx = buildCleanRenderScene();
node._tyOrbitRenderCtx = renderCtx;
node._tyOrbitCanvas = renderCtx.canvas;
node._tyOrbitReady = false;
node._tyOrbitOverlayState = null;
node._tyOrbitDragMode = null;
const doRender = () => {
const azimuth = getWidgetValue(node, "azimuth", 0);
const elevation = getWidgetValue(node, "elevation", 45);
const intensity = getWidgetValue(node, "intensity", 1.5);
const az = toRadians(azimuth);
const el = toRadians(elevation);
const radius = 10;
renderCtx.dirLight.position.set(
radius * Math.cos(el) * Math.sin(az),
radius * Math.sin(el),
radius * Math.cos(el) * Math.cos(az)
);
renderCtx.dirLight.intensity = intensity;
renderCtx.renderer.shadowMap.needsUpdate = true;
renderCtx.renderer.render(renderCtx.scene, renderCtx.camera);
const renderWidget = findWidget(node, "render_b64");
if (renderWidget) {
renderWidget.value = renderCtx.canvas.toDataURL("image/png");
}
node._tyOrbitReady = true;
requestNodeRedraw(node);
};
let renderTimer = null;
const scheduleRender = () => {
clearTimeout(renderTimer);
renderTimer = setTimeout(doRender, 30);
};
const hookSliders = () => {
for (const name of ["azimuth", "elevation", "intensity"]) {
const widget = findWidget(node, name);
if (!widget || widget._tyOrbitHooked) continue;
widget._tyOrbitHooked = true;
const originalCallback = widget.callback;
widget.callback = function callback(value, ...args) {
originalCallback?.call(this, value, ...args);
scheduleRender();
};
}
};
const refreshSelectionIfNeeded = () => {
if (!node.is_selected || !app?.canvas?.selectNode) return;
app.canvas.selectNode(null);
app.canvas.selectNode(node, false);
};
const hideRenderWidget = () => {
const widget = findWidget(node, "render_b64");
if (!widget || widget._tyOrbitHidden) return;
widget._tyOrbitHidden = true;
widget.computeSize = () => [0, 0];
widget.draw = () => {};
widget.type = "hidden";
const input = node.inputs?.find((entry) => entry?.name === "render_b64");
if (input) {
input.hidden = true;
}
};
const originalOnDrawForeground = node.onDrawForeground?.bind(node);
node.onDrawForeground = function onDrawForeground(ctx) {
drawPreview(this, ctx);
return originalOnDrawForeground?.(ctx);
};
const originalOnMouseDown = node.onMouseDown?.bind(node);
node.onMouseDown = function onMouseDown(event, localPos, graphCanvas) {
const hit = hitTest(this, localPos);
if (hit === "azimuth") {
this._tyOrbitDragMode = "azimuth";
updateAzimuth(this, localPos);
return true;
}
if (hit === "elevation") {
this._tyOrbitDragMode = "elevation";
updateElevation(this, localPos);
return true;
}
if (hit === "intensity") {
this._tyOrbitDragMode = "intensity";
updateIntensity(this, localPos);
return true;
}
return originalOnMouseDown?.(event, localPos, graphCanvas);
};
const originalOnMouseMove = node.onMouseMove?.bind(node);
node.onMouseMove = function onMouseMove(event, localPos, graphCanvas) {
if (this._tyOrbitDragMode && event?.buttons === 0) {
this._tyOrbitDragMode = null;
}
if (this._tyOrbitDragMode === "azimuth") {
updateAzimuth(this, localPos);
return true;
}
if (this._tyOrbitDragMode === "elevation") {
updateElevation(this, localPos);
return true;
}
if (this._tyOrbitDragMode === "intensity") {
updateIntensity(this, localPos);
return true;
}
return originalOnMouseMove?.(event, localPos, graphCanvas);
};
const originalOnMouseUp = node.onMouseUp?.bind(node);
node.onMouseUp = function onMouseUp(...args) {
this._tyOrbitDragMode = null;
return originalOnMouseUp?.(...args);
};
const originalOnMouseWheel = node.onMouseWheel?.bind(node);
node.onMouseWheel = function onMouseWheel(event, localPos, graphCanvas) {
if (hitTest(this, localPos) === "preview") {
return true;
}
return originalOnMouseWheel?.(event, localPos, graphCanvas);
};
const originalOnResize = node.onResize?.bind(node);
node.onResize = function onResize(size) {
size[0] = Math.max(size[0], 340);
const previewSide = clamp(size[0] - 24, 180, 360);
const minHeight = getTopWidgetsHeight(this) + previewSide + 16;
size[1] = Math.max(size[1], minHeight);
requestNodeRedraw(this);
return originalOnResize?.(size);
};
const originalOnRemoved = node.onRemoved?.bind(node);
node.onRemoved = function onRemoved(...args) {
clearTimeout(renderTimer);
this._tyOrbitRenderCtx?.renderer?.dispose?.();
this._tyOrbitRenderCtx = null;
this._tyOrbitCanvas = null;
this._tyOrbitOverlayState = null;
return originalOnRemoved?.(...args);
};
setTimeout(() => {
hideRenderWidget();
hookSliders();
doRender();
const rect = getPreviewRect(node);
const minHeight = getTopWidgetsHeight(node) + rect.side + 16;
node.setSize?.([Math.max(node.size?.[0] || 340, 340), Math.max(node.size?.[1] || minHeight, minHeight)]);
}, 50);
setTimeout(() => {
hideRenderWidget();
hookSliders();
requestNodeRedraw(node);
refreshSelectionIfNeeded();
}, 300);
},
});
+6
View File
File diff suppressed because one or more lines are too long