From 76a2fd7649987b27f92a299d010a4dd1f615fd9a Mon Sep 17 00:00:00 2001 From: Temult <146690928+Temult@users.noreply.github.com> Date: Sat, 12 Apr 2025 10:00:35 -0400 Subject: [PATCH] ComfyUI Manager Publishing --- LICENSE.txt | 21 + Requirements.txt | 3 + SigmaGraphNode.py | 301 ++++---- __init__.py | 12 +- js/SigmaGraphWidget.js | 1570 +++++++++++++++++++++------------------- pyproject.toml | 15 + 6 files changed, 1011 insertions(+), 911 deletions(-) create mode 100644 LICENSE.txt create mode 100644 Requirements.txt create mode 100644 pyproject.toml 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"