756 lines
34 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
});
|