Files
TechnicalTod-ComfyUI-RotoMask/web/components/Toolbar.js
T
2026-01-28 19:33:35 +13:00

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
};
}