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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user