// SigmaGraphWidget.js // Provides a custom UI widget for editing a sigma schedule graph. import { app } from "../../scripts/app.js"; import { $el } from "../../scripts/ui.js"; /** Node and widget constants */ const nodeClassName = "SigmaGraphNode"; const graphWidgetName = "graph_data"; const stepsWidgetName = "steps"; /** How many preset slots we allow for saving/loading graph shapes. */ const numPresetSlots = 9; /** * 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. */ 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]; } // Defensive copy of the points let localPoints = JSON.parse(JSON.stringify(points || [])); if (!Array.isArray(localPoints) || localPoints.length < 2) { // Fallback to linear if no valid points for (let i = 0; i < steps; i++) { sigmaValues.push(1.0 - 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) { localPoints.unshift({ x: 0.0, y: localPoints[0].y }); } if (localPoints[localPoints.length - 1].x < 1) { 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); while ( currentPointIndex < localPoints.length - 2 && localPoints[currentPointIndex + 1].x < stepProgress ) { currentPointIndex++; } const p1 = localPoints[currentPointIndex]; const p2 = localPoints[currentPointIndex + 1] || p1; let sigma; if (p2.x <= p1.x) { sigma = p1.y; } else { const interpFactor = (stepProgress - p1.x) / (p2.x - p1.x); 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. */ 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); } } // 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] }); } else { for (let i = 0; i < steps; i++) { const x = i / (steps - 1); const y = parsedSigmas[i]; newPoints.push({ x, y }); } } 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); }; } /** * Register the extension with ComfyUI, patching the node definition. */ app.registerExtension({ name: "sigma_graph.widget", async beforeRegisterNodeDef(nodeType, nodeData) { // Only patch the SigmaGraphNode 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 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()); }); }; // Patch onRemoved to clean up the widget const originalOnRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function () { const graphWidget = this.widgets?.find((w) => w.name === graphWidgetName); if (graphWidget && graphWidget.onRemoved) { graphWidget.onRemoved(); } if (originalOnRemoved) originalOnRemoved.call(this); }; }, // 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); }, });