Files
2026-01-28 19:33:35 +13:00

756 lines
34 KiB
JavaScript

/**
* ComfyUI Roto - Frontend Extension
* Interactive curve editor for rotoscoping
*/
import { app } from "/scripts/app.js";
// Core modules
import { createState } from "./core/state.js";
import { evaluateFrame } from "./core/frameEvaluator.js";
import { getActiveShape, getCurvesForFrame, promoteToKeyframe, addPointToCurveAtEdge, commitFrame } from "./core/curveManager.js";
// Utils
import { hexToRgb } from "./utils/helpers.js";
import { deepCopy } from "./utils/helpers.js";
// Components
import { createCanvas, resizeCanvasToImage } from "./components/Canvas.js";
import { createToolbar } from "./components/Toolbar.js";
import { createCurveList } from "./components/CurveList.js";
// Renderer
import { redraw } from "./renderer/redraw.js";
import { drawBackgroundImage, getFrameImageForIndex } from "./renderer/drawBackground.js";
// Events
import { createMouseHandlers } from "./events/mouseHandlers.js";
import { createKeyboardHandlers } from "./events/keyboardHandlers.js";
import { createDocumentListeners } from "./events/documentListeners.js";
app.registerExtension({
name: "ComfyUI.Roto",
async nodeCreated(node) {
// Only attach to RotoMaskNode
// Check both title and class name for compatibility
const nodeTitle = node.getTitle ? node.getTitle() : node.title;
const nodeType = node.type || node.comfyClass;
if (nodeTitle !== "Roto Mask" && nodeType !== "RotoMaskNode") {
return;
}
// Default node size constants (enforced via setTimeout after widget setup)
const defaultWidth = 800;
const defaultHeight = 1200;
// Initialize state
const state = createState();
// List of widgets to hide
const widgetsToHide = ['curve_json', 'frame_index', 'frame_count'];
// Hide widgets early - before creating UI to prevent layout issues
const hideWidgetsEarly = () => {
try {
const widgets = node.widgets || [];
widgetsToHide.forEach(widgetName => {
const widgetIndex = widgets.findIndex(w => w.name === widgetName);
if (widgetIndex !== -1) {
const widget = widgets[widgetIndex];
// ComfyUI/LiteGraph widget hiding - set all size-related properties
widget.computeSize = function() { return [0, -4]; };
widget.hidden = true;
widget.type = "hidden";
widget.options = widget.options || {};
widget.options.hidden = true;
widget.options.height = 0;
widget.y = -100; // Push off screen
widget.last_y = -100;
widget.size = [0, 0];
// Override draw to do nothing
widget.draw = function() { return; };
widget.mouse = function() { return false; };
// Hide DOM element if present
if (widget.element) {
widget.element.style.display = 'none';
widget.element.style.height = '0';
widget.element.style.overflow = 'hidden';
widget.element.style.visibility = 'hidden';
widget.element.style.position = 'absolute';
widget.element.style.pointerEvents = 'none';
}
}
});
// Force node to recalculate size
if (node.setSize) {
node.setSize(node.computeSize());
}
} catch (err) {
console.debug("[ComfyUI Roto] Could not hide widgets early:", err);
}
};
// Hide widgets immediately
hideWidgetsEarly();
// Hook into node's onConfigure to hide widgets when node is loaded
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) {
originalOnConfigure.call(this, config);
}
hideWidgetsEarly();
};
// Hook into onDrawForeground to ensure widgets stay hidden
const originalOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx, graphCanvas) {
// Hide widgets before drawing
const widgets = this.widgets || [];
widgetsToHide.forEach(widgetName => {
const widget = widgets.find(w => w.name === widgetName);
if (widget) {
widget.hidden = true;
widget.y = -100;
}
});
if (originalOnDrawForeground) {
originalOnDrawForeground.call(this, ctx, graphCanvas);
}
};
// Create container - ensure it's a proper DOM element
const container = document.createElement("div");
if (!container || !(container instanceof HTMLElement)) {
console.error("[ComfyUI Roto] Failed to create container element");
return;
}
// Set styles before adding to DOM
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "8px";
container.style.padding = "8px";
container.style.marginTop = "4px"; // Add margin to prevent overlap with hidden widgets
container.style.backgroundColor = "var(--comfy-menu-bg)";
container.style.border = "1px solid var(--border-color)";
container.style.borderRadius = "4px";
container.style.width = "100%";
container.style.minHeight = "540px";
container.style.boxSizing = "border-box";
container.style.overflow = "hidden"; // Prevent content from poking out
container.style.position = "relative"; // Ensure proper positioning
// Function to update container height based on node size
const updateContainerHeight = () => {
if (!node || !node.size) return;
// node.size is [width, height]
// Use a generous offset to ensure content stays within node bounds
const nodeHeight = node.size[1];
const totalOffset = 135; // Header + margins + borders + padding + buffer
const availableHeight = nodeHeight - totalOffset;
if (availableHeight > 500) {
container.style.height = `${availableHeight}px`;
} else {
container.style.height = ''; // Let minHeight take over
}
};
// Make container focusable for keyboard shortcuts
container.tabIndex = 0;
// Create canvas
const { canvas, ctx } = createCanvas(state);
// Widget reference (will be set later)
let widgetRef = null;
// Canvas scale update function (will be defined after canvasWrapper)
let updateCanvasScale = null;
// Helper functions
function updateCanvasLayout() {
container.style.minHeight = `${canvas.height + 56}px`;
// Update canvas display scale
if (updateCanvasScale) {
updateCanvasScale();
}
// Force node to recalculate size when canvas changes
if (node && typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
}
function resizeCanvasToImageFn(img) {
resizeCanvasToImage(canvas, state, img, updateCanvasLayout);
}
// Create callbacks object for components
const callbacks = {
getActiveShape: () => getActiveShape(state),
getCurvesForFrame: (frame) => getCurvesForFrame(state, frame),
evaluateFrame: (shape, frameIndex, options) => evaluateFrame(shape, frameIndex, options),
promoteToKeyframe: (curveId) => promoteToKeyframe(state, evaluateFrame, deepCopy, curveId),
commitFrame: () => commitFrame(state, updateNodeInput),
redraw: () => redraw(ctx, canvas, state, callbacks, node, app),
updateNodeInput,
setMode,
removeDocumentListeners: null, // Will be set after document listeners are created
attachDocumentListeners: null, // Will be set after document listeners are created
drawBackgroundImage: (ctx, canvas, state, node, app) => drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, callbacks.redraw),
getFrameImageForIndex: (index) => getFrameImageForIndex(state, index),
resizeCanvasToImage: resizeCanvasToImageFn,
addPointToCurveAtEdge: null, // Will be set after toolbar is created
updateAddPointButton: null, // Will be set after toolbar is created
updateCurveList: null, // Will be set after curve list is created
setFrame: null // Will be set after toolbar is created
};
// Create toolbar
const toolbarControls = createToolbar(state, callbacks, canvas, node);
callbacks.updateAddPointButton = toolbarControls.updateAddPointButton;
callbacks.updateToggleSmoothButton = toolbarControls.updateToggleSmoothButton;
callbacks.setFrame = toolbarControls.setFrame;
// Initial button state update
if (toolbarControls.updateToggleSmoothButton) {
toolbarControls.updateToggleSmoothButton();
}
// Add toggle smooth callback
callbacks.togglePointSmoothness = () => {
// Require both curve and point selection
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// Promote affected curves to keyframes
const affectedCurveIds = new Set();
state.selectedPoints.forEach(pointKey => {
const [curveId] = pointKey.split(":");
if (state.selectedCurves.has(curveId)) {
affectedCurveIds.add(curveId);
promoteToKeyframe(state, evaluateFrame, deepCopy, curveId);
}
});
// Get keyframe curves
if (!shape.frames[frameKey]) shape.frames[frameKey] = [];
const curves = shape.frames[frameKey];
// Determine target state (if all hard, make smooth; otherwise make hard)
let allHard = true;
let allSmooth = true;
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
// Only check points in selected curves
if (!state.selectedCurves.has(curveId)) {
return;
}
const curve = curves.find(c => c.id === curveId);
if (!curve) {
// Curve doesn't exist - skip
return;
}
// Get pointTypes with validation
let types;
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
types = curve.pointTypes;
} else {
types = curve.points.map(() => "hard");
}
const idx = parseInt(pointIndex);
if (idx >= 0 && idx < curve.points.length && idx < types.length) {
const type = types[idx];
if (type === "smooth") {
allHard = false;
} else if (type === "hard") {
allSmooth = false;
}
// Invalid type treated as "hard" (defensive)
}
});
const targetType = allHard ? "smooth" : "hard";
// Collect point indices to toggle per curve
const curvePointsToToggle = new Map(); // curveId -> Set of point indices
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
// Only toggle points in selected curves
if (!state.selectedCurves.has(curveId)) {
return;
}
if (!curvePointsToToggle.has(curveId)) {
curvePointsToToggle.set(curveId, new Set());
}
curvePointsToToggle.set(curveId, curvePointsToToggle.get(curveId).add(parseInt(pointIndex)));
});
// Update pointTypes in ALL keyframes for each affected curve
curvePointsToToggle.forEach((pointIndices, curveId) => {
// Iterate through all keyframes
Object.keys(shape.frames).forEach(fKey => {
const frameCurves = shape.frames[fKey] || [];
const curve = frameCurves.find(c => c.id === curveId);
if (!curve) return;
// Ensure pointTypes array exists and matches points length
if (!Array.isArray(curve.pointTypes) || curve.pointTypes.length !== curve.points.length) {
curve.pointTypes = curve.points.map(() => "hard");
}
// Toggle each selected point index
pointIndices.forEach(idx => {
if (idx >= 0 && idx < curve.points.length && idx < curve.pointTypes.length) {
curve.pointTypes[idx] = targetType;
}
});
});
});
commitFrame(state, updateNodeInput);
callbacks.redraw();
if (toolbarControls.updateToggleSmoothButton) {
toolbarControls.updateToggleSmoothButton();
}
};
// Set up addPointToCurveAtEdge callback now that toolbarControls exists
callbacks.addPointToCurveAtEdge = (curveId, edgeIndex, position) => {
addPointToCurveAtEdge(
state,
curveId,
edgeIndex,
position,
(state, evaluateFrameFn, deepCopyFn, curveId) => promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId),
evaluateFrame,
deepCopy,
() => commitFrame(state, updateNodeInput),
callbacks.redraw,
toolbarControls.updateAddPointButton
);
};
// Create curve list
const curveListControls = createCurveList(state, callbacks);
callbacks.updateCurveList = curveListControls.updateCurveList;
// Create mouse handlers first (returns handleDragMove function)
const handleDragMoveFn = createMouseHandlers(canvas, container, state, callbacks);
// Create document listeners with the handleDragMove function
const documentListeners = createDocumentListeners(state, handleDragMoveFn);
callbacks.removeDocumentListeners = documentListeners.removeDocumentListeners;
callbacks.attachDocumentListeners = documentListeners.attachDocumentListeners;
// Create keyboard handlers
createKeyboardHandlers(container, state, callbacks);
// Mode management
function setMode(mode) {
// Clean up dragging if switching modes
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
state.mode = mode;
updateModeButtons();
callbacks.redraw();
}
function updateModeButtons() {
if (state.mode === "draw") {
toolbarControls.drawBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
toolbarControls.editBtn.style.backgroundColor = "";
} else {
toolbarControls.editBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
toolbarControls.drawBtn.style.backgroundColor = "";
}
}
// Update node input with current state
function updateNodeInput() {
try {
const shape = getActiveShape(state);
// Extract frame_paths from state.frameImages
const frame_paths = state.frameImages.map(frame => ({
filename: frame.filename,
subfolder: frame.subfolder || ""
}));
// Convert background color from hex to RGB [0-1]
const background_color = hexToRgb(state.backgroundColor);
const meta = {
version: 1,
width: state.canvasWidth,
height: state.canvasHeight,
feather_amount: state.featherAmount,
background_color: background_color,
mask_color: state.maskColor || "#ffffff",
mask_opacity: state.maskOpacity || 10,
frame_paths: frame_paths
};
const curveData = {
meta: meta,
shapes: {}
};
// Add all shapes
Object.keys(state.shapes).forEach(shapeId => {
curveData.shapes[shapeId] = state.shapes[shapeId];
});
const jsonString = JSON.stringify(curveData);
// Find curve_json widget
const widgets = node.widgets || [];
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
if (curveJsonWidget) {
curveJsonWidget.value = jsonString;
// Trigger widget change event
if (curveJsonWidget.callback) {
curveJsonWidget.callback(jsonString);
}
} else {
console.warn("[ComfyUI Roto] curve_json widget not found");
}
// Sync current frame from widget if present
const frameWidget = widgets.find(w => w.name === "frame_index");
if (frameWidget && frameWidget.value !== undefined) {
const nextFrame = Number(frameWidget.value) || 1;
if (nextFrame !== state.currentFrame) {
state.currentFrame = nextFrame;
toolbarControls.frameSlider.value = String(nextFrame);
}
}
} catch (error) {
console.error("[ComfyUI Roto] Error updating node input:", error);
}
}
// Initialize feather amount and background color from existing curve_json
function initializeFeatherAmount() {
try {
const widgets = node.widgets || [];
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
if (curveJsonWidget && curveJsonWidget.value) {
try {
const curveData = JSON.parse(curveJsonWidget.value);
const meta = curveData?.meta || {};
// Initialize feather amount
const featherAmount = typeof meta.feather_amount === 'number' && !isNaN(meta.feather_amount)
? Math.max(0, Math.min(100, meta.feather_amount))
: 0.0;
state.featherAmount = featherAmount;
toolbarControls.featherSlider.value = String(featherAmount);
toolbarControls.featherValueLabel.textContent = featherAmount.toFixed(1);
// Initialize background color
if (Array.isArray(meta.background_color) && meta.background_color.length === 3) {
const [r, g, b] = meta.background_color;
const rInt = Math.round(Math.max(0, Math.min(255, r * 255)));
const gInt = Math.round(Math.max(0, Math.min(255, g * 255)));
const bInt = Math.round(Math.max(0, Math.min(255, b * 255)));
const hexColor = `#${rInt.toString(16).padStart(2, '0')}${gInt.toString(16).padStart(2, '0')}${bInt.toString(16).padStart(2, '0')}`;
state.backgroundColor = hexColor;
toolbarControls.bgColorInput.value = hexColor;
}
// Initialize mask color
if (typeof meta.mask_color === 'string') {
state.maskColor = meta.mask_color;
toolbarControls.maskColorInput.value = meta.mask_color;
} else {
state.maskColor = "#ffffff";
toolbarControls.maskColorInput.value = "#ffffff";
}
// Initialize mask opacity
const maskOpacity = typeof meta.mask_opacity === 'number' && !isNaN(meta.mask_opacity)
? Math.max(0, Math.min(100, meta.mask_opacity))
: 10;
state.maskOpacity = maskOpacity;
toolbarControls.maskOpacitySlider.value = String(maskOpacity);
toolbarControls.maskOpacityValueLabel.textContent = String(maskOpacity);
// Initialize frame_paths if present
if (Array.isArray(meta.frame_paths) && meta.frame_paths.length > 0) {
state.frameImages = meta.frame_paths.map(fp => ({
filename: fp.filename || "",
subfolder: fp.subfolder || "",
url: `/view?filename=${fp.filename}&type=input&subfolder=${fp.subfolder || ""}&t=${Date.now()}`
}));
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
}
// Initialize frame output controls from widgets
const frameIndexWidget = widgets.find(w => w.name === "frame_index");
const frameCountWidget = widgets.find(w => w.name === "frame_count");
const startFrame = (frameIndexWidget && frameIndexWidget.value) ? Number(frameIndexWidget.value) : 1;
const frameCount = (frameCountWidget && frameCountWidget.value) ? Number(frameCountWidget.value) : 1;
const endFrame = startFrame + frameCount - 1;
toolbarControls.startFrameInput.value = String(startFrame);
toolbarControls.endFrameInput.value = String(endFrame);
} catch (parseError) {
// Invalid JSON, use default
console.warn("[ComfyUI Roto] Failed to parse curve_json for initialization:", parseError);
}
}
} catch (error) {
console.warn("[ComfyUI Roto] Error initializing from curve_json:", error);
}
}
// Create canvas wrapper for flexible sizing
const canvasWrapper = document.createElement("div");
canvasWrapper.style.flex = "1 1 0"; // Grow to fill available space, start from 0
canvasWrapper.style.minHeight = "200px";
canvasWrapper.style.display = "flex";
canvasWrapper.style.alignItems = "center";
canvasWrapper.style.justifyContent = "center";
canvasWrapper.style.overflow = "hidden";
canvasWrapper.style.position = "relative";
canvasWrapper.style.width = "100%"; // Ensure full width
canvasWrapper.appendChild(canvas);
// Make canvas scale to fit wrapper while maintaining aspect ratio
updateCanvasScale = () => {
if (!canvasWrapper.offsetWidth || !canvasWrapper.offsetHeight) return;
const wrapperWidth = canvasWrapper.offsetWidth;
const wrapperHeight = canvasWrapper.offsetHeight;
const canvasAspect = canvas.width / canvas.height;
const wrapperAspect = wrapperWidth / wrapperHeight;
let displayWidth, displayHeight;
if (canvasAspect > wrapperAspect) {
// Canvas is wider - fit to width
displayWidth = wrapperWidth;
displayHeight = wrapperWidth / canvasAspect;
} else {
// Canvas is taller - fit to height
displayHeight = wrapperHeight;
displayWidth = wrapperHeight * canvasAspect;
}
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
};
// Use ResizeObserver to detect wrapper size changes
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
updateCanvasScale();
});
resizeObserver.observe(canvasWrapper);
// Also observe container size changes to update canvas when node is resized
const containerResizeObserver = new ResizeObserver(() => {
updateCanvasScale();
});
containerResizeObserver.observe(container);
}
// Assemble UI in new layout order
container.appendChild(toolbarControls.loadFramesSection);
container.appendChild(toolbarControls.toolButtonsSection);
container.appendChild(canvasWrapper);
container.appendChild(toolbarControls.timelineSection);
container.appendChild(curveListControls.curveListContainer);
container.appendChild(toolbarControls.settingsSection);
// Verify container is still valid before adding widget
if (!container || !(container instanceof HTMLElement)) {
console.error("[ComfyUI Roto] Container is not a valid HTMLElement");
return;
}
// Add widget to node
try {
if (typeof node.addDOMWidget !== "function") {
console.error("[ComfyUI Roto] node.addDOMWidget is not a function");
return;
}
// Calculate MINIMUM widget size (fixed values to prevent feedback loop)
const calculateWidgetSize = () => {
const padding = 16; // 8px padding on each side
const gaps = 8 * 6; // 6 gaps between sections (8px each)
// Use fixed estimates for UI sections to prevent feedback loop
const loadFramesHeight = 40;
const toolButtonsHeight = 32;
const timelineHeight = 40;
const curveListHeight = 120;
const settingsHeight = 80;
// Use canvas native height as minimum (not wrapper height!)
const canvasHeight = Math.max(canvas.height, 300);
const totalHeight = loadFramesHeight + toolButtonsHeight + canvasHeight +
timelineHeight + curveListHeight + settingsHeight + gaps + padding;
const totalWidth = Math.max(canvas.width + padding, 1200); // Minimum width (3x wider)
return [totalWidth, totalHeight];
};
const widget = node.addDOMWidget("roto_editor", "custom", null, { element: container });
widgetRef = widget; // Store reference for use in other functions
if (widget) {
widget.element = container;
widget.computeSize = () => {
// Use actual measured heights if available, otherwise estimate
return calculateWidgetSize();
};
}
// Hide widgets - multiple approaches
const hideWidgetByName = (widgetName) => {
try {
const widget = (node.widgets || []).find(w => w.name === widgetName);
if (widget) {
// ComfyUI/LiteGraph widget hiding - set all size-related properties
widget.computeSize = function() { return [0, -4]; };
widget.hidden = true;
widget.type = "hidden";
widget.options = widget.options || {};
widget.options.hidden = true;
widget.options.height = 0;
widget.y = -100; // Push off screen
widget.last_y = -100;
widget.size = [0, 0];
// Override draw to do nothing
widget.draw = function() { return; };
widget.mouse = function() { return false; };
// Hide DOM element if present
if (widget.element) {
widget.element.style.display = 'none';
widget.element.style.height = '0';
widget.element.style.overflow = 'hidden';
widget.element.style.visibility = 'hidden';
widget.element.style.position = 'absolute';
widget.element.style.pointerEvents = 'none';
}
}
} catch (err) {
console.debug(`[ComfyUI Roto] Could not hide ${widgetName} widget:`, err);
}
};
// Hide all internal widgets
const hideAllWidgets = () => {
hideWidgetByName('curve_json');
hideWidgetByName('frame_index');
hideWidgetByName('frame_count');
};
hideAllWidgets();
// Hide again after short delays to catch widgets rendered later
[100, 500, 1000].forEach(delay => {
setTimeout(hideAllWidgets, delay);
});
// Hook frame_index widget to update current frame
const frameWidget = (node.widgets || []).find(w => w.name === "frame_index");
if (frameWidget && typeof frameWidget.callback === "function") {
const originalCallback = frameWidget.callback;
frameWidget.callback = function (v) {
if (state.updatingFrame) {
return originalCallback.call(frameWidget, v, null, node);
}
state.updatingFrame = true;
state.currentFrame = Number(v) || 1;
toolbarControls.frameSlider.value = String(state.currentFrame);
callbacks.redraw();
const result = originalCallback.call(frameWidget, v, null, node);
state.updatingFrame = false;
return result;
};
}
// Initial setup after widget is attached
requestAnimationFrame(() => {
try {
initializeFeatherAmount();
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
toolbarControls.frameSlider.value = String(state.currentFrame);
toolbarControls.updateFrameLabel();
callbacks.redraw();
updateNodeInput();
// Initial canvas scale update
if (updateCanvasScale) {
updateCanvasScale();
}
// Hook into node's onResize to update container height
const originalOnResize = node.onResize;
node.onResize = function(size) {
if (originalOnResize) {
originalOnResize.call(this, size);
}
updateContainerHeight();
if (updateCanvasScale) {
updateCanvasScale();
}
};
// Initial height update
updateContainerHeight();
// Force minimum size after everything is initialized (multiple attempts)
const enforceMinSize = () => {
if (node.size && node.size[0] < defaultWidth) {
const newSize = [defaultWidth, Math.max(node.size[1], defaultHeight)];
node.size = newSize;
if (node.setSize) node.setSize(newSize);
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, true);
updateContainerHeight();
if (updateCanvasScale) updateCanvasScale();
}
};
setTimeout(enforceMinSize, 50);
setTimeout(enforceMinSize, 200);
setTimeout(enforceMinSize, 500);
} catch (error) {
console.error("[ComfyUI Roto] Error in initial setup:", error);
}
});
} catch (error) {
console.error("[ComfyUI Roto] Error adding DOM widget:", error);
console.error("[ComfyUI Roto] Error details:", error.stack);
}
}
});