Files
JoeNavark-comfyui_custom_si…/js/extension.js
T

749 lines
24 KiB
JavaScript

import { app } from "../../scripts/app.js";
// --- Centripetal Catmull-Rom Spline Implementation ---
function centripetalCatmullRomSpline(points, numSamples = 100) {
const n = points.length;
if (n < 2) return points.map(p => [p.x, p.y]);
const out = [];
const pts = [
points[0],
...points,
points[points.length - 1]
];
function tj(ti, pi, pj) {
const dx = pj.x - pi.x, dy = pj.y - pi.y;
return ti + Math.sqrt(Math.hypot(dx, dy));
}
for (let i = 1; i < pts.length - 2; i++) {
const p0 = pts[i - 1], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2];
const t0 = 0;
const t1 = tj(t0, p0, p1);
const t2 = tj(t1, p1, p2);
const t3 = tj(t2, p2, p3);
for (let j = 0; j < numSamples; j++) {
const t = t1 + (t2 - t1) * (j / numSamples);
function lerp(pa, pb, ta, tb) {
if (tb - ta === 0) return { x: pa.x, y: pa.y };
const ratio = (t - ta) / (tb - ta);
return {
x: pa.x + (pb.x - pa.x) * ratio,
y: pa.y + (pb.y - pa.y) * ratio
};
}
const A1 = lerp(p0, p1, t0, t1);
const A2 = lerp(p1, p2, t1, t2);
const A3 = lerp(p2, p3, t2, t3);
const B1 = lerp(A1, A2, t0, t2);
const B2 = lerp(A2, A3, t1, t3);
const C = lerp(B1, B2, t1, t2);
out.push([
Math.max(0, Math.min(1, C.x)),
Math.max(0, Math.min(1, C.y))
]);
}
}
out.push([points[points.length - 1].x, points[points.length - 1].y]);
return out;
}
// ================ ADD THESE CURVE GENERATOR FUNCTIONS HERE ================
function generateSimpleCurve(numPoints = 10) {
// Simple linear curve from 1 to 0 (current default)
return Array.from({length: numPoints}, (_, i) => {
const t = i / (numPoints - 1);
return {
x: t,
y: 1.0 - t // High noise (1.0) to low noise (0.0)
};
});
}
function generateKarrasCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
const rho = 7.0;
return Array.from({length: numPoints}, (_, i) => {
// t should go from 1.0 to 0.0 for high->low noise
const t = 1.0 - (i / (numPoints - 1));
// Karras formula: sigma(t) = sigma_max * (sigma_min/sigma_max)^(t * rho)
const sigma = sigma_max * Math.pow(sigma_min/sigma_max, t * rho);
// At t=1 (beginning), sigma ≈ sigma_min (lowest)
// At t=0 (end), sigma = sigma_max (highest)
// We need to invert this to get the correct direction
const normalized = 1.0 - Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateExponentialCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
return Array.from({length: numPoints}, (_, i) => {
// t should go from 1.0 to 0.0 for high->low noise
const t = 1.0 - (i / (numPoints - 1));
// Exponential formula: sigma = sigma_min^(1-t) * sigma_max^t
const sigma = Math.pow(sigma_min, 1-t) * Math.pow(sigma_max, t);
// At t=1 (beginning), sigma = sigma_max (highest)
// At t=0 (end), sigma = sigma_min (lowest)
const normalized = Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateVPCurve(numPoints = 10) {
const beta_d = 19.9;
const beta_min = 0.1;
const beta_max = 20.0;
return Array.from({length: numPoints}, (_, i) => {
// t should go from 1.0 to 0.0 for high->low noise
const t = 1.0 - (i / (numPoints - 1));
// VP formula
const beta = beta_min + t * (beta_max - beta_min);
// Convert beta to alpha, then to sigma
const alpha = Math.exp(-beta);
const alpha_cumprod = Math.exp(-(beta + 0.5 * t * t * beta_d));
const sigma = Math.sqrt((1 - alpha_cumprod) / alpha_cumprod);
// Normalize to 0-1 range
// Higher sigma means higher noise
const max_sigma = 10.0; // Approximate highest sigma value
const normalized = Math.min(1.0, sigma / max_sigma);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateVECurve(numPoints = 10) {
const sigma_min = 0.02;
const sigma_max = 100.0;
return Array.from({length: numPoints}, (_, i) => {
// Standard t from 0 to 1 for position on x-axis
const x_pos = i / (numPoints - 1);
// For VE formula, we need t from 0 to 1 going right to left
// This means t=0 at x=0, and t=1 at x=1
const t = x_pos; // Not reversed here!
// Standard VE formula
const sigma = Math.pow(sigma_min, t) * Math.pow(sigma_max, 1-t);
// Now let's normalize so that:
// - At x=0 (t=0): sigma ≈ sigma_max → normalized should be 1.0
// - At x=1 (t=1): sigma ≈ sigma_min → normalized should be 0.0
const normalized = 1.0 - Math.min(1.0, Math.max(0.0,
(sigma - sigma_min) / (sigma_max - sigma_min)));
return {
x: x_pos,
y: normalized
};
});
}
function generateLinearCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
return Array.from({length: numPoints}, (_, i) => {
// t should go from 1.0 to 0.0 for high->low noise
const t = 1.0 - (i / (numPoints - 1));
// Linear interpolation
const sigma = sigma_min + t * (sigma_max - sigma_min);
// Normalize
const normalized = Math.min(1.0, sigma / sigma_max);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generatePolyexponentialCurve(numPoints = 10) {
const sigma_min = 0.1;
const sigma_max = 10.0;
const rho = 3.0; // Changed from 1.0 to 3.0 to make it distinct
return Array.from({length: numPoints}, (_, i) => {
// t should go from 1.0 to 0.0 for high->low noise
const t = 1.0 - (i / (numPoints - 1));
// Polyexponential formula with rho != 1 for non-linear curve
const sigma = sigma_min + (sigma_max - sigma_min) * (t ** (1.0 / rho));
// Normalize
const normalized = Math.min(1.0, sigma / sigma_max);
return {
x: i / (numPoints - 1),
y: normalized
};
});
}
function generateDPMSolverFastCurve(numPoints = 10) {
return Array.from({length: numPoints}, (_, i) => {
const t = i / (numPoints - 1);
// Directly define the curve shape
// For DPM-Solver, we want a fast then slow noise reduction
let noise_level;
// Should start high (1.0) and end low (0.0)
if (t < 0.5) {
// Faster reduction in first half
noise_level = 1.0 - 0.75 * (2 * t);
} else {
// Slower reduction in second half
noise_level = 0.25 * (2.0 - 2 * t);
}
return {
x: t,
y: noise_level
};
});
}
// ================ END OF CURVE GENERATOR FUNCTIONS ================
// --- Node Implementation ---
class CustomGraphNode {
// No constructor! All node setup is done in onNodeCreated
onNodeCreated() {
// Node size
if (!this.size) this.size = [340, 260];
// --- Graph area config (proportions only) ---
this.graph_padding_top = 120;
this.graph_side_margin = 25;
this.graph_bottom_margin = 20;
// --- Widget for curve data ---
// Always add the visible, enabled curve_data widget if missing
if (!this.widgets) this.widgets = [];
if (!this.widgets.find(w => w.name === "curve_data")) {
this.addWidget(
"string",
"curve_data",
"",
null,
{ multiline: false, disabled: false }
);
}
// Add preset selector widget
if (!this.widgets.find(w => w.name === "preset_selector")) {
this.addWidget("combo", "preset_selector", "Simple", (value) => {
if (value === "Custom") return;
// Apply the selected preset
switch(value) {
case "Simple":
this.points = generateSimpleCurve(10);
break;
case "Karras":
this.points = generateKarrasCurve(10);
break;
case "Exponential":
this.points = generateExponentialCurve(10);
break;
case "VP":
this.points = generateVPCurve(10);
break;
case "VE":
this.points = generateVECurve(10);
break;
case "Linear":
this.points = generateLinearCurve(10);
break;
case "Polyexponential":
this.points = generatePolyexponentialCurve(10);
break;
case "DPM-Solver-Fast":
this.points = generateDPMSolverFastCurve(10);
break;
default:
return;
}
// Store the last selected preset for potential reset
this.lastSelectedPreset = value;
// Update the curve and curve_data widget
this._ensureValidPoints();
this.updateCurve();
// Keep the preset name in the dropdown (don't change to "Custom")
this.setDirtyCanvas(true, true);
app.graph.change();
}, {
values: ["Simple", "Karras", "Exponential", "VP", "VE", "Linear",
"Polyexponential", "DPM-Solver-Fast", "Custom"]
});
}
// ================ END OF PRESET SELECTOR CODE ================
// --- NEW: Try to load points from the widget value (JSON) ---
const widget = this.widgets.find(w => w.name === "curve_data");
let loaded = false;
if (widget && widget.value) {
try {
const data = JSON.parse(widget.value);
if (data && Array.isArray(data.control_points) && data.control_points.length >= 2) {
this.points = data.control_points.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
loaded = true;
} else if (data && Array.isArray(data.samples) && data.samples.length >= 2) {
// Use endpoints as fallback
this.points = [
{x: Number(data.samples[0][0]), y: Number(data.samples[0][1])},
{x: Number(data.samples[data.samples.length-1][0]), y: Number(data.samples[data.samples.length-1][1])}
];
loaded = true;
}
} catch (e) {
// ignore parse error, will use default points below
}
}
if (!loaded) {
if (!this.points || !Array.isArray(this.points)) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
}
// --- NEW: React to widget value changes (e.g., when workflow loads) ---
if (widget) {
widget.callback = (value) => {
let loaded = false;
if (value) {
try {
const data = JSON.parse(value);
if (data && Array.isArray(data.control_points) && data.control_points.length >= 2) {
this.points = data.control_points.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
loaded = true;
} else if (data && Array.isArray(data.samples) && data.samples.length >= 2) {
this.points = [
{x: Number(data.samples[0][0]), y: Number(data.samples[0][1])},
{x: Number(data.samples[data.samples.length-1][0]), y: Number(data.samples[data.samples.length-1][1])}
];
loaded = true;
}
} catch (e) {}
}
if (!loaded) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
this.updateCurve();
this.setDirtyCanvas(true, true);
};
}
this.dragState = null;
this.smoothedPoints = null;
this.hitRadius = 0.05;
this._ensureValidPoints();
this.updateCurve();
this._updateCurveWidget();
}
calcGraphArea() {
// Calculate the exact position where widgets end
let widgets_bottom = 40; // Start with space for node title (usually ~30-40px)
if (this.widgets && this.widgets.length) {
// Widgets in ComfyUI are typically about 30px tall including margins
// We'll go through each widget type to be more precise
this.widgets.forEach(w => {
// Different widget types have different heights
let widget_height = 30; // Default height
if (w.type === "combo") widget_height = 30;
else if (w.type === "number") widget_height = 30;
else if (w.type === "string" && w.options && w.options.multiline) {
// Multiline text fields are taller
widget_height = 80;
}
else if (w.type === "string") widget_height = 30;
widgets_bottom += widget_height;
});
// Add some extra padding for spacing between widgets
widgets_bottom += 10;
}
// Add extra clearance to ensure no overlap with widgets
const extra_clearance = 2; // 2px additional space below widgets
this.graph_area_top = widgets_bottom + extra_clearance;
// Calculate available space for graph
this.graph_area_height = this.size[1] - this.graph_area_top - this.graph_bottom_margin;
this.graph_area_width = this.size[0] - this.graph_side_margin * 2;
this.graph_area_left = this.graph_side_margin;
// Ensure minimum graph height by resizing node if needed
const min_graph_height = 140; // Reasonable minimum height
if (this.graph_area_height < min_graph_height) {
// Resize the node to fit everything
const additional_height = min_graph_height - this.graph_area_height;
this.size[1] += additional_height;
this.graph_area_height = min_graph_height;
// Update node size in ComfyUI
if (this.setSize) {
this.setSize(this.size);
}
}
}
_ensureValidPoints() {
if (!Array.isArray(this.points) || this.points.length < 2) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
this.points = this.points
.map(p => ({
x: Math.max(0, Math.min(1, p.x)),
y: Math.max(0, Math.min(1, p.y))
}))
.sort((a, b) => a.x - b.x);
this.points = this.points.filter((pt, idx, arr) =>
idx === 0 || pt.x !== arr[idx - 1].x
);
this.points[0].x = 0;
this.points[this.points.length - 1].x = 1;
if (this.points.length < 2) {
this.points = [
{ x: 0, y: 1 },
{ x: 1, y: 0 }
];
}
}
// --- Coordinate transforms for the graph area only ---
toScreenCoords(point) {
this.calcGraphArea(); // <<<< recalc on every call for resizing
return [
this.graph_area_left + point.x * this.graph_area_width,
this.graph_area_top + (1 - point.y) * this.graph_area_height
];
}
toGraphCoords(pos) {
this.calcGraphArea(); // <<<< recalc on every call for resizing
return {
x: Math.max(0, Math.min(1, (pos[0] - this.graph_area_left) / this.graph_area_width)),
y: Math.max(0, Math.min(1, 1 - (pos[1] - this.graph_area_top) / this.graph_area_height))
};
}
onDrawForeground(ctx) {
this.calcGraphArea(); // <<<< recalc on every draw for resizing
this._ensureValidPoints();
this.updateCurve();
// --- White background for the graph area ---
ctx.fillStyle = "#fff";
ctx.fillRect(
this.graph_area_left,
this.graph_area_top,
this.graph_area_width,
this.graph_area_height
);
// --- Draw grid ---
ctx.strokeStyle = "#eee";
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0.25; i < 1; i += 0.25) {
// Vertical grid lines
let x = this.graph_area_left + i * this.graph_area_width;
ctx.moveTo(x, this.graph_area_top);
ctx.lineTo(x, this.graph_area_top + this.graph_area_height);
// Horizontal grid lines
let y = this.graph_area_top + i * this.graph_area_height;
ctx.moveTo(this.graph_area_left, y);
ctx.lineTo(this.graph_area_left + this.graph_area_width, y);
}
ctx.stroke();
// --- Draw axes ---
ctx.strokeStyle = "#aaa";
ctx.beginPath();
// X axis (bottom)
ctx.moveTo(this.graph_area_left, this.graph_area_top + this.graph_area_height);
ctx.lineTo(this.graph_area_left + this.graph_area_width, this.graph_area_top + this.graph_area_height);
// Y axis (left)
ctx.moveTo(this.graph_area_left, this.graph_area_top + this.graph_area_height);
ctx.lineTo(this.graph_area_left, this.graph_area_top);
ctx.stroke();
// --- Draw the spline curve ---
if (this.smoothedPoints && this.smoothedPoints.length > 1) {
ctx.strokeStyle = "#3366FF";
ctx.lineWidth = 2;
ctx.beginPath();
let start = this.toScreenCoords({ x: this.smoothedPoints[0][0], y: this.smoothedPoints[0][1] });
ctx.moveTo(start[0], start[1]);
for (let i = 1; i < this.smoothedPoints.length; i++) {
let p = this.toScreenCoords({ x: this.smoothedPoints[i][0], y: this.smoothedPoints[i][1] });
ctx.lineTo(p[0], p[1]);
}
ctx.stroke();
}
// --- Draw control polygon ---
ctx.strokeStyle = "#FF8888";
ctx.lineWidth = 1;
ctx.beginPath();
if (this.points.length > 1) {
let first = this.toScreenCoords(this.points[0]);
ctx.moveTo(first[0], first[1]);
for (let i = 1; i < this.points.length; i++) {
let pt = this.toScreenCoords(this.points[i]);
ctx.lineTo(pt[0], pt[1]);
}
}
ctx.stroke();
// --- Draw points ---
ctx.fillStyle = "#FF5555";
for (const point of this.points) {
const [x, y] = this.toScreenCoords(point);
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#880000";
ctx.lineWidth = 1.5;
ctx.stroke();
}
// --- Draw user instruction just below the graph area ---
ctx.fillStyle = "#222";
ctx.font = "11px sans-serif";
ctx.fillText(
"Shift+Click to delete point",
this.graph_area_left + 5,
this.graph_area_top + this.graph_area_height + 16
);
}
updateCurve() {
this._ensureValidPoints();
this.smoothedPoints = centripetalCatmullRomSpline(this.points, 100);
this._updateCurveWidget();
}
_updateCurveWidget() {
if (!this.widgets) return;
const widget = this.widgets.find(w => w.name === "curve_data");
if (widget) {
const newValue = JSON.stringify({
control_points: this.points,
samples: this.smoothedPoints
});
if (widget.value !== newValue) {
widget.value = newValue;
// Notify ComfyUI that this input has changed
if (this.setDirtyCanvas) this.setDirtyCanvas(true, true);
if (app && app.graph) app.graph.change();
}
}
}
onMouseDown(e, pos) {
this.calcGraphArea();
// --- Only allow interaction inside the graph area ---
if (
pos[0] < this.graph_area_left ||
pos[0] > this.graph_area_left + this.graph_area_width ||
pos[1] < this.graph_area_top ||
pos[1] > this.graph_area_top + this.graph_area_height
)
return false;
const graphPos = this.toGraphCoords(pos);
const pointIndex = this.points.findIndex(p =>
Math.hypot(p.x - graphPos.x, p.y - graphPos.y) < this.hitRadius
);
if (pointIndex >= 0) {
// Delete if Shift+Left click, else drag
if (e.button === 0 && e.shiftKey) {
if (this.points.length > 2) {
this.points.splice(pointIndex, 1);
// Add this line to change preset selector to "Custom" on point deletion
this._setPresetToCustom();
this.updateCurve();
app.graph.change();
this.setDirtyCanvas(true, true);
}
return true;
} else if (e.button === 0) {
this.dragState = {
index: pointIndex,
offsetX: graphPos.x - this.points[pointIndex].x,
offsetY: graphPos.y - this.points[pointIndex].y
};
app.graph.change();
this.setDirtyCanvas(true, true);
return true;
}
}
if (e.button === 0) {
let newX = Math.max(0, Math.min(1, graphPos.x));
if (this.points.some(p => Math.abs(p.x - newX) < 1e-4)) return false;
let newY = Math.max(0, Math.min(1, graphPos.y));
this.points.push({ x: newX, y: newY });
// Add this line to change preset selector to "Custom" on point addition
this._setPresetToCustom();
this.updateCurve();
app.graph.change();
this.setDirtyCanvas(true, true);
return true;
}
return false;
}
onMouseMove(e, pos) {
if (!this.dragState) return false;
this.calcGraphArea();
// --- Only allow interaction inside the graph area ---
if (
pos[0] < this.graph_area_left ||
pos[0] > this.graph_area_left + this.graph_area_width ||
pos[1] < this.graph_area_top ||
pos[1] > this.graph_area_top + this.graph_area_height
)
return false;
const graphPos = this.toGraphCoords(pos);
let newX = Math.max(0, Math.min(1, graphPos.x - this.dragState.offsetX));
let newY = Math.max(0, Math.min(1, graphPos.y - this.dragState.offsetY));
const i = this.dragState.index;
if (i === 0) newX = 0;
else if (i === this.points.length - 1) newX = 1;
else {
if (i > 0) newX = Math.max(this.points[i - 1].x + 1e-3, newX);
if (i < this.points.length - 1) newX = Math.min(this.points[i + 1].x - 1e-3, newX);
}
this.points[i] = { x: newX, y: newY };
// Add this line to change preset selector to "Custom"
this._setPresetToCustom();
this.updateCurve();
this.setDirtyCanvas(true, true);
return true;
}
onMouseUp() {
if (this.dragState) {
this.dragState = null;
app.graph.change();
return true;
}
return false;
}
onExecute() {
this.updateCurve();
}
onExecuted(data) {}
onConfigure(info) {
// This is called when the node is loaded from a workflow file
if (info.curve_state && Array.isArray(info.curve_state)) {
try {
this.points = info.curve_state.map(pt => ({
x: Number(pt.x),
y: Number(pt.y)
}));
this._ensureValidPoints();
this.updateCurve();
} catch (e) {
console.error("Failed to load curve state:", e);
}
}
}
onSerialize(info) {
// Save the curve state when the workflow is saved
info.curve_state = this.points;
}
_setPresetToCustom() {
// Find preset selector widget
const presetWidget = this.widgets.find(w => w.name === "preset_selector");
if (presetWidget && presetWidget.value !== "Custom") {
presetWidget.value = "Custom";
// For ComfyUI to register the widget value change
if (this.onWidgetChanged) {
this.onWidgetChanged(presetWidget.name, presetWidget.value, presetWidget.last_value);
}
}
}
}
app.registerExtension({
name: "InteractiveCentripetalCatmullRomGraph",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "CustomSplineSigma") {
const instance = new CustomGraphNode();
Object.getOwnPropertyNames(Object.getPrototypeOf(instance))
.filter(prop => prop !== 'constructor')
.forEach(method => {
nodeType.prototype[method] = instance[method];
});
Object.assign(nodeType.prototype, {
hitRadius: instance.hitRadius
});
// Make sure onNodeCreated is called for each node instance
const oldOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (oldOnNodeCreated) oldOnNodeCreated.call(this);
instance.onNodeCreated.call(this);
};
}
}
});