778 lines
29 KiB
JavaScript
778 lines
29 KiB
JavaScript
/**
|
|
* 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
|
|
};
|
|
}
|