diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..0825204
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) [2025] [Temult]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Requirements.txt b/Requirements.txt
new file mode 100644
index 0000000..988ccbb
--- /dev/null
+++ b/Requirements.txt
@@ -0,0 +1,3 @@
+# requirements.txt for SigmaGraphNode
+# This custom node requires PyTorch to create the sigma schedule tensor.
+torch>=2.6.0
diff --git a/SigmaGraphNode.py b/SigmaGraphNode.py
index 941abe3..86295f5 100644
--- a/SigmaGraphNode.py
+++ b/SigmaGraphNode.py
@@ -1,213 +1,170 @@
# Import necessary libraries
-import json # For parsing the graph data string from the UI
-import torch # For creating the final tensor output
+import json
+import torch
+import math # Import math for isnan
-# Define the main node class
class SigmaGraphNode:
"""
A ComfyUI node that generates a sigma schedule tensor based on user-defined
- points edited via a custom graph widget in the UI. The schedule typically
- decreases from high sigma (noise) to low sigma.
+ points edited via a custom graph widget in the UI. It also outputs the
+ number of steps used. The schedule typically decreases from high sigma
+ (noise) to low sigma.
"""
+ # Define a small epsilon for floating point comparisons
+ EPSILON = 1e-6
@classmethod
def INPUT_TYPES(cls):
- """
- Specifies the inputs the node requires in the ComfyUI interface.
- """
- # Define the default shape of the graph (linear descent) as a JSON string.
- # This is used if no data is loaded or provided.
default_points = json.dumps([
- {"x": 0.0, "y": 1.0}, # Start point (step 0, progress 0.0) -> High Sigma
- {"x": 1.0, "y": 0.0} # End point (step N, progress 1.0) -> Low Sigma
+ {"x": 0.0, "y": 1.0},
+ {"x": 1.0, "y": 0.0}
])
return {
"required": {
- # 'steps': The number of diffusion steps the schedule is for.
- # The output sigma tensor will have 'steps + 1' values.
"steps": ("INT", {
- "default": 20,
- "min": 1, # Must have at least 1 step
- "max": 1000, # Reasonable upper limit for steps
+ "default": 20, "min": 1, "max": 1000,
}),
- # 'graph_data': A JSON string representing the list of points [{x, y}, ...].
- # This input uses a custom widget named 'sigma_graph' (defined in JS).
- # 'forceInput: False' allows using the default or connecting an input.
+ # 'graph_data': JSON string representing points [{x, y}, ...].
+ # This is now primarily for storing the state set by the JS widget.
+ # It no longer has a 'widget' association in Python.
"graph_data": ("STRING", {
"default": default_points,
- "widget": "sigma_graph", # Links to the custom JS widget
- "multiline": True, # Allows the widget to potentially use more space
- "forceInput": False, # Allows using the default value
+ "multiline": True, # Keep multiline for easier debugging if needed
}),
},
}
- # Define the output type and name
- RETURN_TYPES = ("SIGMAS",) # Standard ComfyUI type for sigma schedules
- RETURN_NAMES = ("sigmas",) # Name of the output slot in the UI
-
- # Specify the function to execute when the node runs
+ RETURN_TYPES = ("SIGMAS", "INT",)
+ RETURN_NAMES = ("sigmas", "steps",)
FUNCTION = "calculate_sigmas"
+ CATEGORY = "sampling/custom_sampling"
- # Define the category under which the node appears in the Add Node menu
- CATEGORY = "sampling/custom_sampling" # Or your preferred category
+ def _validate_and_clean_points(self, points_data_str):
+ """ Parses, validates, and cleans the points data. """
+ points = []
+ default_points_list = [{"x": 0.0, "y": 1.0}, {"x": 1.0, "y": 0.0}]
+ try:
+ points_data = json.loads(points_data_str)
+ if not isinstance(points_data, list):
+ raise ValueError("Graph data is not a list.")
+
+ # Filter for valid point structure and numeric types, check for NaN/Infinity
+ valid_points = []
+ for p in points_data:
+ if isinstance(p, dict) and 'x' in p and 'y' in p and \
+ isinstance(p['x'], (int, float)) and not math.isnan(p['x']) and not math.isinf(p['x']) and \
+ isinstance(p['y'], (int, float)) and not math.isnan(p['y']) and not math.isinf(p['y']):
+ valid_points.append({"x": float(p['x']), "y": float(p['y'])}) # Ensure float type
+ else:
+ print(f"[SigmaGraphNode Warning] Ignoring invalid point data: {p}")
+
+ points = valid_points
+ if len(points) != len(points_data):
+ print("[SigmaGraphNode Warning] Some points in graph_data were invalid and ignored.")
+
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
+ print(f"[SigmaGraphNode Warning] Invalid graph_data input: {e}. Using default points.")
+ return default_points_list # Return default on parse error
+
+ if not points:
+ print("[SigmaGraphNode Warning] No valid points found after filtering. Using default points.")
+ return default_points_list
+
+ # Ensure boundary points exist using a tolerance
+ has_start = any(abs(p['x'] - 0.0) < self.EPSILON for p in points)
+ has_end = any(abs(p['x'] - 1.0) < self.EPSILON for p in points)
+
+ if not has_start:
+ # Find point closest to x=0 to determine y-value, or default to 1.0
+ start_y = min(points, key=lambda p: abs(p['x'] - 0.0))['y'] if points else 1.0
+ points.append({"x": 0.0, "y": start_y})
+ print("[SigmaGraphNode Info] Added missing start point (x=0).")
+ if not has_end:
+ # Find point closest to x=1 to determine y-value, or default to 0.0
+ end_y = min(points, key=lambda p: abs(p['x'] - 1.0))['y'] if points else 0.0
+ points.append({"x": 1.0, "y": end_y})
+ print("[SigmaGraphNode Info] Added missing end point (x=1).")
+
+ # Sort points by x-coordinate
+ points.sort(key=lambda p: p["x"])
+
+ # Remove duplicate points based on x-coordinate with tolerance
+ unique_points = []
+ if points:
+ unique_points.append(points[0])
+ last_x = points[0]['x']
+ for i in range(1, len(points)):
+ if abs(points[i]["x"] - last_x) > self.EPSILON:
+ unique_points.append(points[i])
+ last_x = points[i]['x']
+ else:
+ print(f"[SigmaGraphNode Warning] Removing duplicate point near x={points[i]['x']}.")
+
+
+ # Ensure minimum number of points (e.g., 2 for interpolation)
+ if len(unique_points) < 2:
+ print("[SigmaGraphNode Warning] Not enough unique points after cleanup. Using default points.")
+ return default_points_list
+
+ return unique_points
def calculate_sigmas(self, steps, graph_data):
"""
- Calculates a sigma schedule tensor (torch.Tensor) with 'steps + 1' values.
-
- The schedule is generated by linearly interpolating between the control points
- defined in the 'graph_data' JSON string. It assumes the graph represents
- sigma values (y-axis) over normalized step progress (x-axis, 0.0 to 1.0).
-
- Args:
- steps (int): The number of sampling steps requested.
- graph_data (str): A JSON string representing a list of points, e.g.,
- '[{"x": 0.0, "y": 1.0}, {"x": 1.0, "y": 0.0}]'.
-
- Returns:
- tuple: A tuple containing a single element: the calculated sigma
- schedule as a torch.Tensor of dtype float32.
+ Calculates a sigma schedule tensor and returns it along with the steps.
"""
- # --- Input Validation and Parsing ---
- points = [] # Initialize empty list for points
- try:
- # Attempt to parse the JSON string from the input/widget
- points_data = json.loads(graph_data)
- # Ensure the parsed data is a list
- if not isinstance(points_data, list):
- raise ValueError("Graph data is not a list.")
- # Filter out any invalid point entries (must be dict with 'x' and 'y')
- points = [p for p in points_data if isinstance(p, dict) and 'x' in p and 'y' in p]
- # Warn if some points were filtered out
- if len(points) != len(points_data):
- print("[SigmaGraphNode Warning] Some points in graph_data were invalid and ignored.")
- except (json.JSONDecodeError, ValueError, TypeError) as e:
- # Catch potential errors during parsing or validation
- print(f"[SigmaGraphNode Warning] Invalid graph_data input: {e}. Using linear fallback.")
- # Ensure points is empty to trigger fallback logic below
- points = []
-
- # Determine the required number of sigma values (N steps need N+1 sigmas)
- num_sigmas = steps + 1
-
- # --- Fallback Logic ---
- # If parsing failed, or not enough points were provided for interpolation
- if not points or len(points) < 2:
- print("[SigmaGraphNode Info] Using linear sigma schedule fallback.")
- # Generate a simple linear schedule descending from 1.0 to near 0.0
- # Avoid division by zero if steps = 0 (though input min is 1)
- denominator = steps if steps > 0 else 1
- # Calculate sigma values using list comprehension
- sigma_values = [max(0.001, 1.0 - (i / denominator)) for i in range(num_sigmas)]
- # Convert the list to a PyTorch tensor
- sigmas_tensor = torch.tensor(sigma_values, dtype=torch.float32)
- # Provide debug output showing the first few values
- print(f"[SigmaGraphNode Debug] Fallback Sigmas (first 5): {sigmas_tensor[:5]}...")
- # Return the tensor in a tuple as required by ComfyUI
- return (sigmas_tensor,)
-
- # --- Interpolation Logic ---
- # Sort the valid points by their x-coordinate (step progress)
- points.sort(key=lambda p: p["x"])
-
- # Ensure boundary points exist at x=0 and x=1 for complete interpolation.
- # This guarantees the schedule covers the entire range [0.0, 1.0].
- if points[0]["x"] > 0:
- # If the first point doesn't start at x=0, prepend a point at x=0
- # using the y-value of the original first point.
- points.insert(0, {"x": 0.0, "y": points[0]["y"]})
- if points[-1]["x"] < 1:
- # If the last point doesn't end at x=1, append a point at x=1
- # using the y-value of the original last point.
- points.append({"x": 1.0, "y": points[-1]["y"]})
-
- # Remove duplicate points based on x-coordinate, keeping the first occurrence.
- # This prevents issues during interpolation if points overlap vertically.
- unique_points = []
- seen_x = set()
- for p in points:
- if p["x"] not in seen_x:
- unique_points.append(p)
- seen_x.add(p["x"])
- points = unique_points
-
- # Check again if we have enough unique points after cleanup
- if len(points) < 2:
- print("[SigmaGraphNode Warning] Not enough unique points after cleanup. Using linear fallback.")
- denominator = steps if steps > 0 else 1
- sigma_values = [max(0.001, 1.0 - (i / denominator)) for i in range(num_sigmas)]
- sigmas_tensor = torch.tensor(sigma_values, dtype=torch.float32)
- print(f"[SigmaGraphNode Debug] Fallback Sigmas (first 5): {sigmas_tensor[:5]}...")
- return (sigmas_tensor,)
+ steps = max(1, int(steps))
+ points = self._validate_and_clean_points(graph_data)
+ num_sigmas_to_generate = steps
# --- Perform Interpolation ---
- sigma_values = [] # List to store calculated sigma values
- current_point_idx = 0 # Index of the starting point for the current segment
+ sigma_values = []
+ current_point_idx = 0
- # Iterate to generate each required sigma value
- for i in range(num_sigmas):
- # Calculate the normalized progress for the current sigma index (0 to steps)
- # Avoid division by zero if steps = 0
- step_progress = i / steps if steps > 0 else 0.0
+ # Handle steps = 1 case separately for clarity
+ if steps == 1:
+ # Find point at x=0
+ start_point = next((p for p in points if abs(p['x'] - 0.0) < self.EPSILON), points[0])
+ sigma_values.append(max(0.001, start_point['y']))
+ else:
+ for i in range(num_sigmas_to_generate):
+ step_progress = i / (steps - 1)
+ step_progress = min(1.0, max(0.0, step_progress)) # Clamp progress [0, 1]
- # Advance the starting point index (`current_point_idx`) if the
- # `step_progress` has moved past the *next* point's x-coordinate.
- # This finds the correct segment [p1, p2] for the current `step_progress`.
- # We use '<' to ensure that if step_progress lands exactly on a point,
- # we use the segment starting *at* that point.
- while (current_point_idx < len(points) - 2 and
- points[current_point_idx + 1]["x"] < step_progress):
- current_point_idx += 1
+ # Find the correct segment [p1, p2] for interpolation
+ # Advance index while the *next* point's x is less than current progress
+ while (current_point_idx < len(points) - 2 and
+ points[current_point_idx + 1]["x"] < step_progress - self.EPSILON):
+ current_point_idx += 1
- # Get the pair of points defining the current segment for interpolation
- p1 = points[current_point_idx]
- # Ensure p2 is the next point, or clamp to the last point if already there
- p2 = points[min(current_point_idx + 1, len(points) - 1)]
+ p1 = points[current_point_idx]
+ # Ensure p2 is the next point, or clamp to the last point if already there
+ p2 = points[min(current_point_idx + 1, len(points) - 1)]
- # Calculate the difference in x-coordinates for the segment
- x_diff = p2["x"] - p1["x"]
+ x_diff = p2["x"] - p1["x"]
+ sigma = 0.0
- # Perform linear interpolation (lerp) between p1.y and p2.y
- sigma = 0.0
- if x_diff <= 1e-6: # Use a small epsilon to handle floating point comparison
- # If points have the same x-coordinate (or are extremely close),
- # avoid division by zero. Choose the y-value based on which point
- # the step_progress is closer to (or use p1's y if exactly on p1).
- if step_progress <= p1["x"] or abs(step_progress - p1["x"]) < abs(step_progress - p2["x"]):
- sigma = p1["y"]
+ # Check for vertical segment or identical points
+ if x_diff <= self.EPSILON:
+ # If progress is closer to p2.x, use p2.y, otherwise use p1.y
+ sigma = p2["y"] if abs(step_progress - p2["x"]) < abs(step_progress - p1["x"]) else p1["y"]
else:
- sigma = p2["y"]
- else:
- # Calculate the interpolation factor (ratio) within the segment
- ratio = (step_progress - p1["x"]) / x_diff
- # Clamp ratio to [0, 1] to prevent extrapolation due to float errors
- ratio = max(0.0, min(1.0, ratio))
- # Calculate the interpolated sigma value
- sigma = p1["y"] + ratio * (p2["y"] - p1["y"])
+ # Standard linear interpolation
+ # Clamp progress to the segment bounds before calculating ratio
+ clamped_progress = max(p1["x"], min(p2["x"], step_progress))
+ ratio = (clamped_progress - p1["x"]) / x_diff
+ # Ensure ratio is valid [0, 1] due to potential float errors
+ ratio = max(0.0, min(1.0, ratio))
+ sigma = p1["y"] + ratio * (p2["y"] - p1["y"])
- # Clamp the calculated sigma to a minimum value (e.g., 0.001).
- # This prevents issues with log(0) or division by zero in some samplers.
- # 0.0 might also be acceptable depending on the specific sampler used later.
- sigma_values.append(max(0.001, sigma))
+ # Ensure sigma is positive and non-zero
+ sigma_values.append(max(0.001, sigma))
# Convert the final list of sigma values to a PyTorch tensor
sigmas_tensor = torch.tensor(sigma_values, dtype=torch.float32)
- # Provide debug output showing the first few calculated values
- print(f"[SigmaGraphNode Debug] Calculated Sigmas (first 5): {sigmas_tensor[:5]}...")
+ # print(f"[SigmaGraphNode Debug] Calculated Sigmas ({len(sigma_values)} values, first 5): {sigmas_tensor[:5]}...")
+ return (sigmas_tensor, steps,)
- # Return the result as a tuple containing the tensor
- return (sigmas_tensor,)
-
-# --- Node Registration for ComfyUI ---
-
-# Map the Python class to the node's internal name
-NODE_CLASS_MAPPINGS = {
- "SigmaGraphNode": SigmaGraphNode
-}
-
-# (Optional) Map the internal name to a user-friendly display name in the UI
-NODE_DISPLAY_NAME_MAPPINGS = {
- "SigmaGraphNode": "Sigma Schedule Graph"
-}
+# --- Node Registration ---
+NODE_CLASS_MAPPINGS = { "SigmaGraphNode": SigmaGraphNode }
+NODE_DISPLAY_NAME_MAPPINGS = { "SigmaGraphNode": "Sigma Schedule Graph" }
diff --git a/__init__.py b/__init__.py
index 5c1a519..ceb1ae1 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,12 +1,6 @@
-# __init__.py in your sigma_graph folder
-# This file tells ComfyUI about the custom node class and where to find its UI widget code.
-
-from .SigmaGraphNode import NODE_CLASS_MAPPINGS
-
-WEB_DIRECTORY = "./js" # Serve .js files in the "js" folder as an extension.
-
-NODE_DISPLAY_NAME_MAPPINGS = {}
-
+# __init__.py
+from .SigmaGraphNode import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
+WEB_DIRECTORY = "./js"
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
diff --git a/js/SigmaGraphWidget.js b/js/SigmaGraphWidget.js
index 2c7d781..e0227af 100644
--- a/js/SigmaGraphWidget.js
+++ b/js/SigmaGraphWidget.js
@@ -1,801 +1,911 @@
// SigmaGraphWidget.js
-// Provides a custom UI widget for editing a sigma schedule graph.
+// Refactored to use the addDOMWidget API
+console.log(window.LiteGraph);
+console.log(window.app);
+console.log(window.ui);
+console.log("[SigmaGraphWidget] top-level: file loaded successfully");
-import { app } from "../../scripts/app.js";
-import { $el } from "../../scripts/ui.js";
+import { app } from "/../scripts/app.js";
+import { $el } from "/../scripts/ui.js";
+//import { LiteGraph } from "/scripts/litegraph.js";
/** Node and widget constants */
const nodeClassName = "SigmaGraphNode";
-const graphWidgetName = "graph_data";
+const graphDataWidgetName = "graph_data"; // Name of the hidden STRING widget
const stepsWidgetName = "steps";
+const customWidgetName = "sigma_graph_ui"; // Name for our new custom DOM widget
/** How many preset slots we allow for saving/loading graph shapes. */
const numPresetSlots = 9;
+/** Minimum number of points allowed on the graph */
+const minGraphPoints = 3;
+
+// --- Helper Functions (now mostly standalone) ---
/**
- * A local JS utility to approximate the same sigma schedule you do in Python,
- * but used only for the text area preview inside the widget.
+ * Calculates sigma values for the text preview based on points and steps.
*/
function calculateSigmasJS(points, steps) {
const sigmaValues = [];
- if (steps <= 0) return [];
- if (steps === 1) {
- // If there's only 1 step, return the y-value at x=0 (if found), else default to 1.0
- const startPoint = points?.find((p) => p.x === 0);
- return [startPoint ? Math.max(0.001, startPoint.y) : 1.0];
+ steps = Math.max(1, Math.floor(steps));
+
+ if (!points || points.length < 1) {
+ for (let i = 0; i < steps; i++) { sigmaValues.push(1.0); }
+ return sigmaValues;
}
- // Defensive copy of the points
- let localPoints = JSON.parse(JSON.stringify(points || []));
+ if (steps === 1) {
+ const startPoint = points.find((p) => Math.abs(p.x - 0.0) < 1e-6);
+ return [startPoint ? Math.max(0.001, startPoint.y) : (points[0]?.y ?? 1.0)];
+ }
+
+ let localPoints = JSON.parse(JSON.stringify(points)); // Deep copy
if (!Array.isArray(localPoints) || localPoints.length < 2) {
- // Fallback to linear if no valid points
+ const yVal = localPoints[0]?.y ?? 1.0;
for (let i = 0; i < steps; i++) {
- sigmaValues.push(1.0 - i / (steps - 1));
+ sigmaValues.push(Math.max(0.001, yVal - (yVal * i) / (steps - 1)));
}
return sigmaValues;
}
- // Sort by x, ensure boundary points at x=0 and x=1
localPoints.sort((a, b) => a.x - b.x);
- if (localPoints[0].x > 0) {
+ if (localPoints[0].x > 1e-6) { // Tolerance for boundary check
localPoints.unshift({ x: 0.0, y: localPoints[0].y });
}
- if (localPoints[localPoints.length - 1].x < 1) {
+ if (localPoints[localPoints.length - 1].x < 1.0 - 1e-6) { // Tolerance
localPoints.push({ x: 1.0, y: localPoints[localPoints.length - 1].y });
}
- // Interpolate
let currentPointIndex = 0;
for (let i = 0; i < steps; i++) {
- const stepProgress = i / (steps - 1);
+ const stepProgress = steps === 1 ? 0 : i / (steps - 1);
while (
currentPointIndex < localPoints.length - 2 &&
- localPoints[currentPointIndex + 1].x < stepProgress
+ localPoints[currentPointIndex + 1].x < stepProgress - 1e-6 // Tolerance
) {
currentPointIndex++;
}
const p1 = localPoints[currentPointIndex];
const p2 = localPoints[currentPointIndex + 1] || p1;
let sigma;
- if (p2.x <= p1.x) {
+ const x_diff = p2.x - p1.x;
+ if (x_diff <= 1e-6) {
sigma = p1.y;
} else {
- const interpFactor = (stepProgress - p1.x) / (p2.x - p1.x);
+ const clampedProgress = Math.max(p1.x, Math.min(p2.x, stepProgress));
+ const interpFactor = (clampedProgress - p1.x) / x_diff;
sigma = p1.y + interpFactor * (p2.y - p1.y);
}
sigmaValues.push(Math.max(0.001, sigma));
}
-
return sigmaValues;
}
/**
- * Initialize the custom graph UI for the given widget on the node.
+ * Generates `numTargetPoints` points by interpolating along `existingPoints`.
*/
-function setupSigmaGraph(widget, node) {
- // Create a container for everything
- const container = $el("div", {
- style: { display: "flex", flexDirection: "column", gap: "10px" },
- });
- widget.element = container;
-
- // ----------------------------------------------------------------
- // 1. A row for the sigma text area and the stacked buttons
- const sigmaDisplayContainer = $el("div", {
- style: { display: "flex", flexDirection: "row", gap: "5px" },
- });
- const sigmaDisplay = $el("textarea", {
- placeholder: "Sigma values...",
- style: {
- flexGrow: "1",
- minHeight: "45px",
- fontSize: "0.9em",
- background: "#181818",
- color: "#ccc",
- border: "1px solid #555",
- borderRadius: "3px",
- boxSizing: "border-box",
- resize: "auto",
- fontFamily: "monospace",
- padding: "2px 4px",
- },
- });
- widget.sigmaDisplayEl = sigmaDisplay;
- sigmaDisplayContainer.appendChild(sigmaDisplay);
-
- // Instead of one tall button, we have a container with two stacked buttons:
- const stackedButtonsContainer = $el("div", {
- style: {
- display: "flex",
- flexDirection: "column",
- alignItems: "stretch",
- width: "20px",
- height: "42px",
- borderRadius: "3px",
- overflow: "hidden",
- border: "1px solid #555",
- background: "#444",
- },
- });
- sigmaDisplayContainer.appendChild(stackedButtonsContainer);
- container.appendChild(sigmaDisplayContainer);
-
- // Top half: "ℹ" info button
- const infoButton = $el("button", {
- textContent: "ℹ", // Unicode info symbol
- style: {
- flex: "1 1 auto",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- fontSize: "13px",
- border: "none",
- borderBottom: "1px solid #555", // thin border separating from bottom button
- background: "transparent",
- color: "#ccc",
- cursor: "pointer",
- lineHeight: "normal",
- },
- title: "Show instructions about this node",
- });
- stackedButtonsContainer.appendChild(infoButton);
-
- // Bottom half: the down-arrow apply button
- const applyButton = $el("button", {
- textContent: "▼",
- style: {
- flex: "1 1 auto",
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
- fontSize: "13px",
- border: "none",
- background: "transparent",
- color: "#ccc",
- cursor: "pointer",
- lineHeight: "normal",
- },
- title: "Apply sigma values from the text area to the graph points",
- });
- stackedButtonsContainer.appendChild(applyButton);
-
- // 1a. The overlay or note for instructions (initially hidden):
- const infoOverlay = $el("div", {
- style: {
- position: "absolute",
- top: "55%",
- left: "50%",
- transform: "translate(-50%, -50%)",
- width: "240px",
- background: "#222",
- color: "#ccc",
- border: "1px solid #555",
- borderRadius: "6px",
- padding: "8px 12px",
- fontSize: "11px",
- lineHeight: "1.4",
- textAlign: "center",
- display: "none",
- zIndex: "9999",
- pointerEvents: "none", // so it doesn't block interaction
- },
- });
- infoOverlay.innerHTML = `
-
- Sigma Schedule Editor
- Bidirectional editor for sigma schedules.
- Use graph or text input interchangeably.
- Click ▼ to apply text to graph.
- Drag, double-click, or right-click to edit points.
- Special thanks to huchenlei for js help!!
-
- `;
-
- container.appendChild(infoOverlay);
-
- infoButton.addEventListener("click", () => {
- // Toggle the overlay's visibility
- infoOverlay.style.display = (infoOverlay.style.display === "none")
- ? "block"
- : "none";
- });
-
-
- // ----------------------------------------------------------------
- // 2. The canvas for interactive editing
- // ----------------------------------------------------------------
- const canvasContainer = $el("div", {
- style: {
- position: "relative",
- width: "100%",
- height: "100%",
- boxSizing: "border-box",
- cursor: "crosshair",
- overflow: "visible",
- },
- });
- const canvas = $el("canvas", {
- style: {
- flexGrow: "1",
- width: "100%",
- height: "120px",
- background: "#282828",
- display: "block",
- minHeight: "40px",
- border: "2px solid #555",
- borderRadius: "3px",
- boxSizing: "border-box",
- resize: "both",
- },
- });
- canvasContainer.appendChild(canvas);
- container.appendChild(canvasContainer);
-
- // ----------------------------------------------------------------
- // 3. A row of Save/Load preset slots
- // ----------------------------------------------------------------
- const presetContainer = $el("div", {
- style: {
- display: "inline-flex",
- flexDirection: "row",
- alignItems: "center",
- gap: "5px",
- marginTop: "0px",
- },
- });
- container.appendChild(presetContainer);
-
- // Single toggle button for "save mode" on/off
- const saveModeButton = $el("button", {
- textContent: "💾",
- style: {
- width: "30px",
- height: "30px",
- borderRadius: "3px",
- border: "1px solid #555",
- background: "#96322f",
- color: "#fff",
- fontSize: "15px",
- cursor: "pointer",
- padding: "0",
- lineHeight: "30px",
- textAlign: "center",
- },
- title: "Toggle Save Mode (then click a slot to save). Otherwise, click to load.",
- });
- presetContainer.appendChild(saveModeButton);
-
- widget._recordMode = false;
- widget._saveSlots = new Array(numPresetSlots).fill(null);
- widget._saveSlotButtons = [];
-
- // Retrieve previously saved slots from localStorage.
- const savedSlotsJSON = localStorage.getItem("sigma_graph_saveSlots");
- if (savedSlotsJSON) {
- try {
- const savedSlots = JSON.parse(savedSlotsJSON);
- if (Array.isArray(savedSlots) && savedSlots.length === numPresetSlots) {
- widget._saveSlots = savedSlots;
- }
- } catch (err) {
- console.error("Error parsing saved slots from localStorage:", err);
+function interpolatePoints(existingPoints, numTargetPoints) {
+ if (numTargetPoints < 2 || !existingPoints || existingPoints.length < 2) {
+ return [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
}
- }
-
- // Create each preset slot
- for (let i = 0; i < numPresetSlots; i++) {
- const slotContainer = $el("div", {
- style: {
- width: "30px",
- height: "30px",
- display: "inline-block",
- },
- });
- const slotButton = $el("button", {
- textContent: `${i + 1}`,
- style: {
- width: "100%",
- height: "100%",
- boxSizing: "border-box",
- padding: "0",
- borderRadius: "3px",
- border: "1px solid #555",
- background: "#333",
- color: "#fff",
- fontSize: "15px",
- cursor: "pointer",
- },
- title: `Slot #${i + 1}`,
- });
- slotButton.addEventListener("click", () => {
- if (widget._recordMode) {
- // Save to this slot
- widget._saveSlots[i] = widget.value;
- localStorage.setItem("sigma_graph_saveSlots", JSON.stringify(widget._saveSlots));
- widget._recordMode = false;
- updateSaveSlotVisibility();
- } else {
- // Load from this slot if available
- if (widget._saveSlots[i]) {
- widget.value = widget._saveSlots[i];
- try {
- widget._points = JSON.parse(widget.value);
- } catch (e) {
- console.error(
- `[SigmaGraphWidget] Error parsing saved profile in slot ${i + 1}:`,
- e
- );
- }
- widget.drawCurve?.();
- widget.updateSigmaDisplay?.();
- console.log(`[SigmaGraphWidget] Loaded from slot ${i + 1}`);
- }
- }
- });
- widget._saveSlotButtons.push(slotButton);
- slotContainer.appendChild(slotButton);
- presetContainer.appendChild(slotContainer);
- }
-
- function updateSaveSlotVisibility() {
- widget._saveSlotButtons.forEach((btn, idx) => {
- // In record mode, show all slots
- if (widget._recordMode) {
- btn.style.visibility = "visible";
- btn.style.backgroundColor = "#4caf50";
- } else {
- // If a slot is saved, make it visible; otherwise, hide it
- if (widget._saveSlots[idx]) {
- btn.style.visibility = "visible";
- btn.style.backgroundColor = "#273648";
- } else {
- btn.style.visibility = "hidden";
- }
- }
- });
- }
- updateSaveSlotVisibility();
-
- saveModeButton.addEventListener("click", () => {
- widget._recordMode = !widget._recordMode;
- updateSaveSlotVisibility();
- // Also update the toggle button’s background color
- saveModeButton.style.background = widget._recordMode ? "#4caf50" : "#333";
- });
-
- // ----------------------------------------------------------------
- // 4. Hook up references
- // ----------------------------------------------------------------
- widget.sigmaDisplayContainerEl = sigmaDisplayContainer;
- widget.canvasContainerEl = canvasContainer;
- widget.presetContainerEl = presetContainer;
-
- // Hide the default input element (the raw text field) if present
- if (widget.inputEl) widget.inputEl.style.display = "none";
-
- /**
- * A method to re-generate the sigma values in text area, given the current points and steps.
- */
- widget.updateSigmaDisplay = function () {
- if (!widget.sigmaDisplayEl) return;
- const stepsWidget = node.widgets.find((w) => w.name === stepsWidgetName);
- if (!stepsWidget) {
- widget.sigmaDisplayEl.value = "Error: steps widget not found.";
- return;
- }
- const steps = stepsWidget.value;
- const sigmas = calculateSigmasJS(widget._points, steps);
- widget.sigmaDisplayEl.value = sigmas.map((s) => s.toFixed(3)).join(", ");
- };
-
- /**
- * Redraw the curve on the canvas.
- */
- widget.drawCurve = function () {
- const canvasEl = widget.canvasContainerEl.querySelector("canvas");
- if (!canvasEl) return;
- const rect = canvasEl.getBoundingClientRect();
- // Resize the canvas if needed
- if (
- Math.abs(canvasEl.width - rect.width) > 1 ||
- Math.abs(canvasEl.height - rect.height) > 1
- ) {
- canvasEl.width = rect.width;
- canvasEl.height = rect.height;
- }
- const ctx = canvasEl.getContext("2d");
- ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
-
- // Draw background grid
- ctx.strokeStyle = "#444";
- ctx.lineWidth = 0.5;
- ctx.beginPath();
- for (let i = 1; i < 10; i++) {
- const vx = (i * canvasEl.width) / 10;
- const vy = (i * canvasEl.height) / 10;
- ctx.moveTo(vx, 0);
- ctx.lineTo(vx, canvasEl.height);
- ctx.moveTo(0, vy);
- ctx.lineTo(canvasEl.width, vy);
- }
- ctx.stroke();
-
- // Sort points by x
- const sortedPoints = [...widget._points].sort((a, b) => a.x - b.x);
-
- // Draw the line
- ctx.strokeStyle = "white";
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- sortedPoints.forEach((pt, idx) => {
- const px = pt.x * canvasEl.width;
- const py = (1 - pt.y) * canvasEl.height;
- if (idx === 0) ctx.moveTo(px, py);
- else ctx.lineTo(px, py);
- });
- ctx.stroke();
-
- // Draw the control points
- widget._points.forEach((pt, idx) => {
- // Compute ratio for interpolation across the total number of points.
- const ratio = idx / (widget._points.length - 1);
- // Define start and end colors:
- const startColor = { r: 61, g: 3, b: 0 }; // Black
- const endColor = { r: 255, g: 0, b: 0 }; // Red
-
- // Linear interpolation for each channel:
- const r = Math.round(startColor.r + ratio * (endColor.r - startColor.r));
- const g = Math.round(startColor.g + ratio * (endColor.g - startColor.g));
- const b = Math.round(startColor.b + ratio * (endColor.b - startColor.b));
-
- ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
-
- const px = pt.x * canvasEl.width;
- const py = (1 - pt.y) * canvasEl.height;
- ctx.beginPath();
- ctx.arc(px, py, 4, 0, Math.PI * 2);
- ctx.fill();
- });
-
-
- };
-
- // ----------------------------------------------------------------
- // 5. Updating the underlying widget value (the JSON string)
- // whenever the user modifies points
- // ----------------------------------------------------------------
- widget._points = [];
- const updateValue = (function () {
- let timer = null;
- return function () {
- clearTimeout(timer);
- timer = setTimeout(() => {
- widget._points.sort((a, b) => a.x - b.x);
- const serialized = JSON.stringify(widget._points);
- if (widget.value !== serialized) {
- widget.value = serialized;
- if (widget.callback) widget.callback(widget.value);
- node.setDirtyCanvas(true, true);
- // ALSO refresh the text area with new sigmas
- widget.updateSigmaDisplay?.();
- }
- }, 80);
- };
- })();
-
- // ----------------------------------------------------------------
- // 6. “Apply” button event:
- // Rebuild points from typed sigma values, one point per step
- // ----------------------------------------------------------------
- applyButton.addEventListener("click", () => {
- const stepsWidget = node.widgets.find((w) => w.name === stepsWidgetName);
- if (!stepsWidget) {
- alert("Error: 'steps' widget not found.");
- return;
- }
- const steps = stepsWidget.value;
- const textValue = sigmaDisplay.value.trim();
- if (!textValue) {
- alert("Error: Sigma text input is empty.");
- return;
- }
- const parts = textValue.split(",").map((s) => s.trim()).filter((s) => s !== "");
- const parsedSigmas = parts.map(Number);
-
- if (parsedSigmas.some(isNaN)) {
- alert("Error: Input contains non-numeric values.");
- return;
- }
- if (parsedSigmas.length !== steps) {
- alert(`Error: Expected ${steps} sigma values, got ${parsedSigmas.length}.`);
- return;
- }
-
- // Rebuild the entire _points array from typed sigmas
const newPoints = [];
- if (steps === 1) {
- newPoints.push({ x: 0, y: parsedSigmas[0] });
+ const sortedExisting = [...existingPoints].sort((a, b) => a.x - b.x);
+ if (sortedExisting[0].x > 1e-6) {
+ sortedExisting.unshift({ x: 0.0, y: sortedExisting[0].y });
+ }
+ if (sortedExisting[sortedExisting.length - 1].x < 1.0 - 1e-6) {
+ sortedExisting.push({ x: 1.0, y: sortedExisting[sortedExisting.length - 1].y });
+ }
+
+ let currentSegmentIndex = 0;
+ for (let i = 0; i < numTargetPoints; i++) {
+ const targetX = numTargetPoints === 1 ? 0 : i / (numTargetPoints - 1);
+ while (
+ currentSegmentIndex < sortedExisting.length - 2 &&
+ sortedExisting[currentSegmentIndex + 1].x < targetX - 1e-6 // Tolerance
+ ) {
+ currentSegmentIndex++;
+ }
+ const p1 = sortedExisting[currentSegmentIndex];
+ const p2 = sortedExisting[currentSegmentIndex + 1] || p1;
+ let interpolatedY;
+ const segmentXDiff = p2.x - p1.x;
+ if (segmentXDiff <= 1e-6) {
+ interpolatedY = p1.y;
+ } else {
+ const clampedTargetX = Math.max(p1.x, Math.min(p2.x, targetX));
+ const interpFactor = (clampedTargetX - p1.x) / segmentXDiff;
+ interpolatedY = p1.y + interpFactor * (p2.y - p1.y);
+ }
+ newPoints.push({ x: targetX, y: Math.max(0.001, interpolatedY) });
+ }
+
+ const uniqueNewPoints = [];
+ const seenX = new Set();
+ for (const p of newPoints) {
+ let isDuplicate = false;
+ for(const sx of seenX) { if (Math.abs(p.x - sx) < 1e-6) { isDuplicate = true; break; } }
+ if (!isDuplicate) { uniqueNewPoints.push(p); seenX.add(p.x); }
+ }
+ if (uniqueNewPoints.length > 0) {
+ if (!uniqueNewPoints.some(p => Math.abs(p.x - 0.0) < 1e-6))
+ uniqueNewPoints.unshift({x: 0, y: newPoints[0].y});
+ if (!uniqueNewPoints.some(p => Math.abs(p.x - 1.0) < 1e-6))
+ uniqueNewPoints.push({x: 1, y: newPoints[newPoints.length-1].y});
} else {
- for (let i = 0; i < steps; i++) {
- const x = i / (steps - 1);
- const y = parsedSigmas[i];
- newPoints.push({ x, y });
- }
+ return [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
}
- widget._points = newPoints;
-
- widget.drawCurve();
- updateValue();
- });
-
- // ----------------------------------------------------------------
- // 7. Mouse interactions on the canvas: drag, add, remove points
- // ----------------------------------------------------------------
- widget._draggingIndex = -1;
- widget._rightClickIndex = -1;
-
- const handleMouseUp = () => {
- if (widget._draggingIndex >= 0) {
- widget._draggingIndex = -1;
- canvas.style.cursor = "crosshair";
- updateValue();
- }
- };
- document.addEventListener("mouseup", handleMouseUp, true);
-
- canvas.addEventListener("mousedown", (evt) => {
- evt.stopPropagation();
- const rect = canvas.getBoundingClientRect();
- const x = evt.clientX - rect.left;
- const y = evt.clientY - rect.top;
- const clickRadiusSq = 64;
- widget._draggingIndex = -1;
- widget._rightClickIndex = -1;
- let closestDistSq = Infinity;
- let clickedIndex = -1;
-
- widget._points.forEach((pt, i) => {
- const px = pt.x * canvas.width;
- const py = (1 - pt.y) * canvas.height;
- const distSq = (px - x) ** 2 + (py - y) ** 2;
- if (distSq < clickRadiusSq && distSq < closestDistSq) {
- closestDistSq = distSq;
- clickedIndex = i;
- }
- });
-
- // Left button = drag, right button = mark for removal
- if (clickedIndex !== -1) {
- if (evt.button === 0) {
- widget._draggingIndex = clickedIndex;
- canvas.style.cursor = "grabbing";
- } else if (evt.button === 2) {
- widget._rightClickIndex = clickedIndex;
- }
- }
- });
-
- canvas.addEventListener("mousemove", (evt) => {
- const rect = canvas.getBoundingClientRect();
- const x = evt.clientX - rect.left;
- const y = evt.clientY - rect.top;
- if (widget._draggingIndex >= 0) {
- evt.stopPropagation();
- let nx = x / canvas.width;
- let ny = 1 - y / canvas.height;
- nx = Math.max(0, Math.min(1, nx));
- ny = Math.max(0, Math.min(1, ny));
- const point = widget._points[widget._draggingIndex];
- // Keep boundary points pinned at x=0 or x=1
- if (point.x === 0 || point.x === 1) {
- nx = point.x;
- }
- widget._points[widget._draggingIndex] = { x: nx, y: ny };
- widget.drawCurve();
- updateValue();
- } else {
- // Cursor feedback if near a point
- let hovering = false;
- const hoverRadiusSq = 64;
- widget._points.forEach((pt) => {
- const px = pt.x * canvas.width;
- const py = (1 - pt.y) * canvas.height;
- if ((px - x) ** 2 + (py - y) ** 2 < hoverRadiusSq) hovering = true;
- });
- canvas.style.cursor = hovering ? "grab" : "crosshair";
- }
- });
-
- canvas.addEventListener("dblclick", (evt) => {
- // Double-click to add a new control point
- evt.stopPropagation();
- const rect = canvas.getBoundingClientRect();
- let nx = (evt.clientX - rect.left) / canvas.width;
- let ny = 1 - (evt.clientY - rect.top) / canvas.height;
- nx = Math.max(0, Math.min(1, nx));
- ny = Math.max(0, Math.min(1, ny));
- widget._points.push({ x: nx, y: ny });
- widget.drawCurve();
- updateValue();
- });
-
- canvas.addEventListener("contextmenu", (evt) => {
- // Right-click (context menu) to remove a point (except boundaries)
- evt.preventDefault();
- evt.stopPropagation();
- if (widget._rightClickIndex >= 0) {
- const pt = widget._points[widget._rightClickIndex];
- // Only remove if it's not the forced boundary
- if (pt.x > 0 && pt.x < 1) {
- widget._points.splice(widget._rightClickIndex, 1);
- widget.drawCurve();
- updateValue();
- } else {
- console.log("[SigmaGraphWidget] Can't remove boundary points at x=0 or x=1.");
- }
- }
- widget._rightClickIndex = -1;
- });
-
- // ----------------------------------------------------------------
- // 8. Initialize the widget with any existing data
- // ----------------------------------------------------------------
- let initialValue =
- widget.value ?? widget.options?.default ?? '[{"x":0,"y":1},{"x":1,"y":0}]';
- try {
- widget._points = JSON.parse(initialValue);
- // Force boundary points if missing
- if (!widget._points.some((p) => p.x === 0)) widget._points.push({ x: 0, y: 1 });
- if (!widget._points.some((p) => p.x === 1)) widget._points.push({ x: 1, y: 0 });
- widget._points.sort((a, b) => a.x - b.x);
- // Remove duplicates
- widget._points = widget._points.filter(
- (p, i, arr) => !(i > 0 && p.x === arr[i - 1].x)
- );
- widget.value = JSON.stringify(widget._points);
- } catch (e) {
- console.error("[SigmaGraphWidget] Failed to parse initial JSON:", e);
- widget._points = [
- { x: 0, y: 1 },
- { x: 1, y: 0 },
- ];
- widget.value = JSON.stringify(widget._points);
- }
- widget.updateSigmaDisplay?.();
-
- // ----------------------------------------------------------------
- // 9. Observe size changes for the canvas container
- // ----------------------------------------------------------------
- widget.resizeObserver = new ResizeObserver(() => {
- const canvasEl = widget.canvasContainerEl.querySelector("canvas");
- if (!canvasEl) return;
- requestAnimationFrame(() => {
- widget.drawCurve?.();
- });
- });
- widget.resizeObserver.observe(widget.canvasContainerEl);
-
- // Cleanup if the widget is removed
- const originalOnRemoved = widget.onRemoved;
- widget.onRemoved = function () {
- document.removeEventListener("mouseup", handleMouseUp, true);
- if (widget.resizeObserver) {
- widget.resizeObserver.disconnect();
- widget.resizeObserver = null;
- }
- if (originalOnRemoved) originalOnRemoved.call(widget);
- };
+ return uniqueNewPoints.sort((a,b) => a.x - b.x);
}
/**
- * Register the extension with ComfyUI, patching the node definition.
+ * Redraws the curve on the canvas.
*/
+function drawCurve(widgetState) {
+ const canvasEl = widgetState.canvasEl;
+ if (!canvasEl || !widgetState._points) return;
+ const rect = canvasEl.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ const dpr = window.devicePixelRatio || 1;
+ const displayWidth = Math.round(rect.width);
+ const displayHeight = Math.round(rect.height);
+ if (canvasEl.width !== displayWidth * dpr || canvasEl.height !== displayHeight * dpr) {
+ canvasEl.width = displayWidth * dpr;
+ canvasEl.height = displayHeight * dpr;
+ canvasEl.style.width = `${displayWidth}px`;
+ canvasEl.style.height = `${displayHeight}px`;
+ }
+
+ const ctx = canvasEl.getContext("2d");
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, displayWidth, displayHeight);
+
+ ctx.strokeStyle = "#444";
+ ctx.lineWidth = 0.5;
+ ctx.beginPath();
+ for (let i = 1; i < 10; i++) {
+ const vx = (i * displayWidth) / 10;
+ const vy = (i * displayHeight) / 10;
+ ctx.moveTo(vx, 0);
+ ctx.lineTo(vx, displayHeight);
+ ctx.moveTo(0, vy);
+ ctx.lineTo(displayWidth, vy);
+ }
+ ctx.stroke();
+
+ const sortedPoints = [...widgetState._points].sort((a, b) => a.x - b.x);
+ if (sortedPoints.length < 2) return;
+ ctx.strokeStyle = "white";
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ sortedPoints.forEach((pt, idx) => {
+ const px = pt.x * displayWidth;
+ const py = (1 - pt.y) * displayHeight;
+ if (idx === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
+ });
+ ctx.stroke();
+
+ widgetState._points.forEach((pt, idx) => {
+ const ratio = widgetState._points.length > 1 ? idx / (widgetState._points.length - 1) : 0;
+ const startColor = { r: 61, g: 3, b: 0 };
+ const endColor = { r: 255, g: 0, b: 0 };
+ const r = Math.round(startColor.r + ratio * (endColor.r - startColor.r));
+ const g = Math.round(startColor.g + ratio * (endColor.g - startColor.g));
+ const b = Math.round(startColor.b + ratio * (endColor.b - startColor.b));
+ ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
+ const px = pt.x * displayWidth;
+ const py = (1 - pt.y) * displayHeight;
+ ctx.beginPath();
+ ctx.arc(px, py, 4, 0, Math.PI * 2);
+ ctx.fill();
+ });
+}
+
+/**
+ * Updates the sigma text area display.
+ */
+function updateSigmaDisplay(widgetState) {
+ if (!widgetState.sigmaDisplayEl || !widgetState.stepsWidget || !widgetState._points) return;
+ const steps = widgetState.stepsWidget.value;
+ const sigmas = calculateSigmasJS(widgetState._points, steps);
+ widgetState.sigmaDisplayEl.value = sigmas.map((s) => s.toFixed(3)).join(", ");
+}
+
+/**
+ * Updates the enabled state of the remove (-) button.
+ */
+function updateRemoveButtonState(widgetState) {
+ if (!widgetState.removeButtonEl || !widgetState._points) return;
+ const canRemove = widgetState._points.length > minGraphPoints;
+ widgetState.removeButtonEl.disabled = !canRemove;
+ widgetState.removeButtonEl.style.opacity = canRemove ? "1" : "0.5";
+ widgetState.removeButtonEl.style.cursor = canRemove ? "pointer" : "not-allowed";
+}
+
+// --- Main Extension Registration ---
+
app.registerExtension({
name: "sigma_graph.widget",
- async beforeRegisterNodeDef(nodeType, nodeData) {
- // Only patch the SigmaGraphNode
+ async beforeRegisterNodeDef(nodeType, nodeData, appInstance) {
if (nodeData.name !== nodeClassName) return;
- // === Make the node non-resizable and force a fixed size ===
- nodeType.prototype.resizable = false;
- nodeType.prototype.computeSize = function () {
- // Width=290, Height=280, as an example. Adjust to taste.
- return [290, 280];
- };
-
- // Remove or no-op any onResize if it exists, because we've locked the size
- if (nodeType.prototype.onResize) {
- nodeType.prototype.onResize = function () {
- // No-op: size is fixed
- };
- }
-
- // Patch onConfigure to set up the custom widget if needed
+ // Patch onConfigure to set up the custom widget
const originalOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
- if (originalOnConfigure) {
- originalOnConfigure.call(this, info);
- }
- const graphWidget = this.widgets?.find((w) => w.name === graphWidgetName);
- if (
- graphWidget &&
- (!graphWidget.element || graphWidget.element.tagName !== "DIV")
- ) {
- setupSigmaGraph(graphWidget, this);
- }
- requestAnimationFrame(() => {
- this.setSize(this.computeSize());
- });
- };
+ originalOnConfigure?.apply(this, arguments);
+ if (this.widgets?.find(w => w.name === customWidgetName)) { return; }
+ console.log(`[SigmaGraphWidget] ${this.id}: Patched onConfigure running - setting up widget.`);
- // Patch onRemoved to clean up the widget
- const originalOnRemoved = nodeType.prototype.onRemoved;
- nodeType.prototype.onRemoved = function () {
- // --- Defensive Check ---
- // Check if 'this' is valid and has 'widgets' before proceeding.
- if (!this || !this.widgets) {
- // Log a warning to the console so you know this happened.
- console.warn(
- "[SigmaGraphWidget] onRemoved called with invalid context or missing widgets. Skipping custom cleanup.",
- this // Log the problematic 'this' value if needed
- );
- // Attempt to call the original onRemoved if it exists,
- // passing whatever 'this' context we received (even if undefined).
- if (originalOnRemoved) {
- try {
- // Use Reflect.apply for safer calling if 'this' might be totally invalid
- Reflect.apply(originalOnRemoved, this, []);
- } catch (e) {
- console.error(
- "[SigmaGraphWidget] Error calling original onRemoved:",
- e
- );
+ const dataWidget = this.widgets.find((w) => w.name === graphDataWidgetName);
+ const stepsWidget = this.widgets.find((w) => w.name === stepsWidgetName);
+ if (!dataWidget) { console.error(`[SigmaGraphWidget] ${this.id}: Could not find data widget '${graphDataWidgetName}'!`); return; }
+ if (!stepsWidget) { console.error(`[SigmaGraphWidget] ${this.id}: Could not find steps widget '${stepsWidgetName}'!`); }
+
+ if (dataWidget.inputEl) { dataWidget.inputEl.style.display = "none"; }
+ try {
+ const widgetIndex = this.widgets.findIndex(w => w === dataWidget);
+ if (widgetIndex !== -1 && this.inputs && this.inputs[widgetIndex]) {
+ const slotElement = this.graphcanvas?.canvas?.querySelector(`.node_${this.id}_slot_${widgetIndex}`);
+ if (slotElement?.previousElementSibling?.tagName === 'LABEL') {
+ slotElement.previousElementSibling.style.display = 'none';
+ }
}
- }
- return; // Exit the function early to prevent the TypeError
- }
- // --- End Defensive Check ---
+ } catch(e) { console.warn("[SigmaGraphWidget] Error trying to hide label:", e); }
- // If the check passed, 'this' and 'this.widgets' should be valid
- const graphWidget = this.widgets.find((w) => w.name === graphWidgetName); // Can remove ?. now
- if (graphWidget && graphWidget.onRemoved) {
- // Ensure the widget's own onRemoved exists and call it
- graphWidget.onRemoved();
- } else if (graphWidget) {
- // Fallback cleanup if widget.onRemoved wasn't assigned correctly
- console.warn(
- "[SigmaGraphWidget] graphWidget found, but widget.onRemoved is missing. Performing fallback cleanup."
- );
- if (graphWidget.resizeObserver) {
- graphWidget.resizeObserver.disconnect();
- graphWidget.resizeObserver = null;
+ // Build the root element for the widget UI
+ const rootElement = $el("div", {
+ className: "sigma-graph-widget-container",
+ style: { width: "100%", height: "100%", padding: "5px", boxSizing: "border-box", display: "flex", flexDirection: "column", gap: "10px" },
+ });
+
+ // Initialize widget state and store key elements in widgetState object
+ const widgetState = {
+ _points: [],
+ _saveSlots: new Array(numPresetSlots).fill(null),
+ _saveSlotButtons: [],
+ _recordMode: false,
+ _draggingIndex: -1,
+ _rightClickIndex: -1,
+ rootElement: rootElement,
+ node: this,
+ dataWidget: dataWidget,
+ stepsWidget: stepsWidget,
+ sigmaDisplayEl: null,
+ canvasContainerEl: null,
+ canvasEl: null,
+ presetContainerEl: null,
+ addButtonEl: null,
+ removeButtonEl: null,
+ refreshButton: null, // We'll assign the refresh button here
+ originalStepsCallback: stepsWidget ? stepsWidget.callback : null,
+ saveTimeout: null,
+ boundMouseUpHandler: null,
+ };
+
+ const saveStateToDataWidget = () => {
+ clearTimeout(widgetState.saveTimeout);
+ widgetState.saveTimeout = setTimeout(() => {
+ widgetState._points.sort((a, b) => a.x - b.x);
+ const serialized = JSON.stringify(widgetState._points);
+ if (widgetState.dataWidget.value !== serialized) {
+ widgetState.dataWidget.value = serialized;
+ }
+ }, 100);
+ };
+
+ // --- Build the UI ---
+
+ // Sigma display and button grid container
+ const sigmaDisplayContainer = $el("div", { style: { display: "flex", flexDirection: "row", gap: "5px", alignItems: "stretch" } });
+ const sigmaDisplay = $el("textarea", {
+ placeholder: "Sigma values...",
+ style: {
+ flexGrow: "1",
+ minHeight: "5px",
+ fontSize: "0.9em",
+ background: "#181818",
+ color: "#ccc",
+ border: "1px solid #555",
+ borderRadius: "3px",
+ boxSizing: "border-box",
+ resize: "auto",
+ fontFamily: "monospace",
+ padding: "2px 4px",
}
- // Add any other manual cleanup needed here if widget.onRemoved fails
+ });
+ widgetState.sigmaDisplayEl = sigmaDisplay;
+ sigmaDisplayContainer.appendChild(sigmaDisplay);
+
+ const buttonGridContainer = $el("div", { style: { display: "flex", flexDirection: "row", gap: "1px", height: "45px" } });
+ sigmaDisplayContainer.appendChild(buttonGridContainer);
+ rootElement.appendChild(sigmaDisplayContainer);
+
+ // Left column for refresh & advanced buttons
+ const leftButtonColumn = $el("div", { style: { display: "flex", flexDirection: "column", alignItems: "stretch", width: "20px", borderRadius: "3px", overflow: "hidden", border: "1px solid #555", background: "#444" } });
+ buttonGridContainer.appendChild(leftButtonColumn);
+
+ // Create refreshButton and attach it to widgetState.refreshButton
+ widgetState.refreshButton = $el("button", {
+ textContent: "🔄",
+ style: {
+ flex: "1 1 auto",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "13px",
+ border: "none",
+ borderBottom: "1px solid #555",
+ background: "transparent",
+ color: "#ccc",
+ cursor: "pointer",
+ lineHeight: "normal",
+ padding: "0"
+ },
+ title: "Refresh text display from current graph/steps"
+ });
+ leftButtonColumn.appendChild(widgetState.refreshButton);
+
+ // Create advancedButton (for demonstration purposes)
+ const advancedButton = $el("button", {
+ textContent: "⚙️",
+ style: {
+ flex: "1 1 auto",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "13px",
+ border: "none",
+ background: "transparent",
+ color: "#666",
+ cursor: "not-allowed",
+ lineHeight: "normal",
+ padding: "0"
+ },
+ title: "Advanced Mode (Not Implemented)"
+ });
+ leftButtonColumn.appendChild(advancedButton);
+
+ // Right column for info & apply buttons
+ const rightButtonColumn = $el("div", { style: { display: "flex", flexDirection: "column", alignItems: "stretch", width: "20px", borderRadius: "3px", overflow: "hidden", border: "1px solid #555", background: "#444" } });
+ buttonGridContainer.appendChild(rightButtonColumn);
+
+ const infoButton = $el("button", {
+ textContent: "ℹ",
+ style: {
+ flex: "1 1 auto",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "13px",
+ border: "none",
+ borderBottom: "1px solid #555",
+ background: "transparent",
+ color: "#ccc",
+ cursor: "pointer",
+ lineHeight: "normal",
+ padding: "0"
+ },
+ title: "Show instructions about this node"
+ });
+ rightButtonColumn.appendChild(infoButton);
+
+ const applyButton = $el("button", {
+ textContent: "▼",
+ style: {
+ flex: "1 1 auto",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "13px",
+ border: "none",
+ background: "transparent",
+ color: "#ccc",
+ cursor: "pointer",
+ lineHeight: "normal",
+ padding: "0"
+ },
+ title: "Apply sigma values from the text area to the graph points"
+ });
+ rightButtonColumn.appendChild(applyButton);
+
+ // Info overlay element
+ const infoOverlay = $el("div", {
+ style: {
+ position: "absolute",
+ top: "55%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ width: "240px",
+ background: "#222",
+ color: "#ccc",
+ border: "1px solid #555",
+ borderRadius: "6px",
+ padding: "8px 12px",
+ fontSize: "11px",
+ lineHeight: "1.4",
+ textAlign: "center",
+ display: "none",
+ zIndex: "9999",
+ pointerEvents: "none"
+ }
+ });
+ infoOverlay.innerHTML = `
+ Sigma Schedule Editor
+ Bidirectional editor for sigma schedules.
+ 🔄: Refresh text from graph/steps.
+ ▼: Apply text to graph points.
+ Drag, double-click, right-click to edit points.
+ Use +/- on graph to adjust density.
+ Special thanks to huchenlei for js help!!
+
`;
+ rootElement.appendChild(infoOverlay);
+
+ // Canvas container and canvas element
+ const canvasContainer = $el("div", {
+ style: {
+ position: "relative",
+ width: "100%",
+ flexGrow: "1",
+ minHeight: "50px",
+ boxSizing: "border-box",
+ cursor: "crosshair",
+ overflow: "hidden"
+ }
+ });
+ const canvas = $el("canvas", {
+ style: {
+ display: "block",
+ width: "100%",
+ height: "100%",
+ background: "#282828",
+ border: "2px solid #555",
+ borderRadius: "3px",
+ boxSizing: "border-box"
+ }
+ });
+ widgetState.canvasContainerEl = canvasContainer;
+ widgetState.canvasEl = canvas;
+ canvasContainer.appendChild(canvas);
+ rootElement.appendChild(canvasContainer);
+
+ // Add and remove buttons for graph points
+ const addButton = $el("button", {
+ textContent: "+",
+ style: {
+ position: "absolute",
+ top: "4px",
+ right: "30px",
+ width: "22px",
+ height: "22px",
+ fontSize: "16px",
+ lineHeight: "20px",
+ textAlign: "center",
+ padding: "0",
+ background: "#4CAF50",
+ color: "white",
+ border: "1px solid #388E3C",
+ borderRadius: "3px",
+ cursor: "pointer",
+ zIndex: "10"
+ },
+ title: "Add points (increase density)"
+ });
+ canvasContainer.appendChild(addButton);
+ widgetState.addButtonEl = addButton;
+
+ const removeBtn = $el("button", {
+ textContent: "-",
+ style: {
+ position: "absolute",
+ top: "4px",
+ right: "4px",
+ width: "22px",
+ height: "22px",
+ fontSize: "18px",
+ lineHeight: "18px",
+ textAlign: "center",
+ padding: "0",
+ background: "#f44336",
+ color: "white",
+ border: "1px solid #d32f2f",
+ borderRadius: "3px",
+ cursor: "pointer",
+ zIndex: "10"
+ },
+ title: "Remove points (decrease density)"
+ });
+ canvasContainer.appendChild(removeBtn);
+ widgetState.removeButtonEl = removeBtn;
+
+ // Preset container and save mode button
+ const presetContainer = $el("div", {
+ style: { display: "inline-flex", flexDirection: "row", alignItems: "center", gap: "5px", marginTop: "0px" }
+ });
+ widgetState.presetContainerEl = presetContainer;
+ rootElement.appendChild(presetContainer);
+
+ const saveModeButton = $el("button", {
+ textContent: "💾",
+ style: {
+ width: "30px",
+ height: "30px",
+ borderRadius: "3px",
+ border: "1px solid #555",
+ background: "#96322f",
+ color: "#fff",
+ fontSize: "15px",
+ cursor: "pointer",
+ padding: "0",
+ lineHeight: "30px",
+ textAlign: "center"
+ },
+ title: "Toggle Save Mode (then click a slot to save). Otherwise, click to load."
+ });
+ presetContainer.appendChild(saveModeButton);
+
+ // Load existing preset slots from localStorage
+ const savedSlotsJSON = localStorage.getItem("sigma_graph_saveSlots");
+ if (savedSlotsJSON) {
+ try {
+ const savedSlots = JSON.parse(savedSlotsJSON);
+ if (Array.isArray(savedSlots) && savedSlots.length === numPresetSlots) {
+ widgetState._saveSlots = savedSlots;
+ }
+ } catch (err) {
+ console.error("Error parsing saved slots:", err);
+ }
}
- // Call the original function with the (now assumed valid) 'this' context
- if (originalOnRemoved) {
- originalOnRemoved.call(this);
+ const updateSaveSlotVisibility = () => {
+ widgetState._saveSlotButtons.forEach((btn, idx) => {
+ if (widgetState._recordMode) {
+ btn.style.visibility = "visible";
+ btn.style.backgroundColor = "#4caf50";
+ } else {
+ if (widgetState._saveSlots[idx]) {
+ btn.style.visibility = "visible";
+ btn.style.backgroundColor = "#273648";
+ } else {
+ btn.style.visibility = "hidden";
+ }
+ }
+ });
+ saveModeButton.style.background = widgetState._recordMode ? "#4caf50" : "#333";
+ };
+
+ for (let i = 0; i < numPresetSlots; i++) {
+ const slotContainer = $el("div", { style: { width: "30px", height: "30px", display: "inline-block" } });
+ const slotButton = $el("button", {
+ textContent: `${i + 1}`,
+ style: { width: "100%", height: "100%", boxSizing: "border-box", padding: "0", borderRadius: "3px", border: "1px solid #555", background: "#333", color: "#fff", fontSize: "15px", cursor: "pointer" },
+ title: `Slot #${i + 1}`
+ });
+ slotButton.addEventListener("click", () => {
+ if (widgetState._recordMode) {
+ widgetState._saveSlots[i] = JSON.stringify(widgetState._points);
+ localStorage.setItem("sigma_graph_saveSlots", JSON.stringify(widgetState._saveSlots));
+ widgetState._recordMode = false;
+ updateSaveSlotVisibility();
+ } else {
+ if (widgetState._saveSlots[i]) {
+ try {
+ const loadedPoints = JSON.parse(widgetState._saveSlots[i]);
+ customWidget.value = loadedPoints;
+ } catch (e) {
+ console.error(`Error parsing slot ${i + 1}:`, e);
+ }
+ }
+ }
+ });
+ widgetState._saveSlotButtons.push(slotButton);
+ slotContainer.appendChild(slotButton);
+ presetContainer.appendChild(slotContainer);
}
+ updateSaveSlotVisibility();
+ saveModeButton.addEventListener("click", () => {
+ widgetState._recordMode = !widgetState._recordMode;
+ updateSaveSlotVisibility();
+ });
+
+ // --- Define Options for addDOMWidget ---
+ const widgetOptions = {
+ setValue: (value) => {
+ let parsedPoints = [];
+ try {
+ if (typeof value === 'string') {
+ parsedPoints = JSON.parse(value || '[]');
+ } else if (Array.isArray(value)) {
+ parsedPoints = value;
+ } else {
+ throw new Error("Invalid value type");
+ }
+ if (!Array.isArray(parsedPoints))
+ throw new Error("Parsed value is not an array");
+ } catch (e) {
+ console.error("[SigmaGraphWidget] setValue error:", e);
+ parsedPoints = [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
+ }
+ if (parsedPoints.length < 1)
+ parsedPoints = [ { x: 0, y: 1 }, { x: 1, y: 0 } ];
+ if (!parsedPoints.some((p) => Math.abs(p.x - 0.0) < 1e-6))
+ parsedPoints.push({ x: 0, y: 1 });
+ if (!parsedPoints.some((p) => Math.abs(p.x - 1.0) < 1e-6))
+ parsedPoints.push({ x: 1, y: 0 });
+ parsedPoints.sort((a, b) => a.x - b.x);
+ const uniquePoints = [];
+ const seenX = new Set();
+ for(const p of parsedPoints) {
+ let isDuplicate = false;
+ for(const sx of seenX) {
+ if (Math.abs(p.x - sx) < 1e-6) { isDuplicate = true; break; }
+ }
+ if (!isDuplicate) { uniquePoints.push(p); seenX.add(p.x); }
+ }
+ parsedPoints = uniquePoints;
+ while (parsedPoints.length < minGraphPoints) {
+ const mid = Math.floor(parsedPoints.length / 2);
+ const p1 = parsedPoints[mid-1];
+ const p2 = parsedPoints[mid];
+ parsedPoints.splice(mid, 0, { x: (p1.x+p2.x)/2, y: (p1.y+p2.y)/2 });
+ }
+ widgetState._points = parsedPoints;
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ saveStateToDataWidget();
+ },
+ getValue: () => {
+ widgetState._points.sort((a, b) => a.x - b.x);
+ return JSON.stringify(widgetState._points);
+ },
+ afterResize: () => {
+ requestAnimationFrame(() => drawCurve(widgetState));
+ },
+ };
+
+ // --- Add the Custom DOM Widget ---
+ const customWidget = this.addDOMWidget(customWidgetName, "custom", rootElement, widgetOptions);
+ if (!customWidget) {
+ console.error(`[SigmaGraphWidget] ${this.id}: Failed to add DOM widget!`);
+ return;
+ }
+
+ // --- Attach Event Listeners ---
+ widgetState.refreshButton.addEventListener("click", () => { updateSigmaDisplay(widgetState); });
+ advancedButton.addEventListener("click", () => { /* No action */ });
+ infoButton.addEventListener("click", () => { infoOverlay.style.display = (infoOverlay.style.display === "none") ? "block" : "none"; });
+ applyButton.addEventListener("click", () => {
+ const textValue = widgetState.sigmaDisplayEl.value.trim();
+ if (!textValue) return;
+ const parts = textValue.split(",").map(s => s.trim()).filter(s => s !== "");
+ const parsedSigmas = parts.map(Number);
+ if (parsedSigmas.some(isNaN) || parsedSigmas.length === 0) return;
+ let pointsChanged = false;
+ widgetState._points.sort((a, b) => a.x - b.x);
+ widgetState._points.forEach((pt) => {
+ let targetIndex;
+ if (parsedSigmas.length === 1) targetIndex = 0;
+ else {
+ targetIndex = Math.round(pt.x * (parsedSigmas.length - 1));
+ targetIndex = Math.max(0, Math.min(parsedSigmas.length - 1, targetIndex));
+ }
+ const newY = Math.max(0.001, parsedSigmas[targetIndex]);
+ if (pt.y !== newY) { pt.y = newY; pointsChanged = true; }
+ });
+ if (pointsChanged) {
+ drawCurve(widgetState);
+ saveStateToDataWidget();
+ widgetState.node.setDirtyCanvas(true, true);
+ }
+ });
+ addButton.addEventListener("click", () => {
+ const numCurrent = widgetState._points.length;
+ const numToAdd = Math.max(1, Math.floor(numCurrent * 0.5));
+ widgetState._points = interpolatePoints(widgetState._points, numCurrent + numToAdd);
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ saveStateToDataWidget();
+ });
+ removeBtn.addEventListener("click", () => {
+ const numCurrent = widgetState._points.length;
+ if (numCurrent <= minGraphPoints) return;
+ const numToRemove = Math.max(1, Math.floor(numCurrent * 0.5));
+ let targetNumPoints = Math.max(minGraphPoints, numCurrent - numToRemove);
+ const actualNumToRemove = numCurrent - targetNumPoints;
+ if (actualNumToRemove <= 0) return;
+ const candidates = [];
+ const sortedPoints = [...widgetState._points].sort((a, b) => a.x - b.x);
+ for (let i = 1; i < sortedPoints.length - 1; i++) {
+ const p_prev = sortedPoints[i - 1];
+ const p_curr = sortedPoints[i];
+ const p_next = sortedPoints[i + 1];
+ const x_diff = p_next.x - p_prev.x;
+ let interpolatedY;
+ if (x_diff <= 1e-6) interpolatedY = p_prev.y;
+ else {
+ const ratio = (p_curr.x - p_prev.x) / x_diff;
+ interpolatedY = p_prev.y + ratio * (p_next.y - p_prev.y);
+ }
+ candidates.push({ point: p_curr, distance: Math.abs(p_curr.y - interpolatedY) });
+ }
+ candidates.sort((a, b) => a.distance - b.distance);
+ const pointsToRemove = new Set(candidates.slice(0, actualNumToRemove).map(c => c.point));
+ widgetState._points = widgetState._points.filter(pt => !pointsToRemove.has(pt));
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ saveStateToDataWidget();
+ });
+
+ widgetState.boundMouseUpHandler = () => {
+ if (widgetState._draggingIndex >= 0) {
+ widgetState._draggingIndex = -1;
+ if (widgetState.canvasEl) widgetState.canvasEl.style.cursor = "crosshair";
+ saveStateToDataWidget();
+ }
+ };
+ document.addEventListener("mouseup", widgetState.boundMouseUpHandler, true);
+
+ canvas.addEventListener("mousedown", (evt) => {
+ if (evt.target === addButton || evt.target === removeBtn) return;
+ evt.stopPropagation();
+ const rect = widgetState.canvasEl.getBoundingClientRect();
+ const x = evt.clientX - rect.left;
+ const y = evt.clientY - rect.top;
+ const clickRadiusSq = 64;
+ widgetState._draggingIndex = -1;
+ widgetState._rightClickIndex = -1;
+ let closestDistSq = Infinity;
+ let clickedIndex = -1;
+ widgetState._points.forEach((pt, i) => {
+ const px = pt.x * rect.width;
+ const py = (1 - pt.y) * rect.height;
+ const distSq = (px - x) ** 2 + (py - y) ** 2;
+ if (distSq < clickRadiusSq && distSq < closestDistSq) {
+ closestDistSq = distSq;
+ clickedIndex = i;
+ }
+ });
+ if (clickedIndex !== -1) {
+ if (evt.button === 0) {
+ widgetState._draggingIndex = clickedIndex;
+ widgetState.canvasEl.style.cursor = "grabbing";
+ } else if (evt.button === 2) {
+ widgetState._rightClickIndex = clickedIndex;
+ }
+ }
+ });
+ canvas.addEventListener("mousemove", (evt) => {
+ if (evt.target === addButton || evt.target === removeBtn) return;
+ const rect = widgetState.canvasEl.getBoundingClientRect();
+ const x = evt.clientX - rect.left;
+ const y = evt.clientY - rect.top;
+ if (widgetState._draggingIndex >= 0) {
+ evt.stopPropagation();
+ let nx = x / rect.width;
+ let ny = 1 - y / rect.height;
+ nx = Math.max(0, Math.min(1, nx));
+ ny = Math.max(0, Math.min(1, ny));
+ const point = widgetState._points[widgetState._draggingIndex];
+ if (point.x === 0 || point.x === 1) nx = point.x;
+ widgetState._points[widgetState._draggingIndex] = { x: nx, y: ny };
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ saveStateToDataWidget();
+ } else {
+ let hovering = false;
+ const hoverRadiusSq = 64;
+ widgetState._points.forEach((pt) => {
+ const px = pt.x * rect.width;
+ const py = (1 - pt.y) * rect.height;
+ if ((px - x) ** 2 + (py - y) ** 2 < hoverRadiusSq)
+ hovering = true;
+ });
+ widgetState.canvasEl.style.cursor = hovering ? "grab" : "crosshair";
+ }
+ });
+ canvas.addEventListener("dblclick", (evt) => {
+ if (evt.target === addButton || evt.target === removeBtn) return;
+ evt.stopPropagation();
+ const rect = widgetState.canvasEl.getBoundingClientRect();
+ let nx = (evt.clientX - rect.left) / rect.width;
+ let ny = 1 - (evt.clientY - rect.top) / rect.height;
+ nx = Math.max(0, Math.min(1, nx));
+ ny = Math.max(0, Math.min(1, ny));
+ widgetState._points.push({ x: nx, y: ny });
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ saveStateToDataWidget();
+ });
+ canvas.addEventListener("contextmenu", (evt) => {
+ if (evt.target === addButton || evt.target === removeBtn) { evt.preventDefault(); return; }
+ evt.preventDefault();
+ evt.stopPropagation();
+ if (widgetState._rightClickIndex >= 0) {
+ const pt = widgetState._points[widgetState._rightClickIndex];
+ if (widgetState._points.length <= minGraphPoints) { /* Show a message if needed */ }
+ else if (pt.x > 1e-6 && pt.x < 1.0 - 1e-6) {
+ widgetState._points.splice(widgetState._rightClickIndex, 1);
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ saveStateToDataWidget();
+ } else { /* Do not remove boundary points */ }
+ }
+ widgetState._rightClickIndex = -1;
+ });
+
+ // Steps Widget Hook
+ if (stepsWidget) {
+ stepsWidget.callback = (value) => {
+ if (widgetState.originalStepsCallback) { widgetState.originalStepsCallback.call(stepsWidget, value); }
+ updateSigmaDisplay(widgetState);
+ };
+ }
+
+ // Resize Observer for canvas
+ widgetState.resizeObserver = new ResizeObserver(() => {
+ requestAnimationFrame(() => drawCurve(widgetState));
+ });
+ widgetState.resizeObserver.observe(canvasContainer);
+
+ // --- Initial Setup ---
+ requestAnimationFrame(() => {
+ console.log(`[SigmaGraphWidget] ${this.id}: Running initial setup in rAF.`);
+ if (customWidget?.value !== undefined) {
+ customWidget.value = dataWidget.value;
+ } else {
+ console.error(`[SigmaGraphWidget] ${this.id}: customWidget or customWidget.value missing for initial load.`);
+ let initialPoints = [];
+ try {
+ initialPoints = JSON.parse(dataWidget.value || '[]');
+ } catch(e) {
+ initialPoints = [{x:0,y:1},{x:1,y:0}];
+ }
+ widgetState._points = initialPoints;
+ drawCurve(widgetState);
+ updateSigmaDisplay(widgetState);
+ updateRemoveButtonState(widgetState);
+ }
+ this.setSize(this.computeSize());
+ this.setDirtyCanvas(true, true);
+ if (this.graph) { this.graph.setDirtyCanvas(true, true); }
+ });
+
+ // --- Cleanup on Removal ---
+ const originalOnRemoved = this.onRemoved;
+ this.onRemoved = () => {
+ console.log(`[SigmaGraphWidget] ${this.id}: Cleaning up.`);
+ if (widgetState.boundMouseUpHandler) { document.removeEventListener("mouseup", widgetState.boundMouseUpHandler, true); }
+ if (widgetState.resizeObserver) { widgetState.resizeObserver.disconnect(); widgetState.resizeObserver = null; }
+ if (stepsWidget && widgetState.originalStepsCallback) { stepsWidget.callback = widgetState.originalStepsCallback; }
+ if (originalOnRemoved) { originalOnRemoved.apply(this, arguments); }
+ };
+
+ // Mark node dirty after adding the widget
+ this.setDirtyCanvas(true, true);
+ }; // End of onConfigure patch
+
+ // --- Node Appearance ---
+ nodeType.prototype.resizable = true;
+ const originalComputeSize = nodeType.prototype.computeSize;
+ nodeType.prototype.computeSize = function(out) {
+ const size = originalComputeSize ? originalComputeSize.apply(this, [null]) : [this.constructor.NODE_WIDTH || 140, 0];
+ const customWidgetContentHeight = 225;
+ size[0] = Math.max(290, size[0]);
+ let baseHeight = this.constructor.title_height || 20;
+ if (this.inputs) {
+ this.inputs.forEach(input => { if (input.name !== graphDataWidgetName) { baseHeight += 20; } });
+ }
+ if (this.outputs) { baseHeight += this.outputs.length * 20; }
+ size[1] = baseHeight + customWidgetContentHeight;
+ size[1] = Math.max(280, size[1]);
+ if (out) { out[0] = size[0]; out[1] = size[1]; }
+ return size;
};
- },
-
- // When a new node is created, set up the graph UI right away
- async nodeCreated(node) {
- if (node.comfyClass !== nodeClassName) return;
- setTimeout(() => {
- const graphWidget = node.widgets?.find((w) => w.name === graphWidgetName);
- if (
- graphWidget &&
- (!graphWidget.element || graphWidget.element.tagName !== "DIV")
- ) {
- setupSigmaGraph(graphWidget, node);
- requestAnimationFrame(() => {
- graphWidget.updateSigmaDisplay?.();
- graphWidget.drawCurve?.();
- node.setDirtyCanvas(true, true);
- });
- }
- }, 100);
- },
-});
+ nodeType.prototype.onResize = function() { /* No-op */ };
+ }, // End of beforeRegisterNodeDef
+}); // End of app.registerExtension
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..82988ac
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,15 @@
+[project]
+name = "SigmaGraphNode"
+description = ""
+version = "0.1.0-alpha"
+license = {file = "LICENSE.txt"}
+dependencies = ["# requirements.txt for SigmaGraphNode", "# This custom node requires PyTorch to create the sigma schedule tensor.", "torch>=2.6.0"]
+
+[project.urls]
+Repository = "https://github.com/Temult/SigmaGraphNode"
+# Used by Comfy Registry https://comfyregistry.org
+
+[tool.comfy]
+PublisherId = "temult"
+DisplayName = "ComfyUI"
+Icon = "https://i.imgur.com/eysFHSU.png"