/** * 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); } } });