Initial commit

This commit is contained in:
TechnicalTod
2026-01-28 19:33:35 +13:00
commit 4cd66803ef
34 changed files with 4455 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* Canvas component creation
*/
/**
* Create and configure the canvas element
* @param {Object} state - Editor state
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} Canvas element and context
*/
export function createCanvas(state) {
const canvas = document.createElement("canvas");
canvas.width = state.canvasWidth;
canvas.height = state.canvasHeight;
canvas.style.border = "1px solid var(--border-color)";
canvas.style.backgroundColor = "#1a1a1a";
canvas.style.cursor = "crosshair";
// Don't use flex on canvas - let updateCanvasScale handle sizing
canvas.style.display = "block"; // Remove inline spacing
canvas.tabIndex = 0;
const ctx = canvas.getContext("2d");
return { canvas, ctx };
}
/**
* Resize canvas to match image dimensions
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {HTMLImageElement} img - Image to match
* @param {Function} updateCanvasLayoutFn - Function to update canvas layout
*/
export function resizeCanvasToImage(canvas, state, img, updateCanvasLayoutFn) {
if (!img || !img.width || !img.height) return;
const maxSize = 2048;
const width = Math.min(maxSize, img.width);
const height = Math.min(maxSize, img.height);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
state.canvasWidth = width;
state.canvasHeight = height;
if (updateCanvasLayoutFn) {
updateCanvasLayoutFn();
}
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Curve list component creation and management
*/
/**
* Create the curve list panel
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.setFrame - Set current frame
* @param {Function} callbacks.updateNodeInput - Update node input
* @param {Function} callbacks.redraw - Redraw canvas
* @returns {Object} Curve list elements and update function
*/
export function createCurveList(state, callbacks) {
const curveListContainer = document.createElement("div");
curveListContainer.style.display = "flex";
curveListContainer.style.flexDirection = "column";
curveListContainer.style.gap = "4px";
curveListContainer.style.height = "120px"; // Fixed height to fit ~3 shapes
curveListContainer.style.minHeight = "120px"; // Prevent shrinking
curveListContainer.style.maxHeight = "120px"; // Prevent growing into canvas space
curveListContainer.style.overflow = "auto";
curveListContainer.style.flexShrink = "0"; // Don't shrink when container is tight
// Title
const curveListHeader = document.createElement("div");
curveListHeader.textContent = "Curves";
curveListHeader.style.fontSize = "13px";
curveListHeader.style.fontWeight = "600";
curveListHeader.style.color = "var(--fg-color)";
// Count
const curveListCount = document.createElement("div");
curveListCount.style.fontSize = "12px";
curveListCount.style.opacity = "0.7";
curveListCount.style.marginBottom = "4px";
const curveList = document.createElement("ul");
curveList.style.listStyle = "none";
curveList.style.padding = "0";
curveList.style.margin = "0";
curveListContainer.appendChild(curveListHeader);
curveListContainer.appendChild(curveListCount);
curveListContainer.appendChild(curveList);
function updateCurveList() {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
const currentCurves = shape.frames[frameKey] || [];
const curveMap = new Map();
Object.entries(shape.frames || {}).forEach(([frame, curves]) => {
(curves || []).forEach(curve => {
if (!curve || !curve.id) return;
if (!curveMap.has(curve.id)) {
curveMap.set(curve.id, new Set());
}
curveMap.get(curve.id).add(frame);
});
});
curveList.innerHTML = "";
const ids = Array.from(curveMap.keys());
curveListCount.textContent = `${ids.length} curve(s)`;
// Create a map of curve IDs to display names (Shape 1, Shape 2, etc.)
const curveDisplayNames = new Map();
ids.forEach((id, index) => {
curveDisplayNames.set(id, `Shape ${index + 1}`);
});
ids.forEach(id => {
const item = document.createElement("li");
item.style.display = "flex";
item.style.justifyContent = "space-between";
item.style.alignItems = "center";
item.style.padding = "2px 0";
item.style.gap = "4px";
const label = document.createElement("span");
label.textContent = curveDisplayNames.get(id);
label.style.cursor = "pointer";
const frames = curveMap.get(id);
const frameNumbers = frames ? Array.from(frames).map(f => parseInt(f)).sort((a, b) => a - b) : [];
const hasKeyOnCurrentFrame = frames && frames.has(frameKey);
const dot = document.createElement("span");
dot.style.display = "inline-block";
dot.style.width = "6px";
dot.style.height = "6px";
dot.style.borderRadius = "50%";
dot.style.marginRight = "6px";
dot.style.backgroundColor = hasKeyOnCurrentFrame ? "#ff4d4d" : "transparent";
const left = document.createElement("div");
left.style.display = "flex";
left.style.alignItems = "center";
left.appendChild(dot);
left.appendChild(label);
// Keyframe navigation buttons
const navContainer = document.createElement("div");
navContainer.style.display = "flex";
navContainer.style.gap = "2px";
navContainer.style.alignItems = "center";
// Previous keyframe button
const prevKeyBtn = document.createElement("button");
prevKeyBtn.textContent = "◀";
prevKeyBtn.className = "comfy-button";
prevKeyBtn.type = "button";
prevKeyBtn.style.padding = "0 4px";
prevKeyBtn.style.minWidth = "20px";
prevKeyBtn.style.fontSize = "10px";
prevKeyBtn.title = "Previous keyframe";
prevKeyBtn.onclick = (e) => {
e.stopPropagation();
const currentFrameNum = state.currentFrame;
const prevFrames = frameNumbers.filter(f => f < currentFrameNum);
if (prevFrames.length > 0) {
const prevFrame = prevFrames[prevFrames.length - 1];
callbacks.setFrame(prevFrame);
}
};
// Next keyframe button
const nextKeyBtn = document.createElement("button");
nextKeyBtn.textContent = "▶";
nextKeyBtn.className = "comfy-button";
nextKeyBtn.type = "button";
nextKeyBtn.style.padding = "0 4px";
nextKeyBtn.style.minWidth = "20px";
nextKeyBtn.style.fontSize = "10px";
nextKeyBtn.title = "Next keyframe";
nextKeyBtn.onclick = (e) => {
e.stopPropagation();
const currentFrameNum = state.currentFrame;
const nextFrames = frameNumbers.filter(f => f > currentFrameNum);
if (nextFrames.length > 0) {
const nextFrame = nextFrames[0];
callbacks.setFrame(nextFrame);
}
};
// Delete keyframe button (only for current frame)
const deleteKeyBtn = document.createElement("button");
deleteKeyBtn.textContent = "🔑✕";
deleteKeyBtn.className = "comfy-button";
deleteKeyBtn.type = "button";
deleteKeyBtn.style.padding = "0 4px";
deleteKeyBtn.style.minWidth = "28px";
deleteKeyBtn.style.fontSize = "10px";
deleteKeyBtn.title = "Delete keyframe for this curve";
deleteKeyBtn.disabled = !hasKeyOnCurrentFrame;
deleteKeyBtn.style.opacity = hasKeyOnCurrentFrame ? "1" : "0.4";
deleteKeyBtn.onclick = (e) => {
e.stopPropagation();
if (!hasKeyOnCurrentFrame) return;
// Remove this curve from current frame only
if (shape.frames[frameKey]) {
shape.frames[frameKey] = shape.frames[frameKey].filter(c => c.id !== id);
if (shape.frames[frameKey].length === 0) {
delete shape.frames[frameKey];
}
}
state.selectedCurves.delete(id);
state.selectedPoints.clear();
callbacks.updateNodeInput();
callbacks.redraw();
};
// Delete curve entirely button
const deleteCurveBtn = document.createElement("button");
deleteCurveBtn.textContent = "✕";
deleteCurveBtn.className = "comfy-button";
deleteCurveBtn.type = "button";
deleteCurveBtn.style.padding = "0 6px";
deleteCurveBtn.style.minWidth = "24px";
deleteCurveBtn.title = "Delete curve from all frames";
deleteCurveBtn.onclick = (e) => {
e.stopPropagation();
Object.keys(shape.frames || {}).forEach(frame => {
shape.frames[frame] = (shape.frames[frame] || []).filter(c => c.id !== id);
if (shape.frames[frame].length === 0) {
delete shape.frames[frame];
}
});
state.selectedCurves.delete(id);
state.selectedPoints.clear();
callbacks.updateNodeInput();
callbacks.redraw();
};
navContainer.appendChild(prevKeyBtn);
navContainer.appendChild(nextKeyBtn);
navContainer.appendChild(deleteKeyBtn);
navContainer.appendChild(deleteCurveBtn);
if (state.selectedCurves.has(id)) {
label.style.color = "#4a9eff";
}
label.onclick = (e) => {
e.stopPropagation();
const curve = currentCurves.find(c => c.id === id);
state.selectedCurves.clear();
state.selectedPoints.clear();
if (curve) {
state.selectedCurves.add(id);
}
callbacks.redraw();
};
item.appendChild(left);
item.appendChild(navContainer);
curveList.appendChild(item);
});
}
return {
curveListContainer,
updateCurveList
};
}
+777
View File
@@ -0,0 +1,777 @@
/**
* Toolbar component creation and management
*/
import { uploadImageFile } from "../utils/fileUpload.js";
/**
* Create the toolbar with all controls
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.updateNodeInput - Update node input
* @param {Function} callbacks.setFrame - Set current frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.setMode - Set editor mode
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
* @param {Function} callbacks.updateModeButtons - Update mode button styles
* @param {Function} callbacks.getFrameImageForIndex - Get frame image
* @param {Function} callbacks.resizeCanvasToImage - Resize canvas to image
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} node - ComfyUI node
* @returns {Object} Toolbar elements and controls
*/
export function createToolbar(state, callbacks, canvas, node) {
// Section 1: Load Frames (full width at top)
const loadFramesSection = document.createElement("div");
loadFramesSection.style.display = "flex";
loadFramesSection.style.width = "100%";
loadFramesSection.style.gap = "8px";
// Feather amount slider
const featherLabel = document.createElement("span");
featherLabel.textContent = "Feather Amount:";
featherLabel.style.minWidth = "120px";
featherLabel.style.fontSize = "12px";
const featherSlider = document.createElement("input");
featherSlider.type = "range";
featherSlider.min = "0";
featherSlider.max = "100";
featherSlider.step = "0.1";
featherSlider.value = "0";
featherSlider.style.flex = "1 1 auto";
featherSlider.style.minWidth = "100px";
const featherValueLabel = document.createElement("span");
featherValueLabel.textContent = "0.0";
featherValueLabel.style.minWidth = "40px";
featherValueLabel.style.fontSize = "12px";
featherValueLabel.style.textAlign = "right";
featherSlider.oninput = (e) => {
const value = Math.max(0, Math.min(100, parseFloat(e.target.value) || 0));
state.featherAmount = value;
featherSlider.value = String(value);
featherValueLabel.textContent = value.toFixed(1);
callbacks.updateNodeInput();
};
const featherContainer = document.createElement("div");
featherContainer.style.display = "flex";
featherContainer.style.gap = "8px";
featherContainer.style.alignItems = "center";
featherContainer.style.flex = "1 1 auto";
featherContainer.style.minWidth = "200px";
featherContainer.appendChild(featherLabel);
featherContainer.appendChild(featherSlider);
featherContainer.appendChild(featherValueLabel);
// Background color picker
const bgColorLabel = document.createElement("span");
bgColorLabel.textContent = "Background color:";
bgColorLabel.style.minWidth = "90px";
bgColorLabel.style.fontSize = "12px";
const bgColorInput = document.createElement("input");
bgColorInput.type = "color";
bgColorInput.value = "#000000";
bgColorInput.style.width = "40px";
bgColorInput.style.height = "24px";
bgColorInput.style.cursor = "pointer";
bgColorInput.style.border = "1px solid var(--border-color)";
bgColorInput.style.borderRadius = "2px";
bgColorInput.onchange = (e) => {
state.backgroundColor = e.target.value;
callbacks.updateNodeInput();
};
const bgColorContainer = document.createElement("div");
bgColorContainer.style.display = "flex";
bgColorContainer.style.gap = "8px";
bgColorContainer.style.alignItems = "center";
bgColorContainer.appendChild(bgColorLabel);
bgColorContainer.appendChild(bgColorInput);
// Section 4: Settings - created early so we can append to it
const settingsSection = document.createElement("div");
settingsSection.style.display = "flex";
settingsSection.style.flexDirection = "column";
settingsSection.style.gap = "6px";
settingsSection.style.width = "100%";
// Divider at top of settings section (separates from curve list)
const settingsDivider = document.createElement("div");
settingsDivider.style.width = "100%";
settingsDivider.style.height = "1px";
settingsDivider.style.backgroundColor = "var(--border-color)";
settingsDivider.style.marginBottom = "4px";
// === Mask Visualization Settings ===
const maskVisualizationTitle = document.createElement("div");
maskVisualizationTitle.textContent = "Mask Visualization Settings";
maskVisualizationTitle.style.fontSize = "13px";
maskVisualizationTitle.style.fontWeight = "600";
maskVisualizationTitle.style.color = "var(--fg-color)";
// Mask Color picker
const maskColorLabel = document.createElement("span");
maskColorLabel.textContent = "Mask Color:";
maskColorLabel.style.fontSize = "12px";
maskColorLabel.style.minWidth = "70px";
const maskColorInput = document.createElement("input");
maskColorInput.type = "color";
maskColorInput.value = "#ffffff";
maskColorInput.style.width = "40px";
maskColorInput.style.height = "24px";
maskColorInput.style.cursor = "pointer";
maskColorInput.style.border = "1px solid var(--border-color)";
maskColorInput.style.borderRadius = "2px";
maskColorInput.onchange = (e) => {
state.maskColor = e.target.value;
callbacks.updateNodeInput();
callbacks.redraw();
};
const maskColorContainer = document.createElement("div");
maskColorContainer.style.display = "flex";
maskColorContainer.style.gap = "4px";
maskColorContainer.style.alignItems = "center";
maskColorContainer.appendChild(maskColorLabel);
maskColorContainer.appendChild(maskColorInput);
// Mask Opacity slider
const maskOpacityLabel = document.createElement("span");
maskOpacityLabel.textContent = "Mask Opacity:";
maskOpacityLabel.style.fontSize = "12px";
maskOpacityLabel.style.minWidth = "90px";
const maskOpacitySlider = document.createElement("input");
maskOpacitySlider.type = "range";
maskOpacitySlider.min = "0";
maskOpacitySlider.max = "100";
maskOpacitySlider.step = "1";
maskOpacitySlider.value = "10";
maskOpacitySlider.style.flex = "1 1 auto";
maskOpacitySlider.style.minWidth = "100px";
const maskOpacityValueLabel = document.createElement("span");
maskOpacityValueLabel.textContent = "10";
maskOpacityValueLabel.style.minWidth = "30px";
maskOpacityValueLabel.style.fontSize = "12px";
maskOpacityValueLabel.style.textAlign = "right";
maskOpacitySlider.oninput = (e) => {
const value = Math.max(0, Math.min(100, parseInt(e.target.value) || 0));
state.maskOpacity = value;
maskOpacitySlider.value = String(value);
maskOpacityValueLabel.textContent = String(value);
callbacks.updateNodeInput();
callbacks.redraw();
};
const maskOpacityContainer = document.createElement("div");
maskOpacityContainer.style.display = "flex";
maskOpacityContainer.style.gap = "8px";
maskOpacityContainer.style.alignItems = "center";
maskOpacityContainer.style.flex = "1 1 auto";
maskOpacityContainer.style.minWidth = "200px";
maskOpacityContainer.appendChild(maskOpacityLabel);
maskOpacityContainer.appendChild(maskOpacitySlider);
maskOpacityContainer.appendChild(maskOpacityValueLabel);
// Mask controls row (color and opacity on same line)
const maskControlsRow = document.createElement("div");
maskControlsRow.style.display = "flex";
maskControlsRow.style.gap = "16px";
maskControlsRow.style.alignItems = "center";
maskControlsRow.style.flexWrap = "wrap";
maskControlsRow.appendChild(maskColorContainer);
maskControlsRow.appendChild(maskOpacityContainer);
// Divider between Mask Visualization Settings and Image Output Settings
const maskImageDivider = document.createElement("div");
maskImageDivider.style.width = "100%";
maskImageDivider.style.height = "1px";
maskImageDivider.style.backgroundColor = "var(--border-color)";
maskImageDivider.style.marginTop = "4px";
maskImageDivider.style.marginBottom = "4px";
// === Image Output Settings ===
const imageOutputTitle = document.createElement("div");
imageOutputTitle.textContent = "Image Output Settings";
imageOutputTitle.style.fontSize = "13px";
imageOutputTitle.style.fontWeight = "600";
imageOutputTitle.style.color = "var(--fg-color)";
// Divider before Output Frame Range
const frameRangeDivider = document.createElement("div");
frameRangeDivider.style.width = "100%";
frameRangeDivider.style.height = "1px";
frameRangeDivider.style.backgroundColor = "var(--border-color)";
frameRangeDivider.style.marginTop = "4px";
// === Output Frame Range ===
const frameRangeTitle = document.createElement("div");
frameRangeTitle.textContent = "Output Frame Range";
frameRangeTitle.style.fontSize = "13px";
frameRangeTitle.style.fontWeight = "600";
frameRangeTitle.style.color = "var(--fg-color)";
frameRangeTitle.style.marginTop = "2px";
// Frame output controls - Start Frame
const startFrameLabel = document.createElement("span");
startFrameLabel.textContent = "Start Frame:";
startFrameLabel.style.fontSize = "12px";
startFrameLabel.style.minWidth = "70px";
const startFrameInput = document.createElement("input");
startFrameInput.type = "number";
startFrameInput.min = "1";
startFrameInput.value = "1";
startFrameInput.style.width = "60px";
startFrameInput.style.height = "24px";
startFrameInput.style.padding = "2px 6px";
startFrameInput.style.border = "1px solid var(--border-color, #555)";
startFrameInput.style.borderRadius = "4px";
startFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
startFrameInput.style.color = "var(--input-text, #fff)";
const startFrameContainer = document.createElement("div");
startFrameContainer.style.display = "flex";
startFrameContainer.style.gap = "4px";
startFrameContainer.style.alignItems = "center";
startFrameContainer.appendChild(startFrameLabel);
startFrameContainer.appendChild(startFrameInput);
// Frame output controls - End Frame
const endFrameLabel = document.createElement("span");
endFrameLabel.textContent = "End Frame:";
endFrameLabel.style.fontSize = "12px";
endFrameLabel.style.minWidth = "70px";
const endFrameInput = document.createElement("input");
endFrameInput.type = "number";
endFrameInput.min = "1";
endFrameInput.value = "1";
endFrameInput.style.width = "60px";
endFrameInput.style.height = "24px";
endFrameInput.style.padding = "2px 6px";
endFrameInput.style.border = "1px solid var(--border-color, #555)";
endFrameInput.style.borderRadius = "4px";
endFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
endFrameInput.style.color = "var(--input-text, #fff)";
const endFrameContainer = document.createElement("div");
endFrameContainer.style.display = "flex";
endFrameContainer.style.gap = "4px";
endFrameContainer.style.alignItems = "center";
endFrameContainer.appendChild(endFrameLabel);
endFrameContainer.appendChild(endFrameInput);
// Helper to calculate frame count from start/end
const getFrameCount = () => {
const startFrame = parseInt(startFrameInput.value) || 1;
const endFrame = parseInt(endFrameInput.value) || 1;
return Math.max(1, endFrame - startFrame + 1);
};
// Update frame range (called when frames are loaded)
const updateFrameRangeInfo = () => {
// This function is kept for compatibility but does nothing visual now
};
// Handle frame output setting changes
const updateFrameWidgets = () => {
const startFrame = parseInt(startFrameInput.value) || 1;
const endFrame = parseInt(endFrameInput.value) || 1;
const frameCount = Math.max(1, endFrame - startFrame + 1);
// Update frame_index widget
const frameIndexWidget = (node.widgets || []).find(w => w.name === "frame_index");
if (frameIndexWidget) {
frameIndexWidget.value = startFrame;
if (typeof frameIndexWidget.callback === "function") {
frameIndexWidget.callback(startFrame);
}
}
// Update frame_count widget
const frameCountWidget = (node.widgets || []).find(w => w.name === "frame_count");
if (frameCountWidget) {
frameCountWidget.value = frameCount;
if (typeof frameCountWidget.callback === "function") {
frameCountWidget.callback(frameCount);
}
}
callbacks.updateNodeInput();
};
startFrameInput.onchange = (e) => {
const value = Math.max(1, parseInt(e.target.value) || 1);
startFrameInput.value = String(value);
// Ensure end frame is not less than start frame
const endValue = parseInt(endFrameInput.value) || 1;
if (endValue < value) {
endFrameInput.value = String(value);
}
updateFrameWidgets();
};
endFrameInput.onchange = (e) => {
const startValue = parseInt(startFrameInput.value) || 1;
const value = Math.max(startValue, parseInt(e.target.value) || 1);
endFrameInput.value = String(value);
updateFrameWidgets();
};
// Frame controls row
const frameControlsRow = document.createElement("div");
frameControlsRow.style.display = "flex";
frameControlsRow.style.gap = "16px";
frameControlsRow.style.alignItems = "center";
frameControlsRow.style.flexWrap = "wrap";
frameControlsRow.appendChild(startFrameContainer);
frameControlsRow.appendChild(endFrameContainer);
// Update feather container to be full width row
featherContainer.style.width = "100%";
// Update bg color container to be full width row
bgColorContainer.style.width = "100%";
// Assemble settings section in order:
// 1. Divider (separates from curve list above)
// 2. Mask Visualization Settings title
// 3. Mask Color and Opacity controls (same line)
// 4. Divider (separates Mask Visualization from Image Output)
// 5. Image Output Settings title
// 6. Feather amount (own line)
// 7. Background colour (own line)
// 8. Divider
// 9. Output Frame Range title
// 10. Start/End frame controls
settingsSection.appendChild(settingsDivider);
settingsSection.appendChild(maskVisualizationTitle);
settingsSection.appendChild(maskControlsRow);
settingsSection.appendChild(maskImageDivider);
settingsSection.appendChild(imageOutputTitle);
settingsSection.appendChild(featherContainer);
settingsSection.appendChild(bgColorContainer);
settingsSection.appendChild(frameRangeDivider);
settingsSection.appendChild(frameRangeTitle);
settingsSection.appendChild(frameControlsRow);
// Load frames button + file input
const loadFramesBtn = document.createElement("button");
loadFramesBtn.textContent = "Load Frames";
loadFramesBtn.className = "comfy-button";
loadFramesBtn.type = "button";
loadFramesBtn.style.width = "100%";
const framesInput = document.createElement("input");
framesInput.type = "file";
framesInput.accept = "image/*";
framesInput.multiple = true;
framesInput.style.display = "none";
loadFramesBtn.onclick = () => framesInput.click();
loadFramesSection.appendChild(loadFramesBtn);
loadFramesSection.appendChild(framesInput);
framesInput.onchange = async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
try {
state.frameImages = [];
state.frameImageCache.clear();
for (const file of files) {
const data = await uploadImageFile(file);
const filename = data.name;
const subfolder = data.subfolder || "";
const url = `/view?filename=${filename}&type=input&subfolder=${subfolder}&t=${Date.now()}`;
state.frameImages.push({ filename, subfolder, url });
}
if (state.frameImages.length > 0) {
const first = await callbacks.getFrameImageForIndex(0);
if (first) {
callbacks.resizeCanvasToImage(first);
}
}
frameSlider.max = String(Math.max(1, state.frameImages.length));
// Auto-set frame_count widget to match loaded frames
const widgets = node.widgets || [];
const frameCountWidget = widgets.find(w => w.name === "frame_count");
if (frameCountWidget) {
frameCountWidget.value = state.frameImages.length;
if (typeof frameCountWidget.callback === "function") {
frameCountWidget.callback(state.frameImages.length);
}
}
// Update the frame output controls (Start Frame = 1, End Frame = total frames)
startFrameInput.value = "1";
endFrameInput.value = String(state.frameImages.length);
updateFrameLabel(); // Update frame counter display
callbacks.setFrame(1);
callbacks.redraw();
callbacks.updateNodeInput(); // Store frame_paths in metadata
} catch (error) {
console.error("[ComfyUI Roto] Failed to load frames:", error);
} finally {
framesInput.value = "";
}
};
// Section 2: Tool Buttons (Draw, Edit, Add Point)
const toolButtonsSection = document.createElement("div");
toolButtonsSection.style.display = "flex";
toolButtonsSection.style.gap = "8px";
toolButtonsSection.style.width = "100%";
// Mode buttons
const drawBtn = document.createElement("button");
drawBtn.textContent = "Draw (B)";
drawBtn.className = "comfy-button";
drawBtn.type = "button";
drawBtn.style.flex = "1 1 auto";
drawBtn.onclick = () => {
try {
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.setMode("draw");
} catch (error) {
console.error("[ComfyUI Roto] Error switching to draw mode:", error);
}
};
const editBtn = document.createElement("button");
editBtn.textContent = "Edit (V)";
editBtn.className = "comfy-button";
editBtn.type = "button";
editBtn.style.flex = "1 1 auto";
editBtn.onclick = () => {
try {
callbacks.setMode("edit");
} catch (error) {
console.error("[ComfyUI Roto] Error switching to edit mode:", error);
}
};
// Add Point button
const addPointBtn = document.createElement("button");
addPointBtn.textContent = "Add Point";
addPointBtn.className = "comfy-button";
addPointBtn.type = "button";
addPointBtn.disabled = true;
addPointBtn.style.flex = "1 1 auto";
function updateAddPointButton() {
// Enable/disable based on curve selection
addPointBtn.disabled = state.selectedCurves.size === 0;
if (state.addPointMode) {
addPointBtn.textContent = "Click Edge...";
addPointBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
canvas.style.cursor = "crosshair";
} else {
addPointBtn.textContent = "Add Point";
addPointBtn.style.backgroundColor = "";
canvas.style.cursor = state.mode === "draw" ? "crosshair" : "default";
}
}
addPointBtn.onclick = () => {
if (state.selectedCurves.size === 0) return;
// Toggle add point mode - user must click on edge to add point
state.addPointMode = !state.addPointMode;
updateAddPointButton();
};
// Toggle Smooth button
const toggleSmoothBtn = document.createElement("button");
toggleSmoothBtn.textContent = "Soften Point";
toggleSmoothBtn.className = "comfy-button";
toggleSmoothBtn.type = "button";
toggleSmoothBtn.disabled = true;
toggleSmoothBtn.style.flex = "1 1 auto";
function updateToggleSmoothButton() {
// Button enabled when curves are selected
const hasCurveSelection = state.selectedCurves.size > 0;
const hasPointSelection = state.selectedPoints.size > 0;
toggleSmoothBtn.disabled = !hasCurveSelection || !hasPointSelection;
if (!hasCurveSelection) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
if (!hasPointSelection) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
// Check state of selected points within selected curves
const shape = callbacks.getActiveShape ? callbacks.getActiveShape() : null;
if (!shape) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
const frameKey = String(state.currentFrame);
const curves = shape.frames && shape.frames[frameKey]
? shape.frames[frameKey]
: (callbacks.evaluateFrame ? callbacks.evaluateFrame(shape, state.currentFrame, { hold: true }) : []);
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 (selection might be stale)
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];
// Validate type is valid
if (type === "smooth") {
allHard = false;
} else if (type === "hard") {
allSmooth = false;
}
// If invalid type, treat as "hard" (defensive)
}
});
if (allHard) {
toggleSmoothBtn.textContent = "Soften Point";
} else if (allSmooth) {
toggleSmoothBtn.textContent = "Sharpen Point";
} else {
toggleSmoothBtn.textContent = "Soften Point"; // Mixed: default to softening
}
}
toggleSmoothBtn.onclick = () => {
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
if (callbacks.togglePointSmoothness) {
callbacks.togglePointSmoothness();
}
};
toolButtonsSection.appendChild(drawBtn);
toolButtonsSection.appendChild(editBtn);
toolButtonsSection.appendChild(addPointBtn);
toolButtonsSection.appendChild(toggleSmoothBtn);
// Section 3: Timeline (slider + play button)
const timelineSection = document.createElement("div");
timelineSection.style.display = "flex";
timelineSection.style.gap = "8px";
timelineSection.style.alignItems = "center";
timelineSection.style.width = "100%";
timelineSection.style.padding = "8px 0";
timelineSection.style.borderTop = "1px solid var(--border-color)";
timelineSection.style.borderBottom = "1px solid var(--border-color)";
timelineSection.style.margin = "4px 0";
// Frame counter display
const frameCounter = document.createElement("div");
frameCounter.style.display = "flex";
frameCounter.style.alignItems = "center";
frameCounter.style.gap = "4px";
frameCounter.style.minWidth = "80px";
frameCounter.style.fontSize = "12px";
frameCounter.style.fontWeight = "500";
frameCounter.style.color = "var(--fg-color)";
const frameNumber = document.createElement("span");
frameNumber.textContent = "1";
frameNumber.style.minWidth = "30px";
frameNumber.style.textAlign = "right";
const frameSeparator = document.createElement("span");
frameSeparator.textContent = "/";
frameSeparator.style.opacity = "0.5";
const frameMax = document.createElement("span");
frameMax.textContent = "1";
frameMax.style.minWidth = "30px";
frameMax.style.textAlign = "left";
frameMax.style.opacity = "0.7";
frameCounter.appendChild(frameNumber);
frameCounter.appendChild(frameSeparator);
frameCounter.appendChild(frameMax);
// Divider line
const divider1 = document.createElement("div");
divider1.style.width = "1px";
divider1.style.height = "24px";
divider1.style.backgroundColor = "var(--border-color)";
divider1.style.opacity = "0.5";
const frameSlider = document.createElement("input");
frameSlider.type = "range";
frameSlider.min = "1";
frameSlider.max = "1";
frameSlider.step = "1";
frameSlider.value = "1";
frameSlider.style.flex = "1 1 auto";
frameSlider.style.height = "6px";
frameSlider.style.cursor = "pointer";
// Divider line
const divider2 = document.createElement("div");
divider2.style.width = "1px";
divider2.style.height = "24px";
divider2.style.backgroundColor = "var(--border-color)";
divider2.style.opacity = "0.5";
const playBtn = document.createElement("button");
playBtn.textContent = "▶";
playBtn.className = "comfy-button";
playBtn.type = "button";
playBtn.style.minWidth = "40px";
playBtn.style.width = "40px";
playBtn.style.padding = "4px 8px";
playBtn.style.fontSize = "14px";
let playing = false;
let lastTick = 0;
const fps = 24;
function updateFrameLabel() {
frameNumber.textContent = String(state.currentFrame);
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
frameMax.textContent = String(maxFrame);
}
function safeWidgetCallback(widget, value) {
if (widget && typeof widget.callback === "function") {
try {
widget.callback.call(widget, value, null, node);
} catch (error) {
console.warn("[ComfyUI Roto] Frame widget callback failed:", error);
}
}
}
function setFrame(frameIndex) {
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
const nextFrame = Math.max(1, Math.min(maxFrame, frameIndex));
if (nextFrame === state.currentFrame) return;
state.currentFrame = nextFrame;
frameSlider.value = String(nextFrame);
updateFrameLabel();
callbacks.redraw();
const widgets = node.widgets || [];
const frameWidget = widgets.find(w => w.name === "frame_index");
if (frameWidget) {
frameWidget.value = nextFrame;
if (!state.updatingFrame) {
state.updatingFrame = true;
safeWidgetCallback(frameWidget, nextFrame);
state.updatingFrame = false;
}
}
}
function tick(ts) {
if (!playing) return;
if (!lastTick) lastTick = ts;
const interval = 1000 / fps;
if (ts - lastTick >= interval) {
lastTick = ts;
const maxFrame = Number(frameSlider.max || 1);
const next = (state.currentFrame + 1) > maxFrame ? 1 : state.currentFrame + 1;
setFrame(next);
}
requestAnimationFrame(tick);
}
playBtn.onclick = () => {
playing = !playing;
playBtn.textContent = playing ? "⏸" : "▶";
if (playing) {
lastTick = 0;
requestAnimationFrame(tick);
}
};
frameSlider.oninput = (e) => {
const value = Number(e.target.value || 0);
setFrame(value);
};
// Assemble timeline section
timelineSection.appendChild(frameCounter);
timelineSection.appendChild(divider1);
timelineSection.appendChild(frameSlider);
timelineSection.appendChild(divider2);
timelineSection.appendChild(playBtn);
return {
// Sections for layout
loadFramesSection,
toolButtonsSection,
timelineSection,
settingsSection,
// Individual controls (for backward compatibility)
featherSlider,
featherValueLabel,
bgColorInput,
maskColorInput,
maskOpacitySlider,
maskOpacityValueLabel,
frameSlider,
addPointBtn,
toggleSmoothBtn,
drawBtn,
editBtn,
playBtn,
// Frame output controls
startFrameInput,
endFrameInput,
// Functions
updateFrameLabel,
updateAddPointButton,
updateToggleSmoothButton,
updateFrameRangeInfo,
setFrame
};
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Curve management functions
*/
import { generateUUID } from "../utils/uuid.js";
import { deepCopy } from "../utils/helpers.js";
import { evaluateFrame } from "./frameEvaluator.js";
/**
* Get the next available shape number
* @param {Object} state - Editor state
* @returns {number} Next available shape number
*/
function getNextShapeNumber(state) {
const existingNumbers = new Set();
// Extract numbers from existing shape names
Object.values(state.shapes || {}).forEach(shape => {
if (shape && shape.name) {
const match = shape.name.match(/^Shape\s+(\d+)$/);
if (match) {
existingNumbers.add(parseInt(match[1], 10));
}
}
});
// Find the next available number
let nextNumber = 1;
while (existingNumbers.has(nextNumber)) {
nextNumber++;
}
return nextNumber;
}
/**
* Initialize default shape in state
* @param {Object} state - Editor state
*/
export function initializeDefaultShape(state) {
const shapeId = generateUUID();
const shapeNumber = getNextShapeNumber(state);
state.activeShapeId = shapeId;
state.shapes[shapeId] = {
name: `Shape ${shapeNumber}`,
frames: {}
};
}
/**
* Get the active shape, initializing if needed
* @param {Object} state - Editor state
* @returns {Object} Active shape object
*/
export function getActiveShape(state) {
if (!state.activeShapeId || !state.shapes[state.activeShapeId]) {
initializeDefaultShape(state);
}
return state.shapes[state.activeShapeId];
}
/**
* Get curves for a specific frame
* @param {Object} state - Editor state
* @param {number} frame - Frame number
* @returns {Array} Curves for the frame
*/
export function getCurvesForFrame(state, frame) {
const shape = getActiveShape(state);
const frameKey = String(frame);
return shape.frames[frameKey] || [];
}
/**
* Promote a specific curve to the current frame as a keyframe.
* If curveId is provided, only that curve is promoted.
* If not provided, only interpolated curves are promoted (not held ones).
*
* @param {Object} state - Editor state
* @param {Function} evaluateFrameFn - Function to evaluate frame (to avoid circular dependency)
* @param {Function} deepCopyFn - Deep copy function
* @param {string|null} curveId - Optional curve ID to promote
*/
export function promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId = null) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// If already a keyframe, check if we need to add a specific curve
if (shape.frames[frameKey]) {
if (curveId) {
// Check if this curve already exists at this keyframe
const exists = shape.frames[frameKey].some(c => c.id === curveId);
if (!exists) {
// Find this curve from evaluated frame and add it
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const curve = allCurves.find(c => c.id === curveId);
if (curve) {
shape.frames[frameKey].push(deepCopyFn(curve));
}
}
}
return;
}
// Frame doesn't exist yet - create it
if (curveId) {
// Only promote the specific curve being edited
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const curve = allCurves.find(c => c.id === curveId);
shape.frames[frameKey] = curve ? [deepCopyFn(curve)] : [];
} else {
// Get interpolated curves only (not held curves)
const curves = evaluateFrameFn(shape, state.currentFrame, { hold: false });
shape.frames[frameKey] = deepCopyFn(curves);
}
}
/**
* Add a point to a curve at a specific edge.
* IMPORTANT: Adds point to ALL keyframes to keep point counts in sync.
*
* @param {Object} state - Editor state
* @param {string} curveId - Curve ID
* @param {number} edgeIndex - Index of the edge (point index)
* @param {{x: number, y: number}|null} position - Optional position to add point at
* @param {Function} promoteToKeyframeFn - Function to promote to keyframe
* @param {Function} evaluateFrameFn - Function to evaluate frame
* @param {Function} deepCopyFn - Deep copy function
* @param {Function} commitFrameFn - Function to commit frame
* @param {Function} redrawFn - Function to redraw
* @param {Function} updateAddPointButtonFn - Function to update add point button
*/
export function addPointToCurveAtEdge(
state,
curveId,
edgeIndex,
position,
promoteToKeyframeFn,
evaluateFrameFn,
deepCopyFn,
commitFrameFn,
redrawFn,
updateAddPointButtonFn
) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// First, ensure current frame has this curve as a keyframe
promoteToKeyframeFn(state, evaluateFrameFn, deepCopyFn, curveId);
// Get the actual keyframe curve (not interpolated) - refresh after promotion
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
const curves = shape.frames[frameKey];
let curve = curves.find(c => c.id === curveId);
// If curve still doesn't exist, something went wrong - try to get from evaluated
if (!curve) {
const evaluatedCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const evaluatedCurve = evaluatedCurves.find(c => c.id === curveId);
if (evaluatedCurve) {
// Add it to keyframe
curve = deepCopyFn(evaluatedCurve);
curves.push(curve);
}
}
if (!curve || curve.points.length < 2) return;
// Calculate t parameter (0-1) for where on the edge the point is being added
const p1 = curve.points[edgeIndex];
const p2 = curve.points[(edgeIndex + 1) % curve.points.length];
let t = 0.5; // default to midpoint
if (position) {
// Calculate t based on position along the edge
const edgeLength = Math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2);
if (edgeLength > 0) {
const posLength = Math.sqrt((position.x - p1[0]) ** 2 + (position.y - p1[1]) ** 2);
t = Math.max(0, Math.min(1, posLength / edgeLength));
}
}
// Add point to ALL keyframes that contain this curve
Object.keys(shape.frames).forEach(fKey => {
const frameCurves = shape.frames[fKey] || [];
const frameCurve = frameCurves.find(c => c.id === curveId);
if (frameCurve && frameCurve.points.length > edgeIndex) {
const fp1 = frameCurve.points[edgeIndex];
const fp2 = frameCurve.points[(edgeIndex + 1) % frameCurve.points.length];
// Calculate new point position using same t parameter
const newX = fp1[0] + t * (fp2[0] - fp1[0]);
const newY = fp1[1] + t * (fp2[1] - fp1[1]);
// Insert new point after edgeIndex
frameCurve.points.splice(edgeIndex + 1, 0, [newX, newY]);
// Also insert "hard" into pointTypes array at same index
if (!Array.isArray(frameCurve.pointTypes) || frameCurve.pointTypes.length !== frameCurve.points.length - 1) {
// Reinitialize if missing or wrong length (before adding new point)
frameCurve.pointTypes = frameCurve.points.slice(0, -1).map(() => "hard");
}
frameCurve.pointTypes.splice(edgeIndex + 1, 0, "hard");
}
});
// Exit add point mode
state.addPointMode = false;
if (updateAddPointButtonFn) {
updateAddPointButtonFn();
}
if (commitFrameFn) {
commitFrameFn();
}
if (redrawFn) {
redrawFn();
}
}
/**
* Commit current frame (ensure frame exists in shape)
* @param {Object} state - Editor state
* @param {Function} updateNodeInputFn - Function to update node input
*/
export function commitFrame(state, updateNodeInputFn) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
if (updateNodeInputFn) {
updateNodeInputFn();
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Frame evaluation and interpolation logic
*/
import { deepCopy } from "../utils/helpers.js";
/**
* Get interpolated pointTypes array from two curves.
* Since pointTypes should be the same across all keyframes, we prefer curveA's types.
* @param {Object} curveA - First curve (earlier keyframe)
* @param {Object} curveB - Second curve (later keyframe)
* @param {number} totalPoints - Total point count in the interpolated result
* @returns {Array<string>} Array of point types
*/
function getInterpolatedPointTypes(curveA, curveB, totalPoints) {
// Since pointTypes should be consistent across keyframes, prefer curveA
// but fall back to curveB if curveA doesn't have valid types
const typesA = Array.isArray(curveA.pointTypes) ? curveA.pointTypes : [];
const typesB = Array.isArray(curveB.pointTypes) ? curveB.pointTypes : [];
const result = [];
for (let i = 0; i < totalPoints; i++) {
if (i < typesA.length && (typesA[i] === "smooth" || typesA[i] === "hard")) {
result.push(typesA[i]);
} else if (i < typesB.length && (typesB[i] === "smooth" || typesB[i] === "hard")) {
result.push(typesB[i]);
} else {
result.push("hard"); // Default for any missing entries
}
}
return result;
}
/**
* Evaluate curves for a given frame.
* Each curve is interpolated independently based on its own keyframes.
*
* @param {Object} shape - Shape object with frames
* @param {number} frameIndex - Frame number to evaluate
* @param {Object} options - Evaluation options
* @param {boolean} options.hold - If true, hold curves from nearest keyframe when no interpolation possible
* @returns {Array} List of curve objects for this frame
*/
export function evaluateFrame(shape, frameIndex, options = {}) {
const frames = shape.frames || {};
const frameKey = String(frameIndex);
const hold = Boolean(options.hold);
// Collect all unique curve IDs across all keyframes
const allCurveIds = new Set();
const curveKeyframes = new Map(); // curveId -> sorted array of frame numbers where it exists
Object.entries(frames).forEach(([frame, curves]) => {
const frameNum = parseInt(frame);
if (isNaN(frameNum)) return;
(curves || []).forEach(c => {
if (c && c.id) {
allCurveIds.add(c.id);
if (!curveKeyframes.has(c.id)) {
curveKeyframes.set(c.id, []);
}
curveKeyframes.get(c.id).push(frameNum);
}
});
});
// Sort keyframes for each curve
curveKeyframes.forEach((frames, id) => {
frames.sort((a, b) => a - b);
});
if (allCurveIds.size === 0) {
return [];
}
const result = [];
// Evaluate each curve independently based on its own keyframes
for (const curveId of allCurveIds) {
const keyframes = curveKeyframes.get(curveId) || [];
if (keyframes.length === 0) continue;
// Check for exact keyframe for this curve
if (keyframes.includes(frameIndex)) {
const frameCurves = frames[frameKey] || [];
const curve = frameCurves.find(c => c.id === curveId);
if (curve) {
result.push(deepCopy(curve));
}
continue;
}
// Find prev and next keyframes FOR THIS CURVE
let prevFrame = null;
let nextFrame = null;
for (const fn of keyframes) {
if (fn < frameIndex) {
prevFrame = fn;
} else if (fn > frameIndex) {
nextFrame = fn;
break;
}
}
// Interpolate or hold based on this curve's keyframes
if (prevFrame !== null && nextFrame !== null) {
// Interpolate between this curve's keyframes
const curveA = (frames[String(prevFrame)] || []).find(c => c.id === curveId);
const curveB = (frames[String(nextFrame)] || []).find(c => c.id === curveId);
if (curveA && curveB) {
const t = (frameIndex - prevFrame) / (nextFrame - prevFrame);
const interpolatedPoints = [];
// Handle different point counts - use the smaller count
const minPoints = Math.min(curveA.points.length, curveB.points.length);
for (let i = 0; i < minPoints; i++) {
const pa = curveA.points[i];
const pb = curveB.points[i];
const x = pa[0] + (pb[0] - pa[0]) * t;
const y = pa[1] + (pb[1] - pa[1]) * t;
interpolatedPoints.push([x, y]);
}
// If one curve has more points, add them at the end (hold from that curve)
if (curveA.points.length > minPoints) {
for (let i = minPoints; i < curveA.points.length; i++) {
interpolatedPoints.push([...curveA.points[i]]);
}
} else if (curveB.points.length > minPoints) {
for (let i = minPoints; i < curveB.points.length; i++) {
interpolatedPoints.push([...curveB.points[i]]);
}
}
result.push({
id: curveA.id,
closed: curveA.closed,
points: interpolatedPoints,
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
});
}
} else if (hold) {
// Hold from nearest keyframe for this curve
const holdFrame = prevFrame !== null ? prevFrame : nextFrame;
if (holdFrame !== null) {
const curve = (frames[String(holdFrame)] || []).find(c => c.id === curveId);
if (curve) {
result.push(deepCopy(curve));
}
}
}
}
return result;
}
/**
* Interpolate curves between two keyframes.
* Curves that only exist in one keyframe are held (included as-is).
*
* @param {Array} curvesA - Curves at earlier keyframe
* @param {Array} curvesB - Curves at later keyframe
* @param {number} frameA - Earlier frame number
* @param {number} frameB - Later frame number
* @param {number} targetFrame - Frame to interpolate to
* @returns {Array} List of interpolated curves
*/
export function interpolateCurves(curvesA, curvesB, frameA, frameB, targetFrame) {
const t = (targetFrame - frameA) / (frameB - frameA);
const result = [];
const processedIds = new Set();
// Process curves from A - interpolate if in both, hold if only in A
for (const curveA of curvesA) {
processedIds.add(curveA.id);
const curveB = curvesB.find(cb => cb.id === curveA.id);
if (curveB) {
// Curve exists in both keyframes - interpolate
const interpolatedPoints = [];
// Handle different point counts - use the smaller count
const minPoints = Math.min(curveA.points.length, curveB.points.length);
for (let i = 0; i < minPoints; i++) {
const pa = curveA.points[i];
const pb = curveB.points[i];
const x = pa[0] + (pb[0] - pa[0]) * t;
const y = pa[1] + (pb[1] - pa[1]) * t;
interpolatedPoints.push([x, y]);
}
// If one curve has more points, add them at the end (hold from that curve)
if (curveA.points.length > minPoints) {
for (let i = minPoints; i < curveA.points.length; i++) {
interpolatedPoints.push([...curveA.points[i]]);
}
} else if (curveB.points.length > minPoints) {
for (let i = minPoints; i < curveB.points.length; i++) {
interpolatedPoints.push([...curveB.points[i]]);
}
}
result.push({
id: curveA.id,
closed: curveA.closed,
points: interpolatedPoints,
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
});
} else {
// Curve only in A - hold it (use A's position)
result.push(deepCopy(curveA));
}
}
// Process curves only in B (not in A) - hold them
for (const curveB of curvesB) {
if (!processedIds.has(curveB.id)) {
result.push(deepCopy(curveB));
}
}
return result;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* State management for the roto editor
*/
import { generateUUID } from "../utils/uuid.js";
/**
* Initialize the editor state
* @returns {Object} Initial state object
*/
export function createState() {
const shapeId = generateUUID();
return {
currentFrame: 1,
activeShapeId: shapeId,
shapes: {
[shapeId]: {
name: "Shape 1",
frames: {}
}
},
selectedCurves: new Set(),
selectedPoints: new Set(), // Format: "curveId:pointIndex"
dragging: false,
dragStart: null,
mode: "draw", // "draw" or "edit"
addPointMode: false, // true when user clicked "Add Point" button and is ready to click on edge
canvasWidth: 512,
canvasHeight: 512,
previewImage: null,
previewSourceId: null,
frameImages: [],
frameImageCache: new Map(),
updatingFrame: false,
featherAmount: 0.0,
backgroundColor: "#000000",
maskColor: "#ffffff",
maskOpacity: 10, // 0-100 integer
selectionBox: null // { start: {x, y}, end: {x, y}, shiftKey: boolean } or null
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Document-level event listeners for dragging outside canvas
*/
/**
* Create document listener management functions
* @param {Object} state - Editor state
* @param {Function} handleDragMoveFn - Function to handle drag move
* @returns {Object} Functions to attach/remove document listeners
*/
export function createDocumentListeners(state, handleDragMoveFn) {
let documentMouseMoveHandler = null;
let documentMouseUpHandler = null;
function attachDocumentListeners() {
if (documentMouseMoveHandler || documentMouseUpHandler) {
// Already attached, skip
return;
}
documentMouseMoveHandler = (e) => {
handleDragMoveFn(e);
};
documentMouseUpHandler = (e) => {
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
removeDocumentListeners();
}
};
document.addEventListener("mousemove", documentMouseMoveHandler);
document.addEventListener("mouseup", documentMouseUpHandler);
}
function removeDocumentListeners() {
if (documentMouseMoveHandler) {
document.removeEventListener("mousemove", documentMouseMoveHandler);
documentMouseMoveHandler = null;
}
if (documentMouseUpHandler) {
document.removeEventListener("mouseup", documentMouseUpHandler);
documentMouseUpHandler = null;
}
}
return {
attachDocumentListeners,
removeDocumentListeners
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Keyboard event handlers
*/
/**
* Create keyboard event handler
* @param {HTMLElement} container - Container element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.setMode - Set editor mode
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
* @param {Function} callbacks.commitFrame - Commit frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
* @param {Function} callbacks.updateAddPointButton - Update add point button
*/
export function createKeyboardHandlers(container, state, callbacks) {
function handleKeydown(e) {
// Only handle if not typing in an input
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return;
}
if (e.key === "b" || e.key === "B") {
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.setMode("draw");
e.preventDefault();
} else if (e.key === "v" || e.key === "V") {
callbacks.setMode("edit");
e.preventDefault();
} else if (e.key === "Delete" || e.key === "Backspace") {
if (state.mode === "edit" && state.selectedCurves.size > 0) {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Promote only the selected curves to be deleted
state.selectedCurves.forEach(curveId => {
callbacks.promoteToKeyframe(curveId);
});
const curves = shape.frames[frameKey] || [];
state.selectedCurves.forEach(curveId => {
const index = curves.findIndex(c => c.id === curveId);
if (index !== -1) {
curves.splice(index, 1);
}
});
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.commitFrame();
callbacks.redraw();
e.preventDefault();
}
} else if (e.key === "Escape") {
// Cancel selection box if active
if (state.selectionBox !== null) {
state.selectionBox = null;
}
// Cancel add point mode if active
if (state.addPointMode) {
state.addPointMode = false;
callbacks.updateAddPointButton();
}
// Cancel dragging if active
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.redraw();
e.preventDefault();
}
}
// Keyboard shortcuts (scoped to container, with document fallback)
container.addEventListener("keydown", handleKeydown);
document.addEventListener("keydown", handleKeydown);
}
+291
View File
@@ -0,0 +1,291 @@
/**
* Mouse event handlers for canvas
*/
import { getMousePos } from "../utils/canvas.js";
import { generateUUID } from "../utils/uuid.js";
import { evaluateFrame } from "../core/frameEvaluator.js";
import { pointToLineSegmentDistance } from "../renderer/drawCurve.js";
/**
* Create mouse event handlers for canvas
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {HTMLElement} container - Container element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
* @param {Function} callbacks.commitFrame - Commit frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.addPointToCurveAtEdge - Add point to curve at edge
* @param {Function} callbacks.updateAddPointButton - Update add point button
* @param {Function} callbacks.attachDocumentListeners - Attach document listeners
* @returns {Function} Function to handle drag move
*/
export function createMouseHandlers(canvas, container, state, callbacks) {
// Shared function to handle dragging
function handleDragMove(e) {
if (!state.dragging || state.mode !== "edit") return;
const pos = getMousePos(e, canvas);
const deltaX = pos.x - state.dragStart.x;
const deltaY = pos.y - state.dragStart.y;
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Promote only the curves being edited
state.selectedPoints.forEach(pointKey => {
const [curveId] = pointKey.split(":");
callbacks.promoteToKeyframe(curveId);
});
const curves = shape.frames[frameKey] || [];
// Move selected points
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
const curve = curves.find(c => c.id === curveId);
if (curve && curve.points[parseInt(pointIndex)]) {
const point = curve.points[parseInt(pointIndex)];
point[0] = Math.max(0, Math.min(canvas.width, point[0] + deltaX));
point[1] = Math.max(0, Math.min(canvas.height, point[1] + deltaY));
}
});
state.dragStart = pos;
callbacks.commitFrame();
callbacks.redraw();
}
canvas.addEventListener("mousedown", (e) => {
// Ensure keyboard shortcuts work after interaction
container.focus();
const pos = getMousePos(e, canvas);
if (state.mode === "draw") {
// Create new curve - don't promote other curves, just ensure frame exists
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
// Create 4-point box (rectangle) centered on click
// Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(canvas.width, pos.x));
const clampedY = Math.max(0, Math.min(canvas.height, pos.y));
const size = 50;
const curve = {
id: generateUUID(),
closed: true,
points: [
[Math.max(0, clampedX - size), Math.max(0, clampedY - size)], // top-left
[Math.min(canvas.width, clampedX + size), Math.max(0, clampedY - size)], // top-right
[Math.min(canvas.width, clampedX + size), Math.min(canvas.height, clampedY + size)], // bottom-right
[Math.max(0, clampedX - size), Math.min(canvas.height, clampedY + size)] // bottom-left
],
pointTypes: ["hard", "hard", "hard", "hard"] // Initialize all points as hard
};
shape.frames[frameKey].push(curve);
callbacks.commitFrame();
callbacks.redraw();
} else if (state.mode === "edit") {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Check for point hit using EVALUATED curves (includes held curves)
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
let hitPoint = null;
let hitCurve = null;
for (const curve of evaluatedCurves) {
for (let i = 0; i < curve.points.length; i++) {
const point = curve.points[i];
const dx = pos.x - point[0];
const dy = pos.y - point[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10) {
hitPoint = { curve, index: i };
hitCurve = curve;
break;
}
}
if (hitPoint) break;
}
if (hitPoint) {
// Start dragging point
state.dragging = true;
state.dragStart = pos;
const pointKey = `${hitPoint.curve.id}:${hitPoint.index}`;
// Check if the clicked point is already selected
const isPointAlreadySelected = state.selectedPoints.has(pointKey);
if (!e.shiftKey && !isPointAlreadySelected) {
// Only clear selection if clicking a new point (not already selected)
state.selectedPoints.clear();
state.selectedCurves.clear();
}
// Add the point to selection (if not already there)
state.selectedPoints.add(pointKey);
state.selectedCurves.add(hitPoint.curve.id);
// Attach document listeners to allow dragging outside canvas
callbacks.attachDocumentListeners();
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
} else {
// Only check for edge hit if in add point mode
if (state.addPointMode) {
let hitEdge = null;
let minDist = Infinity;
const edgeHitThreshold = 15; // pixels - slightly larger for easier clicking
// Only check selected curves
const selectedCurveId = Array.from(state.selectedCurves)[0];
for (const curve of evaluatedCurves) {
if (curve.id !== selectedCurveId) continue;
for (let i = 0; i < curve.points.length; i++) {
const p1 = curve.points[i];
const p2 = curve.points[(i + 1) % curve.points.length];
// Calculate distance from mouse to line segment
const dist = pointToLineSegmentDistance(pos, p1, p2);
if (dist < edgeHitThreshold && dist < minDist) {
minDist = dist;
// Calculate closest point on line segment
const t = Math.max(0, Math.min(1,
((pos.x - p1[0]) * (p2[0] - p1[0]) + (pos.y - p1[1]) * (p2[1] - p1[1])) /
((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
));
const closestPoint = {
x: p1[0] + t * (p2[0] - p1[0]),
y: p1[1] + t * (p2[1] - p1[1])
};
hitEdge = {
curve: curve,
edgeIndex: i,
position: closestPoint
};
}
}
}
if (hitEdge) {
// Add point at clicked location on edge
if (callbacks.addPointToCurveAtEdge) {
callbacks.addPointToCurveAtEdge(hitEdge.curve.id, hitEdge.edgeIndex, hitEdge.position);
} else {
console.error("[ComfyUI Roto] addPointToCurveAtEdge callback not available");
}
} else {
// Clicked but missed the edge - cancel add point mode
state.addPointMode = false;
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
}
} else {
// Normal edit mode - start selection box if clicking empty space
// Clear selection box if it exists (shouldn't happen, but safety check)
if (state.selectionBox) {
state.selectionBox = null;
}
// Start selection box
state.selectionBox = {
start: pos,
end: pos,
shiftKey: e.shiftKey
};
// If Shift not held, clear existing selection
if (!e.shiftKey) {
state.selectedCurves.clear();
state.selectedPoints.clear();
}
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
}
}
}
});
canvas.addEventListener("mousemove", (e) => {
// Check for selection box first (before dragging)
if (state.selectionBox !== null && state.mode === "edit") {
const pos = getMousePos(e, canvas);
state.selectionBox.end = pos;
callbacks.redraw();
} else {
// Handle point dragging
handleDragMove(e);
}
});
canvas.addEventListener("mouseup", (e) => {
// Handle selection box completion
if (state.selectionBox !== null && state.mode === "edit") {
const shape = callbacks.getActiveShape();
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
// Calculate normalized box bounds
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
// Find all points within selection box
for (const curve of evaluatedCurves) {
for (let i = 0; i < curve.points.length; i++) {
const point = curve.points[i];
const x = point[0];
const y = point[1];
// Check if point center is within box bounds
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
const pointKey = `${curve.id}:${i}`;
state.selectedPoints.add(pointKey);
state.selectedCurves.add(curve.id);
}
}
}
// Clear selection box
state.selectionBox = null;
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
} else if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
});
return handleDragMove;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Background image drawing functions
*/
import { loadImageAsync } from "../utils/fileUpload.js";
import { resizeCanvasToImage } from "../components/Canvas.js";
/**
* Resolve preview image from a ComfyUI node
* @param {Object} targetNode - ComfyUI node
* @returns {HTMLImageElement|null} Preview image or null
*/
export function resolveNodePreviewImage(targetNode) {
if (!targetNode) return null;
if (targetNode.imgs && targetNode.imgs.length > 0) return targetNode.imgs[0];
if (targetNode.images && targetNode.images.length > 0) return targetNode.images[0];
if (targetNode._bgImage) return targetNode._bgImage;
return null;
}
/**
* Get connected input node from ComfyUI graph
* @param {Object} node - Current node
* @param {Object} app - ComfyUI app instance
* @returns {Object|null} Connected input node or null
*/
export function getConnectedInputNode(node, app) {
const input = node.inputs && node.inputs[0];
if (!input || input.link == null) return null;
const link = app.graph && app.graph.links && app.graph.links[input.link];
if (!link) return null;
return app.graph.getNodeById(link.origin_id);
}
/**
* Get preview image from connected input node
* @param {Object} node - Current node
* @param {Object} app - ComfyUI app instance
* @returns {HTMLImageElement|null} Preview image or null
*/
export function getInputPreviewImage(node, app) {
const inputNode = getConnectedInputNode(node, app);
if (!inputNode) return null;
return resolveNodePreviewImage(inputNode);
}
/**
* Get frame image for a given index
* @param {Object} state - Editor state
* @param {number} index - Frame index (0-based)
* @returns {Promise<HTMLImageElement|null>} Loaded image or null
*/
export async function getFrameImageForIndex(state, index) {
if (!state.frameImages.length) return null;
const safeIndex = Math.max(0, Math.min(state.frameImages.length - 1, index));
const entry = state.frameImages[safeIndex];
if (!entry) return null;
if (state.frameImageCache.has(entry.url)) {
return state.frameImageCache.get(entry.url);
}
const img = await loadImageAsync(entry.url);
state.frameImageCache.set(entry.url, img);
return img;
}
/**
* Draw background image on canvas
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {Object} node - ComfyUI node
* @param {Object} app - ComfyUI app instance
* @param {Function} resizeCanvasToImageFn - Function to resize canvas
* @param {Function} redrawFn - Function to trigger redraw
*/
export function drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, redrawFn) {
try {
// If user loaded frame images, use those first
if (state.frameImages.length > 0) {
// Convert 1-based frame number to 0-based array index
const index = (state.currentFrame - 1) % state.frameImages.length;
const cached = state.frameImages[index];
if (cached && state.frameImageCache.has(cached.url)) {
const img = state.frameImageCache.get(cached.url);
resizeCanvasToImageFn(canvas, state, img);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return;
} else {
// Kick off async load; will be drawn on next redraw
getFrameImageForIndex(state, index).then(() => {
if (redrawFn) redrawFn();
});
return;
}
}
// Preferred: use upstream node preview image (ComfyUI uses imgs/images)
const preview = getInputPreviewImage(node, app);
if (preview) {
// Cache preview to avoid repeated lookups
if (preview !== state.previewImage) {
state.previewImage = preview;
}
resizeCanvasToImageFn(canvas, state, state.previewImage);
ctx.drawImage(state.previewImage, 0, 0, canvas.width, canvas.height);
return;
}
// Fallback: attempt to use raw input data if available
if (node && typeof node.getInputData === "function") {
const input = node.getInputData(0);
if (!input) return;
if (input instanceof HTMLImageElement || input instanceof HTMLCanvasElement || input instanceof ImageBitmap) {
ctx.drawImage(input, 0, 0, canvas.width, canvas.height);
return;
}
if (input.data && input.width && input.height) {
const imageData = new ImageData(new Uint8ClampedArray(input.data), input.width, input.height);
const tempCanvas = document.createElement("canvas");
tempCanvas.width = input.width;
tempCanvas.height = input.height;
const tempCtx = tempCanvas.getContext("2d");
tempCtx.putImageData(imageData, 0, 0);
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);
}
}
} catch (error) {
console.warn("[ComfyUI Roto] Unable to render background image:", error);
}
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Curve drawing functions
*/
import { hexToRgb } from "../utils/helpers.js";
/**
* Calculate distance from point to line segment
* @param {{x: number, y: number}} point - Point coordinates
* @param {Array<number>} lineStart - Line start point [x, y]
* @param {Array<number>} lineEnd - Line end point [x, y]
* @returns {number} Distance from point to line segment
*/
export function pointToLineSegmentDistance(point, lineStart, lineEnd) {
const A = point.x - lineStart[0];
const B = point.y - lineStart[1];
const C = lineEnd[0] - lineStart[0];
const D = lineEnd[1] - lineStart[1];
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = lineStart[0];
yy = lineStart[1];
} else if (param > 1) {
xx = lineEnd[0];
yy = lineEnd[1];
} else {
xx = lineStart[0] + param * C;
yy = lineStart[1] + param * D;
}
const dx = point.x - xx;
const dy = point.y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Draw control points for a curve
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {Object} curve - Curve object
* @param {Object} state - Editor state
* @param {boolean} isSelected - Whether curve is selected
*/
export function drawPoints(ctx, curve, state, isSelected = false) {
const pointRadius = 5;
const selectedRadius = 7;
curve.points.forEach((point, index) => {
const pointKey = `${curve.id}:${index}`;
const isPointSelected = state.selectedPoints.has(pointKey);
ctx.beginPath();
ctx.arc(point[0], point[1], isPointSelected ? selectedRadius : pointRadius, 0, Math.PI * 2);
if (isPointSelected || isSelected) {
ctx.fillStyle = "#4a9eff";
ctx.fill();
} else {
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = "#000000";
ctx.lineWidth = 1;
ctx.stroke();
}
// Add stroke outline only for specifically selected points (not just selected curves)
if (isPointSelected) {
ctx.beginPath();
ctx.arc(point[0], point[1], selectedRadius, 0, Math.PI * 2);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
/**
* Draw a single curve (polygon)
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {Object} curve - Curve object
* @param {Object} state - Editor state
* @param {boolean} isSelected - Whether curve is selected
*/
export function drawCurve(ctx, curve, state, isSelected = false) {
if (!curve || !curve.points || curve.points.length < 2) return;
ctx.save();
// Get pointTypes with validation (default to all "hard" if missing or invalid)
let pointTypes;
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
pointTypes = curve.pointTypes;
} else {
pointTypes = curve.points.map(() => "hard");
}
// Draw polygon path with smooth curves where needed using Catmull-Rom style interpolation
ctx.beginPath();
const n = curve.points.length;
const points = curve.points;
// Helper to get point with wrap-around for closed curves
const getPoint = (index) => {
if (curve.closed) {
return points[((index % n) + n) % n];
} else {
if (index < 0) return points[0];
if (index >= n) return points[n - 1];
return points[index];
}
};
// Helper to get point type with wrap-around
const getType = (index) => {
if (curve.closed) {
return pointTypes[((index % n) + n) % n];
} else {
if (index < 0) return pointTypes[0];
if (index >= n) return pointTypes[n - 1];
return pointTypes[index];
}
};
// Start at first point
ctx.moveTo(points[0][0], points[0][1]);
// For each segment
const segmentCount = curve.closed ? n : n - 1;
for (let i = 0; i < segmentCount; i++) {
const nextI = (i + 1) % n;
const p0 = points[i];
const p1 = points[nextI];
const type0 = getType(i);
const type1 = getType(nextI);
// If both points are hard, use straight line
if (type0 === "hard" && type1 === "hard") {
ctx.lineTo(p1[0], p1[1]);
} else {
// At least one point is smooth - use cubic curve
// Calculate control points based on neighboring points (Catmull-Rom style)
const prevP = getPoint(i - 1);
const nextNextP = getPoint(i + 2);
// Tension factor (0.5 is Catmull-Rom default)
const tension = 0.5;
// Calculate control point 1 (based on p0's tangent if smooth)
let cp1x, cp1y;
if (type0 === "smooth") {
// Tangent at p0 based on prevP to p1
cp1x = p0[0] + (p1[0] - prevP[0]) * tension / 2;
cp1y = p0[1] + (p1[1] - prevP[1]) * tension / 2;
} else {
// Hard point - control point is closer to p0
cp1x = p0[0] + (p1[0] - p0[0]) * 0.25;
cp1y = p0[1] + (p1[1] - p0[1]) * 0.25;
}
// Calculate control point 2 (based on p1's tangent if smooth)
let cp2x, cp2y;
if (type1 === "smooth") {
// Tangent at p1 based on p0 to nextNextP
cp2x = p1[0] - (nextNextP[0] - p0[0]) * tension / 2;
cp2y = p1[1] - (nextNextP[1] - p0[1]) * tension / 2;
} else {
// Hard point - control point is closer to p1
cp2x = p1[0] - (p1[0] - p0[0]) * 0.25;
cp2y = p1[1] - (p1[1] - p0[1]) * 0.25;
}
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1[0], p1[1]);
}
}
if (curve.closed) {
ctx.closePath();
}
// Fill with semi-transparent overlay
if (curve.closed) {
// Convert maskOpacity from 0-100 to 0-1 alpha
const alpha = (state.maskOpacity || 10) / 100;
if (isSelected) {
// Selected curves use blue color with custom opacity
ctx.fillStyle = `rgba(74, 158, 255, ${alpha})`;
} else {
// Unselected curves use mask color with custom opacity
const rgb = hexToRgb(state.maskColor || "#ffffff");
// Convert from 0-1 to 0-255 range
const r = Math.round(rgb[0] * 255);
const g = Math.round(rgb[1] * 255);
const b = Math.round(rgb[2] * 255);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
ctx.fill();
}
// Stroke style
if (isSelected) {
ctx.strokeStyle = "#4a9eff";
ctx.lineWidth = 3;
} else {
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
}
ctx.stroke();
// Draw control points
drawPoints(ctx, curve, state, isSelected);
ctx.restore();
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Main redraw orchestration
*/
import { drawCurve } from "./drawCurve.js";
import { drawBackgroundImage } from "./drawBackground.js";
import { evaluateFrame } from "../core/frameEvaluator.js";
/**
* Redraw the canvas with all curves and background
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.drawBackgroundImage - Draw background image
* @param {Function} callbacks.updateCurveList - Update curve list
* @param {Function} callbacks.updateAddPointButton - Update add point button
* @param {Object} node - ComfyUI node
* @param {Object} app - ComfyUI app instance
*/
export function redraw(ctx, canvas, state, callbacks, node, app) {
try {
// Clear canvas
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw input image if available
if (callbacks.drawBackgroundImage) {
callbacks.drawBackgroundImage(ctx, canvas, state, node, app);
}
// Get curves for current frame
const shape = callbacks.getActiveShape();
const curves = evaluateFrame(shape, state.currentFrame, { hold: true });
// Draw all curves
curves.forEach(curve => {
if (curve && curve.points && curve.points.length >= 2) {
const isSelected = state.selectedCurves.has(curve.id);
drawCurve(ctx, curve, state, isSelected);
}
});
// Draw selection box if active
if (state.selectionBox !== null) {
ctx.save();
// Calculate normalized box bounds
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
const width = maxX - minX;
const height = maxY - minY;
// Draw filled rectangle with semi-transparent fill
ctx.fillStyle = "rgba(74, 158, 255, 0.2)";
ctx.fillRect(minX, minY, width, height);
// Draw dashed border
ctx.strokeStyle = "rgba(74, 158, 255, 0.8)";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.strokeRect(minX, minY, width, height);
// Restore line dash
ctx.setLineDash([]);
ctx.restore();
}
// Update Add Point button state
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
// Update Toggle Smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
// Cancel add point mode if no curve is selected
if (state.selectedCurves.size === 0 && state.addPointMode) {
state.addPointMode = false;
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
}
// Update curve list
if (callbacks.updateCurveList) {
callbacks.updateCurveList();
}
} catch (error) {
console.error("[ComfyUI Roto] Error during redraw:", error);
}
}
+755
View File
@@ -0,0 +1,755 @@
/**
* 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);
}
}
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Canvas utility functions
*/
/**
* Get mouse position relative to canvas, accounting for scaling
* @param {MouseEvent} e - Mouse event
* @param {HTMLCanvasElement} canvas - Canvas element
* @returns {{x: number, y: number}} Mouse position clamped to canvas bounds
*/
export function getMousePos(e, canvas) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
// Calculate position even if mouse is outside canvas bounds
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Clamp to canvas bounds
return {
x: Math.max(0, Math.min(canvas.width, x)),
y: Math.max(0, Math.min(canvas.height, y))
};
}
+33
View File
@@ -0,0 +1,33 @@
/**
* File upload and image loading utilities
*/
/**
* Upload an image file to ComfyUI server
* @param {File} file - Image file to upload
* @returns {Promise<Object>} Upload response with filename and subfolder
*/
export async function uploadImageFile(file) {
const body = new FormData();
body.append("image", file);
body.append("overwrite", "true");
const resp = await fetch("/upload/image", { method: "POST", body });
if (!resp.ok) {
throw new Error(`Failed to upload image: ${resp.statusText}`);
}
return resp.json();
}
/**
* Load an image asynchronously from a URL
* @param {string} imageURL - URL of the image to load
* @returns {Promise<HTMLImageElement>} Loaded image element
*/
export async function loadImageAsync(imageURL) {
return new Promise((resolve) => {
const img = new Image();
img.setAttribute("crossorigin", "anonymous");
img.addEventListener("load", () => resolve(img));
img.src = imageURL;
});
}
+27
View File
@@ -0,0 +1,27 @@
/**
* General helper utilities
*/
/**
* Deep copy an object using JSON serialization
* @param {any} obj - Object to deep copy
* @returns {any} Deep copy of the object
*/
export function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Convert hex color string to RGB array [0-1]
* @param {string} hex - Hex color string (e.g., "#ff0000" or "ff0000")
* @returns {number[]} RGB array with values in range [0, 1]
*/
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return [0.0, 0.0, 0.0];
return [
parseInt(result[1], 16) / 255.0,
parseInt(result[2], 16) / 255.0,
parseInt(result[3], 16) / 255.0
];
}
+11
View File
@@ -0,0 +1,11 @@
/**
* UUID generation utility
*/
export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}