Files
ryanontheinside-ComfyUI_Rya…/web/js/drawable_feature.js
ryanontheinstide 27765f316c Version 2
Sweeping update. New features, more maintainable.
See readme and comments for more info.
2025-01-22 11:11:19 -05:00

801 lines
38 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { FeatureTemplates } from "./feature_templates.js";
//TODO: add handling for maximum frame count change
// Register custom widget type
app.registerExtension({
name: "RyanOnTheInside.DrawableFeature",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// console.log("Registering DrawableFeature extension", { nodeType, nodeData });
if (nodeData.name === "DrawableFeatureNode") {
// console.log("Found DrawableFeatureNode, setting up widget");
// Set default size
nodeType.size = [700, 800]; // Increased size for better usability
// Store the original methods
const onNodeCreated = nodeType.prototype.onNodeCreated;
const onDrawForeground = nodeType.prototype.onDrawForeground;
const onMouseDown = nodeType.prototype.onMouseDown;
const onMouseMove = nodeType.prototype.onMouseMove;
const onMouseUp = nodeType.prototype.onMouseUp;
const onDblClick = nodeType.prototype.onDblClick;
const onResize = nodeType.prototype.onResize;
const onSerialize = nodeType.prototype.onSerialize;
const onConfigure = nodeType.prototype.onConfigure;
const onConnectionsChange = nodeType.prototype.onConnectionsChange;
// Add serialization support
nodeType.prototype.onSerialize = function(o) {
if (onSerialize) {
onSerialize.apply(this, arguments);
}
o.points = this.points;
};
// Add deserialization support
nodeType.prototype.onConfigure = function(o) {
if (onConfigure) {
onConfigure.apply(this, arguments);
}
if (o.points) {
this.points = o.points;
this.updatePointsValue();
}
};
// Add connection change handler
nodeType.prototype.onConnectionsChange = function(slotType, slot, isConnected, link_info, output) {
// console.log("onConnectionsChange:", { slotType, slot, isConnected, link_info });
if (onConnectionsChange) {
onConnectionsChange.apply(this, arguments);
}
// Handle frame_count input connection changes
if (slotType === LiteGraph.INPUT) {
const input = this.inputs[slot];
// console.log("Input connection change:", { input, name: input?.name });
if (input && input.name === "frame_count") {
if (isConnected && link_info) {
// When connected, we'll need to listen for value changes
const inputNode = link_info.origin_id ? this.graph._nodes_by_id[link_info.origin_id] : null;
// console.log("Connected node:", inputNode);
if (inputNode) {
// Store the connected node for later reference
this.frameCountInputNode = inputNode;
// Set up the trigger and mode for value changes
input.onTrigger = this.onInputUpdated.bind(this);
// Set mode to trigger on any value change
input.mode = LiteGraph.UP | LiteGraph.DOWN | LiteGraph.EVENT;
// Also watch the connected node's widget for changes
const widget = inputNode.widgets?.[0];
if (widget) {
const originalCallback = widget.callback;
widget.callback = (v) => {
if (originalCallback) {
originalCallback.call(widget, v);
}
// Trigger our input update when the upstream widget changes
console.log("Frame count input updated from upstream widget:", v);
this.onInputUpdated();
};
}
// Trigger initial update
this.onInputUpdated();
}
} else {
// When disconnected, clear the reference and trigger
this.frameCountInputNode = null;
input.onTrigger = null;
input.mode = LiteGraph.DEFAULT; // Reset mode
}
// Redraw since frame count may have changed
this.setDirtyCanvas(true, true);
}
}
};
// Add handler for input value changes
nodeType.prototype.onInputUpdated = function() {
// Find frame_count input
const frameCountInput = this.inputs.find(input => input.name === "frame_count");
if (frameCountInput && frameCountInput.link !== null) {
// Get the connected node
const link = this.graph.links[frameCountInput.link];
if (link && link.origin_id) {
const inputNode = this.graph._nodes_by_id[link.origin_id];
if (inputNode) {
// Try to get value from node's widget first
const widget = inputNode.widgets?.[0];
let frameCount;
if (widget) {
frameCount = widget.value;
} else {
// Fallback to output value if no widget
frameCount = inputNode.getOutputData(link.origin_slot);
}
if (frameCount !== undefined && frameCount !== null) {
// Get frame count widget
const frameCountWidget = this.widgets.find(w => w.name === "frame_count");
if (frameCountWidget) {
const oldFrameCount = frameCountWidget.value;
// console.log("Updating frame count from", oldFrameCount, "to", frameCount);
// Update widget value first
frameCountWidget.value = frameCount;
// Scale points if needed
if (this.points && this.points.length > 0 && oldFrameCount !== frameCount) {
this.points = this.points.map(([frame, value]) => {
const oldMax = oldFrameCount - 1;
const newMax = frameCount - 1;
const newFrame = Math.min(newMax, Math.round((frame / oldMax) * newMax));
return [newFrame, value];
});
this.points.sort((a, b) => a[0] - b[0]);
this.updatePointsValue();
}
// Force graph area update
this.setSize(this.size); // This will trigger size update and redraw
this.setDirtyCanvas(true, true);
}
}
}
}
}
};
// Override onNodeCreated to initialize the node
nodeType.prototype.onNodeCreated = function() {
// console.log("Node created, initializing node");
const r = onNodeCreated?.apply(this, arguments);
// Add trigger for input updates
this.onInputTriggered = this.onInputUpdated.bind(this);
// Register for input triggers
if (!this.inputs) {
this.inputs = [];
}
// Find or create frame_count input
let frameCountInput = this.inputs.find(input => input.name === "frame_count");
if (frameCountInput) {
// Add trigger callback
frameCountInput.onTrigger = this.onInputTriggered;
// Set mode to trigger on value change
frameCountInput.mode = LiteGraph.UP | LiteGraph.DOWN;
}
// Remove default points widget if it exists
const pointsWidgetIndex = this.widgets.findIndex(w => w.name === "points");
if (pointsWidgetIndex > -1) {
this.widgets.splice(pointsWidgetIndex, 1);
}
// Initialize state
this.points = [];
this.isDragging = false;
this.selectedPoint = null;
this.hoverPoint = null;
this.isAddingPoint = false;
// Add frame count change handler
const frameCountWidget = this.widgets.find(w => w.name === "frame_count");
if (frameCountWidget) {
// Track the last frame count outside the callback
let lastFrameCount = frameCountWidget.value;
const originalCallback = frameCountWidget.callback;
frameCountWidget.callback = function(v, e, skipCallback) {
// console.log("Frame count widget callback triggered");
const oldFrameCount = lastFrameCount; // Use tracked value
// console.log("Current frame count:", oldFrameCount);
// console.log("New frame count:", v);
// console.log("this.points exists:", !!this.points);
// console.log("this.points:", JSON.stringify(this.points));
// console.log("this.points length:", this.points?.length);
// console.log("oldFrameCount !== v:", oldFrameCount !== v);
// Update tracked value before calling original callback
lastFrameCount = v;
// Call original callback to update the widget value
if (originalCallback && !skipCallback) {
originalCallback.call(frameCountWidget, v, e);
}
// Now scale points based on the frame count change
if (this.points && this.points.length > 0 && oldFrameCount !== v) {
// console.log("Original points:", JSON.stringify(this.points));
this.points = this.points.map(([frame, value]) => {
const oldMax = oldFrameCount - 1; // Convert to 0-based
const newMax = v - 1; // Convert to 0-based
const newFrame = Math.min(newMax, Math.round((frame / oldMax) * newMax));
// console.log(`Scaling point frame ${frame} to ${newFrame} (old max: ${oldMax}, new max: ${newMax})`);
return [newFrame, value];
});
// Sort points by frame number
this.points.sort((a, b) => a[0] - b[0]);
// console.log("Scaled points:", JSON.stringify(this.points));
this.updatePointsValue();
this.setDirtyCanvas(true, true);
} else {
// console.log("Skipped points scaling because:", {
// hasPoints: !!this.points,
// pointsLength: this.points?.length,
// frameCountChanged: oldFrameCount !== v
// });
}
}.bind(this); // Still bind to node instance for this.points access
}
// Add hidden points widget at the end
this.widgets.push({
type: "text",
name: "points",
value: "[]",
hidden: true
});
// Restore points from widget value
try {
const savedPoints = JSON.parse(this.widgets[this.widgets.length - 1].value);
if (Array.isArray(savedPoints)) {
this.points = savedPoints;
}
} catch (e) {
// console.error("Failed to restore points:", e);
}
// Add template dropdown and controls
const templateWidget = this.addWidget("combo", "Template", "selected_template", (v) => {}, {
values: [
"none",
// Basic waveforms
"sine",
"square",
"triangle",
"sawtooth",
// Animation curves
"easeInOut",
"easeIn",
"easeOut",
// Special patterns
"bounce",
"pulse",
"random",
"smoothRandom",
"heartbeat",
"steps"
],
value: "none"
});
templateWidget.name = "template";
// Add cycles control
const cyclesWidget = this.addWidget("number", "Template Cycles", "template_cycles", function(v) {
return Math.max(0.1, Math.min(200, v));
}, {
value: 1.0,
min: 0.1,
max: 200,
step: 0.1,
precision: 2
});
cyclesWidget.name = "template_cycles";
cyclesWidget.value = 1.0; // Explicitly set initial value
// Add load template button
const loadButton = this.addWidget("button", "Load Template", "load", () => {
const templateWidget = this.widgets.find(w => w.name === "template");
if (templateWidget && templateWidget.value && templateWidget.value !== "none") {
this.loadTemplate(templateWidget.value);
}
});
// Add clear button widget
this.addWidget("button", "Clear Graph", "clear", () => {
this.points = [];
this.updatePointsValue();
this.setDirtyCanvas(true, true);
});
// Add handlers for min/max value changes
const minValueWidget = this.widgets.find(w => w.name === "min_value");
const maxValueWidget = this.widgets.find(w => w.name === "max_value");
if (minValueWidget) {
const originalCallback = minValueWidget.callback;
minValueWidget.callback = (v) => {
const result = originalCallback?.call(this, v);
this.clampPoints();
return result;
};
}
if (maxValueWidget) {
const originalCallback = maxValueWidget.callback;
maxValueWidget.callback = (v) => {
const result = originalCallback?.call(this, v);
this.clampPoints();
return result;
};
}
return r;
};
// Add method to clamp points to min/max range
nodeType.prototype.clampPoints = function() {
if (!this.points || this.points.length === 0) return;
// Get current min/max values
const minValue = this.widgets.find(w => w.name === "min_value")?.value ?? 0;
const maxValue = this.widgets.find(w => w.name === "max_value")?.value ?? 1;
// Clamp any out-of-bounds points
let needsUpdate = false;
this.points = this.points.map(([frame, value]) => {
const clampedValue = Math.min(Math.max(value, minValue), maxValue);
if (clampedValue !== value) needsUpdate = true;
return [frame, clampedValue];
});
// Only update if points were actually clamped
if (needsUpdate) {
this.updatePointsValue();
this.setDirtyCanvas(true, true);
}
};
// Calculate widget area height
nodeType.prototype.getWidgetAreaHeight = function() {
let height = 0;
const titleHeight = 30;
const widgetSpacing = 4;
height += titleHeight;
for (const w of this.widgets) {
if (!w.hidden) {
height += w.computeSize?.[1] || 20;
height += widgetSpacing;
}
}
return height + 10; // Add padding
};
// Get current frame count value, checking both widget and input
nodeType.prototype.getCurrentFrameCount = function() {
// First check if we have a connected input
const frameCountInput = this.inputs.find(input => input.name === "frame_count");
if (frameCountInput && frameCountInput.link !== null) {
const link = this.graph.links[frameCountInput.link];
if (link) {
const inputNode = this.graph._nodes_by_id[link.origin_id];
if (inputNode) {
// Try to get value from node's widget first
const widget = inputNode.widgets?.[0];
if (widget) {
// console.log("Getting frame count from widget:", widget.value);
return widget.value;
}
// Fallback to output value if no widget
const frameCount = inputNode.getOutputData(link.origin_slot);
// console.log("Getting frame count from output:", frameCount);
return frameCount;
}
}
}
// Fallback to widget value
const frameCountWidget = this.widgets.find(w => w.name === "frame_count");
return frameCountWidget?.value || 30; // Default to 30 if no value found
};
// Override onDrawForeground to draw the graph
nodeType.prototype.onDrawForeground = function(ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
if (this.flags.collapsed) return;
// Calculate graph dimensions based on node size and widget area
const margin = 30;
const widgetAreaHeight = this.getWidgetAreaHeight();
const graphWidth = this.size[0] - 2 * margin;
const graphHeight = Math.max(200, this.size[1] - widgetAreaHeight - margin * 2);
const graphY = widgetAreaHeight + margin;
// Draw background
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(margin, graphY, graphWidth, graphHeight);
ctx.strokeStyle = "#666";
ctx.strokeRect(margin, graphY, graphWidth, graphHeight);
// Draw help text
ctx.fillStyle = "#888";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillText("Click empty space to add • Double-click point to delete • Drag points to move",
margin + graphWidth / 2, graphY + 20);
// Draw grid
ctx.strokeStyle = "#333";
ctx.lineWidth = 0.5;
// Vertical lines (frames) and labels
const frameCount = this.getCurrentFrameCount();
const maxFrame = frameCount - 1; // Maximum valid frame
const frameStep = Math.max(1, Math.floor(maxFrame / 10));
ctx.fillStyle = "#888";
ctx.font = "10px Arial";
ctx.textAlign = "center";
for (let f = 0; f <= maxFrame; f += frameStep) {
const x = margin + (f / maxFrame) * graphWidth;
ctx.beginPath();
ctx.moveTo(x, graphY);
ctx.lineTo(x, graphY + graphHeight);
ctx.stroke();
// Frame number labels
ctx.fillText(f.toString(), x, graphY + graphHeight + 15);
}
// Get min/max values from widgets
const minValue = this.widgets.find(w => w.name === "min_value")?.value ?? 0;
const maxValue = this.widgets.find(w => w.name === "max_value")?.value ?? 1;
const valueRange = maxValue - minValue;
// Horizontal lines (values) and labels
ctx.textAlign = "right";
for (let v = 0; v <= 1; v += 0.1) {
const y = graphY + v * graphHeight;
ctx.beginPath();
ctx.moveTo(margin, y);
ctx.lineTo(margin + graphWidth, y);
ctx.stroke();
// Value labels
const value = maxValue - (v * valueRange);
ctx.fillText(value.toFixed(1), margin - 5, y + 4);
}
// Axis labels
ctx.save();
ctx.translate(margin - 25, graphY + graphHeight / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Value", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("Frame", margin + graphWidth / 2, graphY + graphHeight + 30);
// Draw points and lines
if (this.points && this.points.length > 0) {
// Draw lines between points
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2;
ctx.beginPath();
const points = this.points.map(([frame, value]) => ({
x: margin + (frame / maxFrame) * graphWidth,
y: graphY + (1 - this.normalizeValue(value)) * graphHeight
}));
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
// Draw points with hover and selection effects
points.forEach((point, i) => {
ctx.beginPath();
ctx.arc(point.x, point.y, i === this.selectedPoint ? 7 : 5, 0, Math.PI * 2);
if (i === this.selectedPoint) {
ctx.fillStyle = "#00ff00";
} else if (i === this.hoverPoint) {
ctx.fillStyle = "#ffff00";
} else {
ctx.fillStyle = "#fff";
}
ctx.fill();
if (i === this.selectedPoint || i === this.hoverPoint) {
ctx.strokeStyle = "#000";
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
// Draw potential new point position
if (this.isAddingPoint && this.mousePos) {
const [frame, value] = this.coordsToGraphValues(this.mousePos[0], this.mousePos[1]);
if (frame >= 0 && frame < frameCount) {
const x = margin + (frame / maxFrame) * graphWidth;
const y = graphY + (1 - this.normalizeValue(value)) * graphHeight;
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1;
ctx.stroke();
}
}
};
// Normalize value to 0-1 range based on min/max widgets
nodeType.prototype.normalizeValue = function(value) {
const minValue = this.widgets.find(w => w.name === "min_value")?.value ?? 0;
const maxValue = this.widgets.find(w => w.name === "max_value")?.value ?? 1;
return (value - minValue) / (maxValue - minValue);
};
// Denormalize value from 0-1 range to min/max range
nodeType.prototype.denormalizeValue = function(normalized) {
const minValue = this.widgets.find(w => w.name === "min_value")?.value ?? 0;
const maxValue = this.widgets.find(w => w.name === "max_value")?.value ?? 1;
return normalized * (maxValue - minValue) + minValue;
};
// Convert coordinates to graph values
nodeType.prototype.coordsToGraphValues = function(x, y) {
const margin = 30;
const widgetAreaHeight = this.getWidgetAreaHeight();
const graphWidth = this.size[0] - 2 * margin;
const graphHeight = Math.max(200, this.size[1] - widgetAreaHeight - margin * 2);
const graphY = widgetAreaHeight + margin;
const frameCount = this.getCurrentFrameCount();
const maxFrame = frameCount - 1;
// Calculate frame (x value) and clamp to valid range
const frame = Math.round(((x - margin) / graphWidth) * maxFrame);
const clampedFrame = Math.max(0, Math.min(maxFrame, frame));
// Calculate value (y value) - normalize based on graph position
const normalizedY = Math.max(0, Math.min(1, (y - graphY) / graphHeight));
const value = this.denormalizeValue(1 - normalizedY);
return [clampedFrame, value];
};
// Check if mouse is over graph area
nodeType.prototype.isMouseOverGraph = function(x, y) {
const margin = 30;
const widgetAreaHeight = this.getWidgetAreaHeight();
const graphWidth = this.size[0] - 2 * margin;
const graphHeight = Math.max(200, this.size[1] - widgetAreaHeight - margin * 2);
const graphY = widgetAreaHeight + margin;
return x >= margin &&
x <= this.size[0] - margin &&
y >= graphY &&
y <= graphY + graphHeight;
};
// Find point near coordinates
nodeType.prototype.findNearPoint = function(x, y) {
const margin = 30;
const widgetAreaHeight = this.getWidgetAreaHeight();
const graphWidth = this.size[0] - 2 * margin;
const graphHeight = Math.max(200, this.size[1] - widgetAreaHeight - margin * 2);
const graphY = widgetAreaHeight + margin;
const frameCount = this.widgets.find(w => w.name === "frame_count")?.value || 30;
const maxFrame = frameCount - 1;
for (let i = 0; i < this.points.length; i++) {
const [frame, value] = this.points[i];
const px = margin + (frame / maxFrame) * graphWidth;
const py = graphY + (1 - this.normalizeValue(value)) * graphHeight;
const dist = Math.sqrt((x - px) ** 2 + (y - py) ** 2);
if (dist < 10) return i;
}
return null;
};
// Update the hidden points value
nodeType.prototype.updatePointsValue = function() {
const pointsStr = JSON.stringify(this.points);
// Find or create a hidden widget to store the points
let pointsWidget = this.widgets.find(w => w.name === "points");
if (!pointsWidget) {
// Create new points widget at the end of the list
pointsWidget = {
type: "text",
name: "points",
value: "[]",
hidden: true
};
this.widgets.push(pointsWidget);
}
pointsWidget.value = pointsStr;
// Trigger widget change to ensure value is saved
if (this.onWidgetChanged) {
this.onWidgetChanged(pointsWidget.name, pointsWidget.value, pointsWidget.value, pointsWidget);
}
};
// Override mouse handlers for improved drawing
nodeType.prototype.onMouseDown = function(e, pos) {
const [x, y] = pos;
if (!this.isMouseOverGraph(x, y)) return false;
const pointIndex = this.findNearPoint(x, y);
if (pointIndex !== null) {
if (this.isDragging && this.selectedPoint !== null) {
// If already dragging and clicked, drop the point
this.isDragging = false;
this.selectedPoint = null;
this.dragStartPos = null;
} else {
// Start dragging
this.selectedPoint = pointIndex;
this.dragStartPos = [...pos];
this.isDragging = true;
}
} else {
// Not on a point - add new point
const [frame, value] = this.coordsToGraphValues(x, y);
const frameCount = this.widgets.find(w => w.name === "frame_count")?.value || 30;
const maxFrame = frameCount - 1;
if (frame >= 0 && frame <= maxFrame) {
// Remove any existing point at the same frame
const existingIndex = this.points.findIndex(p => p[0] === frame);
if (existingIndex !== -1) {
this.points.splice(existingIndex, 1);
}
this.points.push([frame, value]);
this.points.sort((a, b) => a[0] - b[0]);
this.selectedPoint = this.points.findIndex(p => p[0] === frame);
this.updatePointsValue();
}
}
this.setDirtyCanvas(true, true);
return true;
};
nodeType.prototype.onMouseMove = function(e, pos) {
const [x, y] = pos;
this.mousePos = pos;
if (!this.isMouseOverGraph(x, y)) {
// Clear all states when mouse leaves graph area
this.hoverPoint = null;
this.isAddingPoint = false;
this.selectedPoint = null;
this.isDragging = false;
this.dragStartPos = null;
this.setDirtyCanvas(true, true);
return false;
}
if (this.isDragging && this.selectedPoint !== null) {
const [frame, value] = this.coordsToGraphValues(x, y);
const frameCount = this.widgets.find(w => w.name === "frame_count")?.value || 30;
const maxFrame = frameCount - 1;
// Keep strict < frameCount for dragging to prevent edge issues
if (frame >= 0 && frame <= maxFrame) {
// Check if there's already a point at the target frame (except selected point)
const existingIndex = this.points.findIndex((p, i) =>
i !== this.selectedPoint && p[0] === frame);
if (existingIndex === -1) {
this.points[this.selectedPoint] = [frame, value];
this.points.sort((a, b) => a[0] - b[0]);
this.selectedPoint = this.points.findIndex(p =>
p[0] === frame && p[1] === value);
this.updatePointsValue();
}
}
} else {
// Update hover state
this.hoverPoint = this.findNearPoint(x, y);
this.isAddingPoint = this.hoverPoint === null;
}
this.setDirtyCanvas(true, true);
return true;
};
nodeType.prototype.onMouseUp = function(e, pos) {
// Only clear states if we're not in dragging mode
if (!this.isDragging) {
this.selectedPoint = null;
this.dragStartPos = null;
}
this.mouseDownTime = null;
this.setDirtyCanvas(true, true);
return false;
};
// Add double click handler
nodeType.prototype.onDblClick = function(e, pos) {
const [x, y] = pos;
if (!this.isMouseOverGraph(x, y)) return false;
const pointIndex = this.findNearPoint(x, y);
if (pointIndex !== null) {
this.points.splice(pointIndex, 1);
this.updatePointsValue();
// Clear all interaction states
this.selectedPoint = null;
this.isDragging = false;
this.dragStartPos = null;
this.setDirtyCanvas(true, true);
return true;
}
return false;
};
// Handle node resizing
nodeType.prototype.onResize = function(size) {
if (onResize) {
onResize.apply(this, arguments);
}
// Ensure minimum size
this.size[0] = Math.max(400, size[0]);
this.size[1] = Math.max(500, size[1]);
// Force graph area recalculation
this.setDirtyCanvas(true, true);
};
// Add method to update node size
nodeType.prototype.setSize = function(size) {
this.size = size;
if (this.onResize) {
this.onResize(size);
}
};
// Add click handler
nodeType.prototype.onClick = function(e, pos) {
// Clear all states on any click as a safety measure
this.selectedPoint = null;
this.isDragging = false;
this.dragStartPos = null;
this.setDirtyCanvas(true, true);
return false;
};
// Template loading method
nodeType.prototype.loadTemplate = function(templateName) {
const frameCount = this.getCurrentFrameCount();
const minValue = this.widgets.find(w => w.name === "min_value")?.value ?? 0;
const maxValue = this.widgets.find(w => w.name === "max_value")?.value ?? 1;
const cyclesWidget = this.widgets.find(w => w.name === "template_cycles");
const cycles = cyclesWidget?.value ?? 1;
if (templateName in FeatureTemplates) {
this.points = FeatureTemplates[templateName](frameCount, minValue, maxValue, cycles);
this.updatePointsValue();
this.setDirtyCanvas(true, true);
}
};
}
}
});