Files
jaidaken f09734b0ee
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled
Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 00:56:42 +00:00

390 lines
10 KiB
TypeScript

import type {LGraphCanvas as TLGraphCanvas, Vector2} from "@comfyorg/frontend";
import {app} from "scripts/app.js";
function binarySearch(max: number, getValue: (n: number) => number, match: number) {
let min = 0;
while (min <= max) {
let guess = Math.floor((min + max) / 2);
const compareVal = getValue(guess);
if (compareVal === match) return guess;
if (compareVal < match) min = guess + 1;
else max = guess - 1;
}
return max;
}
/**
* Fits a string against a max width for a ctx. Font should be defined on ctx beforehand.
*/
export function fitString(ctx: CanvasRenderingContext2D, str: string, maxWidth: number) {
let width = ctx.measureText(str).width;
const ellipsis = "…";
const ellipsisWidth = measureText(ctx, ellipsis);
if (width <= maxWidth || width <= ellipsisWidth) {
return str;
}
const index = binarySearch(
str.length,
(guess) => measureText(ctx, str.substring(0, guess)),
maxWidth - ellipsisWidth,
);
return str.substring(0, index) + ellipsis;
}
/** Measures the width of text for a canvas context. */
export function measureText(ctx: CanvasRenderingContext2D, str: string) {
return ctx.measureText(str).width;
}
export type WidgetRenderingOptionsPart = {
type?: "toggle" | "custom";
margin?: number;
fillStyle?: string;
strokeStyle?: string;
lowQuality?: boolean;
draw?(ctx: CanvasRenderingContext2D, x: number, lowQuality: boolean): number;
};
type WidgetRenderingOptions = {
size: [number, number];
pos: [number, number];
borderRadius?: number;
colorStroke?: string;
colorBackground?: string;
};
export function isLowQuality() {
const canvas = app.canvas as TLGraphCanvas;
return (canvas.ds?.scale || 1) <= 0.5;
}
export function drawNodeWidget(ctx: CanvasRenderingContext2D, options: WidgetRenderingOptions) {
const lowQuality = isLowQuality();
const data = {
width: options.size[0],
height: options.size[1],
posY: options.pos[1],
lowQuality,
margin: 15,
colorOutline: LiteGraph.WIDGET_OUTLINE_COLOR,
colorBackground: LiteGraph.WIDGET_BGCOLOR,
colorText: LiteGraph.WIDGET_TEXT_COLOR,
colorTextSecondary: LiteGraph.WIDGET_SECONDARY_TEXT_COLOR,
};
// Draw background.
ctx.strokeStyle = options.colorStroke || data.colorOutline;
ctx.fillStyle = options.colorBackground || data.colorBackground;
ctx.beginPath();
ctx.roundRect(
data.margin,
data.posY,
data.width - data.margin * 2,
data.height,
lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
);
ctx.fill();
if (!lowQuality) {
ctx.stroke();
}
return data;
}
/** Draws a rounded rectangle. */
export function drawRoundedRectangle(
ctx: CanvasRenderingContext2D,
options: WidgetRenderingOptions,
) {
const lowQuality = isLowQuality();
options = {...options};
ctx.save();
ctx.strokeStyle = options.colorStroke || LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fillStyle = options.colorBackground || LiteGraph.WIDGET_BGCOLOR;
ctx.beginPath();
ctx.roundRect(
...options.pos,
...options.size,
lowQuality ? [0] : options.borderRadius ? [options.borderRadius] : [options.size[1] * 0.5],
);
ctx.fill();
!lowQuality && ctx.stroke();
ctx.restore();
}
type DrawNumberWidgetPartOptions = {
posX: number;
posY: number;
height: number;
value: number;
direction?: 1 | -1;
textColor?: string;
};
/**
* Draws a number picker with arrows off to each side.
*
* This is for internal widgets that may have many hit areas (full-width, default number widgets put
* the arrows on either side of the full-width row).
*/
export function drawNumberWidgetPart(
ctx: CanvasRenderingContext2D,
options: DrawNumberWidgetPartOptions,
): [Vector2, Vector2, Vector2] {
const arrowWidth = 9;
const arrowHeight = 10;
const innerMargin = 3;
const numberWidth = 32;
const xBoundsArrowLess: Vector2 = [0, 0];
const xBoundsNumber: Vector2 = [0, 0];
const xBoundsArrowMore: Vector2 = [0, 0];
ctx.save();
let posX = options.posX;
const {posY, height, value, textColor} = options;
const midY = posY + height / 2;
// If we're drawing parts from right to left (usually when something in the middle will be
// flexible), then we can simply move left the expected width of our widget and draw forwards.
if (options.direction === -1) {
posX = posX - arrowWidth - innerMargin - numberWidth - innerMargin - arrowWidth;
}
// Draw the strength left arrow.
ctx.fill(
new Path2D(
`M ${posX} ${midY} l ${arrowWidth} ${
arrowHeight / 2
} l 0 -${arrowHeight} L ${posX} ${midY} z`,
),
);
xBoundsArrowLess[0] = posX;
xBoundsArrowLess[1] = arrowWidth;
posX += arrowWidth + innerMargin;
// Draw the strength text.
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const oldTextcolor = ctx.fillStyle;
if (textColor) {
ctx.fillStyle = textColor;
}
ctx.fillText(fitString(ctx, value.toFixed(2), numberWidth), posX + numberWidth / 2, midY);
ctx.fillStyle = oldTextcolor;
xBoundsNumber[0] = posX;
xBoundsNumber[1] = numberWidth;
posX += numberWidth + innerMargin;
// Draw the strength right arrow.
ctx.fill(
new Path2D(
`M ${posX} ${midY - arrowHeight / 2} l ${arrowWidth} ${arrowHeight / 2} l -${arrowWidth} ${
arrowHeight / 2
} v -${arrowHeight} z`,
),
);
xBoundsArrowMore[0] = posX;
xBoundsArrowMore[1] = arrowWidth;
ctx.restore();
return [xBoundsArrowLess, xBoundsNumber, xBoundsArrowMore];
}
drawNumberWidgetPart.WIDTH_TOTAL = 9 + 3 + 32 + 3 + 9;
type DrawTogglePartOptions = {
posX: number;
posY: number;
height: number;
value: boolean | null;
};
/**
* Draws a toggle for a widget. The toggle is a three-way switch with left being false, right being
* true, and a middle state being null.
*/
export function drawTogglePart(
ctx: CanvasRenderingContext2D,
options: DrawTogglePartOptions,
): Vector2 {
const lowQuality = isLowQuality();
ctx.save();
const {posX, posY, height, value} = options;
const toggleRadius = height * 0.36; // This is the standard toggle height calc.
const toggleBgWidth = height * 1.5; // We don't draw a separate bg, but this would be it.
// Toggle Track
if (!lowQuality) {
ctx.beginPath();
ctx.roundRect(posX + 4, posY + 4, toggleBgWidth - 8, height - 8, [height * 0.5]);
ctx.globalAlpha = app.canvas.editor_alpha * 0.25;
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.fill();
ctx.globalAlpha = app.canvas.editor_alpha;
}
// Toggle itself
ctx.fillStyle = value === true ? "#89B" : "#888";
const toggleX =
lowQuality || value === false
? posX + height * 0.5
: value === true
? posX + height
: posX + height * 0.75;
ctx.beginPath();
ctx.arc(toggleX, posY + height * 0.5, toggleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return [posX, toggleBgWidth];
}
export function drawInfoIcon(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
size: number = 12,
) {
ctx.save();
ctx.beginPath();
ctx.roundRect(x, y, size, size, [size * 0.1]);
ctx.fillStyle = "#2f82ec";
ctx.strokeStyle = "#0f2a5e";
ctx.fill();
// ctx.stroke();
ctx.strokeStyle = "#FFF";
ctx.lineWidth = 2;
// ctx.lineCap = 'round';
const midX = x + size / 2;
const serifSize = size * 0.175;
ctx.stroke(
new Path2D(`
M ${midX} ${y + size * 0.15}
v 2
M ${midX - serifSize} ${y + size * 0.45}
h ${serifSize}
v ${size * 0.325}
h ${serifSize}
h -${serifSize * 2}
`),
);
ctx.restore();
}
export function drawPlusIcon(
ctx: CanvasRenderingContext2D,
x: number,
midY: number,
size: number = 12,
) {
ctx.save();
const s = size / 3;
const plus = new Path2D(`
M ${x} ${midY + s / 2}
v-${s} h${s} v-${s} h${s}
v${s} h${s} v${s} h-${s}
v${s} h-${s} v-${s} h-${s}
z
`);
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.fillStyle = "#3a3";
ctx.strokeStyle = "#383";
ctx.fill(plus);
ctx.stroke(plus);
ctx.restore();
}
/**
* Draws a better button.
*/
export function drawWidgetButton(
ctx: CanvasRenderingContext2D,
options: WidgetRenderingOptions,
text: string | null = null,
isMouseDownedAndOver: boolean = false,
) {
const borderRadius = isLowQuality() ? 0 : (options.borderRadius ?? 4);
ctx.save();
if (!isLowQuality() && !isMouseDownedAndOver) {
drawRoundedRectangle(ctx, {
size: [options.size[0] - 2, options.size[1]],
pos: [options.pos[0] + 1, options.pos[1] + 1],
borderRadius,
colorBackground: "#000000aa",
colorStroke: "#000000aa",
});
}
// BG
drawRoundedRectangle(ctx, {
size: options.size,
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
borderRadius,
colorBackground: isMouseDownedAndOver ? "#444" : LiteGraph.WIDGET_BGCOLOR,
colorStroke: "transparent",
});
if (isLowQuality()) {
ctx.restore();
return;
}
if (!isMouseDownedAndOver) {
// Shadow
drawRoundedRectangle(ctx, {
size: [options.size[0] - 0.75, options.size[1] - 0.75],
pos: options.pos,
borderRadius: borderRadius - 0.5,
colorBackground: "transparent",
colorStroke: "#00000044",
});
// Highlight
drawRoundedRectangle(ctx, {
size: [options.size[0] - 0.75, options.size[1] - 0.75],
pos: [options.pos[0] + 0.75, options.pos[1] + 0.75],
borderRadius: borderRadius - 0.5,
colorBackground: "transparent",
colorStroke: "#ffffff11",
});
}
// Stroke
drawRoundedRectangle(ctx, {
size: options.size,
pos: [options.pos[0], options.pos[1] + (isMouseDownedAndOver ? 1 : 0)],
borderRadius,
colorBackground: "transparent",
});
if (!isLowQuality() && text) {
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText(
text,
options.size[0] / 2,
options.pos[1] + options.size[1] / 2 + (isMouseDownedAndOver ? 1 : 0),
);
}
ctx.restore();
}