Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user