Initial commit

This commit is contained in:
TechnicalTod
2026-01-28 19:33:35 +13:00
commit 4cd66803ef
34 changed files with 4455 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.egg-info/
dist/
build/
# Testing
.pytest_cache/
.coverage
htmlcov/
#docs
docs/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# ComfyUI specific
*.ckpt
*.safetensors
docs/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 ComfyUI Roto Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+147
View File
@@ -0,0 +1,147 @@
# ComfyUI Roto Mask
A rotoscoping tool for ComfyUI that allows you to create animated masks using polygon curves with keyframe interpolation.
## Features
### 🎨 Interactive Canvas Editor
- **Draw Mode**: Click to create polygon curves on the canvas
- **Edit Mode**: Select and drag control points to modify curves
- **Soften/Sharpen Points**: Toggle individual points between smooth (curved) and sharp (angular)
- **Visual Feedback**: Selected curves highlighted in blue with semi-transparent overlays
- **Auto-Resize**: Canvas automatically matches loaded image dimensions (up to 2048px)
### 🎬 Animation & Timeline
- **Keyframe Animation**: Each curve has its own independent keyframe timeline
- **Automatic Interpolation**: Frames between keyframes are automatically interpolated
- **Frame Playback**: Play/Pause controls with 24fps playback
- **Frame Slider**: Scrub through frames to preview animation
- **Batch Frame Loading**: Upload multiple images to create frame sequences
- **Output Frame Range**: Set custom start and end frames for rendering
### 🛠️ Advanced Tools
- **Feather Amount**: Adjustable Gaussian blur for smooth mask edges (0-100)
- **Background Color**: Customizable mask background color
- **Mask Visualization**: Adjustable mask color and opacity for preview
- **Curve List Panel**: Manage all curves with per-curve keyframe navigation
- **Multi-Curve Support**: Combine multiple curves into a single mask
- **Per-Curve Keyframes**: Each curve interpolates independently
- **Add Point**: Add new points to existing curves by clicking on edges
### 📤 Multiple Outputs
- **Mask as Image**: RGB mask tensor for preview
- **Mask**: Single-channel mask for compositing
- **Masked Image**: Original image with mask overlay applied
- **Loaded Image**: Original loaded image
## Installation
1. Clone or download this repository into your ComfyUI `custom_nodes` directory:
```
ComfyUI/custom_nodes/ComfyUI_Roto/
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Restart ComfyUI
## Quick Start
1. **Add the Node**: Search for "Roto Mask" in the node menu and add it to your workflow
2. **Load Frames** (optional):
- Click "Load Frames" button
- Select multiple images (Ctrl+Click or Shift+Click)
- Images will be uploaded and the frame slider will update automatically
3. **Draw Curves**:
- Press `B` or click "Draw (B)" to enter Draw mode
- Click on the canvas to create a polygon curve
- Curves are automatically closed for filled masks
4. **Edit Curves**:
- Press `V` or click "Edit (V)" to enter Edit mode
- Click and drag control points to modify curves
- Use Shift+Click to select multiple points
- Click "Soften Point" to make selected points smooth (curved)
- Click "Sharpen Point" to make selected points angular
5. **Add Points**:
- Select a curve in Edit mode
- Click "Add Point" button
- Click on a curve edge to add a new point at that location
6. **Animate**:
- Move the frame slider to a different frame
- Edit curves on that frame (automatically creates keyframes)
- Frames between keyframes will interpolate automatically
6. **Adjust Settings**:
- **Feather Amount**: Use the slider to control edge softness (0 = sharp, 100 = very soft)
- **Mask Color**: Choose the background color for masked areas
7. **Render**: Set frame range (set to full range by default) to render a specific range of the total loaded clip
## Advanced Usage
### Independent Curve Interpolation
Each curve maintains its own keyframe timeline. This means:
- Curve A can have keyframes at frames 1, 10, 20
- Curve B can have keyframes at frames 1, 15, 30
- They interpolate independently without interfering with each other
### Keyframe Management
- **Creating Keyframes**: Edit a curve on a non-keyframe frame - only that curve gets a keyframe
- **Navigating Keyframes**: Use ◀/▶ buttons in the curve list to jump between keyframes for each curve
- **Deleting Keyframes**: Click 🔑✕ button to remove a keyframe for a specific curve on the current frame
- **Deleting Curves**: Click ✕ button to remove a curve from all frames
The value is stored in the curve metadata and persists with your workflow.
## UI Controls
### Toolbar
- **Load Frames**: Upload multiple images for frame sequences
- **Draw (B)**: Switch to draw mode
- **Edit (V)**: Switch to edit mode
- **Add Point**: Click to enable, then click on curve edge to add point
- **Soften/Sharpen Point**: Toggle selected points between smooth and angular
### Timeline
- **Frame Counter**: Shows current frame / total frames
- **Frame Slider**: Scrub through frames
- **Play/Pause**: Toggle animation playback at 24fps
### Settings Panel
- **Mask Visualization Settings**:
- **Mask Color**: Color picker for preview overlay color
- **Mask Opacity**: Slider to control preview overlay transparency (0-100%)
- **Image Output Settings**:
- **Feather Amount**: Slider to control edge softness (0-100)
- **Background Color**: Color picker for mask background
- **Output Frame Range**:
- **Start Frame**: First frame to render
- **End Frame**: Last frame to render
### Curve List Panel
- Shows all curves with their IDs
- **Red dot**: Indicates curve has a keyframe on current frame
- **◀**: Jump to previous keyframe for this curve
- **▶**: Jump to next keyframe for this curve
- **🔑✕**: Delete keyframe for this curve on current frame
- **✕**: Delete curve from all frames
- **Click curve name**: Select curve on canvas
## License
MIT License - see [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests.
+17
View File
@@ -0,0 +1,17 @@
"""
ComfyUI Roto MAsk - rotoscoping node for ComfyUI
"""
from .roto_node import RotoMaskNode
# Export web directory for frontend extension
WEB_DIRECTORY = "./web"
# Node class mappings for ComfyUI
NODE_CLASS_MAPPINGS = {"RotoMaskNode": RotoMaskNode}
# Display names in UI
NODE_DISPLAY_NAME_MAPPINGS = {"RotoMaskNode": "Roto Mask"}
# Version info
__version__ = "1.2.0"
+3
View File
@@ -0,0 +1,3 @@
"""
Core modules for roto node
"""
+112
View File
@@ -0,0 +1,112 @@
"""
Curve data validation
"""
from typing import Dict
def validate_schema(data: Dict) -> bool:
"""
Validate JSON data structure.
Args:
data: Parsed JSON data
Returns:
True if schema is valid, False otherwise
"""
# Check for required top-level keys
if not isinstance(data, dict):
return False
if "meta" not in data or "shapes" not in data:
return False
# Check meta structure
meta = data["meta"]
if not isinstance(meta, dict):
return False
required_meta = ["version", "width", "height"]
if not all(key in meta for key in required_meta):
return False
# Check shapes structure
shapes = data["shapes"]
if not isinstance(shapes, dict):
return False
# Validate each shape
for shape_id, shape in shapes.items():
if not isinstance(shape, dict):
return False
if "name" not in shape or "frames" not in shape:
return False
frames = shape["frames"]
if not isinstance(frames, dict):
return False
# Validate curves in each frame
for frame_num, curves in frames.items():
if not isinstance(curves, list):
return False
for curve in curves:
if not validate_curve(curve):
return False
return True
def validate_curve(curve: Dict) -> bool:
"""
Validate a single curve structure.
Args:
curve: Curve dictionary
Returns:
True if curve is valid, False otherwise
"""
if not isinstance(curve, dict):
return False
required_keys = ["id", "closed", "points"]
if not all(key in curve for key in required_keys):
return False
# Check points array
points = curve["points"]
if not isinstance(points, list):
return False
# Must have at least 2 points for a valid polygon
# For closed shapes, need at least 3 points
if len(points) < 2:
return False
if curve.get("closed", False) and len(points) < 3:
return False
# Each point must be [x, y] pair
for point in points:
if not isinstance(point, (list, tuple)) or len(point) != 2:
return False
if not all(isinstance(coord, (int, float)) for coord in point):
return False
# Validate optional pointTypes array
if "pointTypes" in curve:
point_types = curve["pointTypes"]
if not isinstance(point_types, list):
return False
# pointTypes length must match points length
if len(point_types) != len(points):
return False
# All values must be "smooth" or "hard"
valid_types = {"smooth", "hard"}
if not all(pt in valid_types for pt in point_types):
return False
return True
+223
View File
@@ -0,0 +1,223 @@
"""
Frame evaluation and interpolation logic
"""
import copy
from typing import Dict, List
def get_interpolated_point_types(curve_a: Dict, curve_b: Dict, total_points: int) -> List[str]:
"""
Get interpolated pointTypes array from two curves.
Since pointTypes should be consistent across all keyframes, prefer curve_a.
Args:
curve_a: First curve (earlier keyframe)
curve_b: Second curve (later keyframe)
total_points: Total point count in the interpolated result
Returns:
List of point types ("smooth" or "hard")
"""
types_a = curve_a.get("pointTypes", []) if isinstance(curve_a.get("pointTypes"), list) else []
types_b = curve_b.get("pointTypes", []) if isinstance(curve_b.get("pointTypes"), list) else []
result = []
for i in range(total_points):
if i < len(types_a) and types_a[i] in ("smooth", "hard"):
result.append(types_a[i])
elif i < len(types_b) and types_b[i] in ("smooth", "hard"):
result.append(types_b[i])
else:
result.append("hard") # Default for any missing entries
return result
def evaluate_frame(shape: Dict, frame_index: int) -> List[Dict]:
"""
Evaluate curves for a given frame.
Each curve is interpolated independently based on its own keyframes.
Args:
shape: Shape dictionary
frame_index: Frame to evaluate
Returns:
List of curve dictionaries for this frame
"""
frames = shape.get("frames", {})
frame_key = str(frame_index)
# Collect all unique curve IDs and their keyframes
all_curve_ids = set()
curve_keyframes: Dict[str, List[int]] = {} # curveId -> sorted list of frame numbers
for frame_str, curves in frames.items():
try:
frame_num = int(frame_str)
except ValueError:
continue
for curve in (curves or []):
if curve and "id" in curve:
curve_id = curve["id"]
all_curve_ids.add(curve_id)
if curve_id not in curve_keyframes:
curve_keyframes[curve_id] = []
curve_keyframes[curve_id].append(frame_num)
# Sort keyframes for each curve
for curve_id in curve_keyframes:
curve_keyframes[curve_id].sort()
if not all_curve_ids:
return []
result = []
# Evaluate each curve independently based on its own keyframes
for curve_id in all_curve_ids:
keyframes = curve_keyframes.get(curve_id, [])
if not keyframes:
continue
# Check for exact keyframe for this curve
if frame_index in keyframes:
frame_curves = frames.get(frame_key, [])
curve = next((c for c in frame_curves if c["id"] == curve_id), None)
if curve:
result.append(copy.deepcopy(curve))
continue
# Find prev and next keyframes FOR THIS CURVE
prev_frame = None
next_frame = None
for fn in keyframes:
if fn < frame_index:
prev_frame = fn
elif fn > frame_index:
next_frame = fn
break
# Interpolate or hold based on this curve's keyframes
if prev_frame is not None and next_frame is not None:
# Interpolate between this curve's keyframes
curve_a = next((c for c in frames.get(str(prev_frame), []) if c["id"] == curve_id), None)
curve_b = next((c for c in frames.get(str(next_frame), []) if c["id"] == curve_id), None)
if curve_a and curve_b:
t = (frame_index - prev_frame) / (next_frame - prev_frame)
interpolated_points = []
# Handle different point counts - use the smaller count
min_points = min(len(curve_a["points"]), len(curve_b["points"]))
for i in range(min_points):
pa = curve_a["points"][i]
pb = curve_b["points"][i]
x = pa[0] + (pb[0] - pa[0]) * t
y = pa[1] + (pb[1] - pa[1]) * t
interpolated_points.append([x, y])
# If one curve has more points, add them at the end (hold from that curve)
if len(curve_a["points"]) > min_points:
for i in range(min_points, len(curve_a["points"])):
interpolated_points.append([curve_a["points"][i][0], curve_a["points"][i][1]])
elif len(curve_b["points"]) > min_points:
for i in range(min_points, len(curve_b["points"])):
interpolated_points.append([curve_b["points"][i][0], curve_b["points"][i][1]])
result.append({
"id": curve_a["id"],
"closed": curve_a["closed"],
"points": interpolated_points,
"pointTypes": get_interpolated_point_types(curve_a, curve_b, len(interpolated_points))
})
else:
# Hold from nearest keyframe for this curve
hold_frame = prev_frame if prev_frame is not None else next_frame
if hold_frame is not None:
curve = next((c for c in frames.get(str(hold_frame), []) if c["id"] == curve_id), None)
if curve:
result.append(copy.deepcopy(curve))
return result
def interpolate_curves(
curves_a: List[Dict],
curves_b: List[Dict],
frame_a: int,
frame_b: int,
target_frame: int
) -> List[Dict]:
"""
Linearly interpolate curves between two keyframes.
Curves that only exist in one keyframe are held (included as-is).
Args:
curves_a: Curves at earlier keyframe
curves_b: Curves at later keyframe
frame_a: Earlier frame number
frame_b: Later frame number
target_frame: Frame to interpolate to
Returns:
List of interpolated curves
"""
# Calculate interpolation factor
t = (target_frame - frame_a) / (frame_b - frame_a)
# Track which curve IDs we've processed
processed_ids = set()
result = []
# Process curves from A - interpolate if in both, hold if only in A
for curve_a in curves_a:
processed_ids.add(curve_a["id"])
# Find matching curve in curves_b
curve_b = None
for cb in curves_b:
if cb["id"] == curve_a["id"]:
curve_b = cb
break
if curve_b is not None:
# Curve exists in both keyframes - interpolate
points_a = curve_a["points"]
points_b = curve_b["points"]
# Handle different point counts - use the smaller count
min_points = min(len(points_a), len(points_b))
interpolated_points = []
for i in range(min_points):
pa = points_a[i]
pb = points_b[i]
x = pa[0] + (pb[0] - pa[0]) * t
y = pa[1] + (pb[1] - pa[1]) * t
interpolated_points.append([x, y])
# If one curve has more points, add them at the end (hold from that curve)
if len(points_a) > min_points:
for i in range(min_points, len(points_a)):
interpolated_points.append([points_a[i][0], points_a[i][1]])
elif len(points_b) > min_points:
for i in range(min_points, len(points_b)):
interpolated_points.append([points_b[i][0], points_b[i][1]])
result.append({
"id": curve_a["id"],
"closed": curve_a["closed"],
"points": interpolated_points,
"pointTypes": get_interpolated_point_types(curve_a, curve_b, len(interpolated_points))
})
else:
# Curve only in A - hold it
result.append(copy.deepcopy(curve_a))
# Process curves only in B (not in A) - hold them
for curve_b in curves_b:
if curve_b["id"] not in processed_ids:
result.append(copy.deepcopy(curve_b))
return result
+3
View File
@@ -0,0 +1,3 @@
"""
Image processing modules
"""
+58
View File
@@ -0,0 +1,58 @@
"""
Image loading utilities
"""
import os
import numpy as np
from PIL import Image
from typing import Optional
try:
from folder_paths import get_input_directory
except ImportError:
# Fallback for testing
def get_input_directory():
return "input"
def load_image_from_disk(
filename: str,
subfolder: str = ""
) -> Optional[np.ndarray]:
"""
Load image from ComfyUI input directory.
Args:
filename: Image filename
subfolder: Optional subfolder within input directory
Returns:
Image array (H, W, 3) in range [0, 1] as float32, or None if failed
"""
if not filename:
return None
try:
input_dir = get_input_directory()
if subfolder:
image_path = os.path.join(input_dir, subfolder, filename)
else:
image_path = os.path.join(input_dir, filename)
if not os.path.exists(image_path):
print(f"[RotoMaskNode] Image not found: {image_path}")
return None
# Load image using PIL
img = Image.open(image_path)
# Convert to RGB if needed
if img.mode != "RGB":
img = img.convert("RGB")
# Convert to numpy array and normalize to [0, 1]
img_array = np.array(img, dtype=np.float32) / 255.0
return img_array
except Exception as e:
print(f"[RotoMaskNode] Error loading image {filename}: {e}")
return None
+66
View File
@@ -0,0 +1,66 @@
"""
Image processing functions
"""
import numpy as np
import cv2
from typing import List
def resize_mask_to_image(
mask: np.ndarray,
target_height: int,
target_width: int
) -> np.ndarray:
"""
Resize mask to match image dimensions.
Args:
mask: Mask array (H, W) in range [0, 1]
target_height: Target height
target_width: Target width
Returns:
Resized mask array (target_height, target_width) in range [0, 1]
"""
if mask.shape[0] == target_height and mask.shape[1] == target_width:
return mask
# Use cv2.resize with INTER_LINEAR for smooth resizing
mask_resized = cv2.resize(mask, (target_width, target_height), interpolation=cv2.INTER_LINEAR)
# Ensure values stay in [0, 1] range
mask_resized = np.clip(mask_resized, 0.0, 1.0)
return mask_resized
def apply_mask_overlay(
image: np.ndarray,
mask: np.ndarray,
background_color: List[float]
) -> np.ndarray:
"""
Apply mask overlay to image with background color.
Where mask is 1.0, use background_color; where mask is 0.0, use original image.
Args:
image: Image array (H, W, 3) in range [0, 1]
mask: Mask array (H, W) in range [0, 1]
background_color: RGB color [r, g, b] in range [0, 1]
Returns:
Image with mask overlay applied (H, W, 3) in range [0, 1]
"""
# Ensure mask is 3D for broadcasting: (H, W) -> (H, W, 1)
mask_3d = np.expand_dims(mask, axis=-1)
# Expand background_color to match image shape: (3,) -> (1, 1, 3)
bg_color_array = np.array(background_color, dtype=np.float32).reshape(1, 1, 3)
# Apply overlay: image * (1 - mask) + background_color * mask
masked_image = image * (1.0 - mask_3d) + bg_color_array * mask_3d
# Ensure values stay in [0, 1] range
masked_image = np.clip(masked_image, 0.0, 1.0)
return masked_image
+24
View File
@@ -0,0 +1,24 @@
"""
Image utility functions
"""
import torch
def create_empty_mask(
batch_size: int,
height: int,
width: int
) -> torch.Tensor:
"""
Create an empty mask tensor.
Args:
batch_size: Number of images in batch
height: Mask height
width: Mask width
Returns:
Zero-filled mask tensor as RGB (B, H, W, 3)
"""
return torch.zeros((batch_size, height, width, 3), dtype=torch.float32)
+3
View File
@@ -0,0 +1,3 @@
"""
Mask generation modules
"""
+36
View File
@@ -0,0 +1,36 @@
"""
Antialiasing and feathering
"""
import numpy as np
import cv2
def apply_antialiasing(mask: np.ndarray, feather_amount: float = 0.0) -> np.ndarray:
"""
Apply Gaussian blur for antialiasing/feathering.
Args:
mask: Binary mask (0-255)
feather_amount: Amount of feathering to apply (0.0 = no blur, max 100.0)
Returns:
Smoothed mask (0-255)
"""
# If feather_amount is 0 or invalid, return mask unchanged
if feather_amount <= 0.0 or not isinstance(feather_amount, (int, float)) or not np.isfinite(feather_amount):
return mask
# Clamp feather_amount to valid range
feather_amount = float(np.clip(feather_amount, 0.0, 100.0))
# Calculate kernel size: 6 * sigma + 1, ensure it's odd and >= 3
kernel_size = int(6 * feather_amount + 1)
if kernel_size % 2 == 0:
kernel_size += 1
kernel_size = max(3, kernel_size) # Minimum kernel size for cv2.GaussianBlur
# Use feather_amount as sigma
sigma = float(feather_amount)
return cv2.GaussianBlur(mask, (kernel_size, kernel_size), sigma)
+54
View File
@@ -0,0 +1,54 @@
"""
Mask generation
"""
import numpy as np
from typing import Dict
from ..core.frame_evaluator import evaluate_frame
from .rasterizer import rasterize_curve
from .antialiasing import apply_antialiasing
def generate_single_mask(
curve_data: Dict,
frame_index: int,
height: int,
width: int,
feather_amount: float = 0.0
) -> np.ndarray:
"""
Generate mask for a single frame.
Args:
curve_data: Parsed curve data
frame_index: Frame to render
height: Output height
width: Output width
feather_amount: Amount of feathering to apply
Returns:
Mask array (H, W) in range [0, 1]
"""
# Create empty mask
mask = np.zeros((height, width), dtype=np.uint8)
# Get all shapes
shapes = curve_data.get("shapes", {})
# Process each shape
for shape_id, shape in shapes.items():
# Evaluate curves for this frame
curves = evaluate_frame(shape, frame_index)
# Rasterize each curve
for curve in curves:
rasterize_curve(mask, curve, height, width)
# Apply antialiasing
mask = apply_antialiasing(mask, feather_amount)
# Normalize to [0, 1]
mask = mask.astype(np.float32) / 255.0
return mask
+178
View File
@@ -0,0 +1,178 @@
"""
Curve rasterization
"""
import math
import numpy as np
import cv2
from typing import Dict, List
def sample_smooth_curve(points: List[List[float]], point_types: List[str], closed: bool = False, samples_per_segment: int = 15) -> List[List[float]]:
"""
Convert smooth curve to polygon by sampling using Catmull-Rom style interpolation.
Args:
points: List of [x, y] point coordinates
point_types: List of "smooth" or "hard" for each point
closed: Whether curve is closed
samples_per_segment: Number of samples per smooth segment
Returns:
List of sampled [x, y] points
"""
# Validate inputs
if not isinstance(points, list) or len(points) < 2:
return points
if not isinstance(point_types, list) or len(point_types) != len(points):
return points # No valid pointTypes, return original
# Validate all point types are valid
valid_types = {"smooth", "hard"}
if not all(pt in valid_types for pt in point_types):
return points # Invalid types, return original
if all(pt == "hard" for pt in point_types):
return points # All hard, return original
n = len(points)
# Helper to get point with wrap-around for closed curves
def get_point(index: int) -> List[float]:
if closed:
return points[index % n]
else:
if index < 0:
return points[0]
if index >= n:
return points[n - 1]
return points[index]
# Helper to get point type with wrap-around
def get_type(index: int) -> str:
if closed:
return point_types[index % n]
else:
if index < 0:
return point_types[0]
if index >= n:
return point_types[n - 1]
return point_types[index]
sampled = [points[0]] # Start with first point
# For each segment
segment_count = n if closed else n - 1
for i in range(segment_count):
next_i = (i + 1) % n
p0 = points[i]
p1 = points[next_i]
type0 = get_type(i)
type1 = get_type(next_i)
# Validate coordinates are finite
if not (all(isinstance(coord, (int, float)) and math.isfinite(coord) for coord in p0 + p1)):
sampled.append(p1) # Invalid coordinates, skip smoothing
continue
# Check segment length (zero-length segments)
dx = p1[0] - p0[0]
dy = p1[1] - p0[1]
segment_length = math.sqrt(dx*dx + dy*dy)
if segment_length < 0.1: # Very short segment, treat as hard
sampled.append(p1)
continue
if type0 == "hard" and type1 == "hard":
sampled.append(p1)
else:
# Use cubic curve with Catmull-Rom style control points
prev_p = get_point(i - 1)
next_next_p = get_point(i + 2)
# Tension factor (0.5 is Catmull-Rom default)
tension = 0.5
# Calculate control point 1 (based on p0's tangent if smooth)
if type0 == "smooth":
cp1x = p0[0] + (p1[0] - prev_p[0]) * tension / 2
cp1y = p0[1] + (p1[1] - prev_p[1]) * tension / 2
else:
cp1x = p0[0] + (p1[0] - p0[0]) * 0.25
cp1y = p0[1] + (p1[1] - p0[1]) * 0.25
# Calculate control point 2 (based on p1's tangent if smooth)
if type1 == "smooth":
cp2x = p1[0] - (next_next_p[0] - p0[0]) * tension / 2
cp2y = p1[1] - (next_next_p[1] - p0[1]) * tension / 2
else:
cp2x = p1[0] - (p1[0] - p0[0]) * 0.25
cp2y = p1[1] - (p1[1] - p0[1]) * 0.25
# Adaptive sampling based on segment length
actual_samples = max(4, min(samples_per_segment, int(segment_length / 5)))
# Sample cubic curve
for j in range(1, actual_samples + 1):
t = j / actual_samples
# Cubic curve: (1-t)³P0 + 3(1-t)²tCP1 + 3(1-t)t²CP2 + t³P1
t2 = t * t
t3 = t2 * t
mt = 1 - t
mt2 = mt * mt
mt3 = mt2 * mt
x = mt3 * p0[0] + 3 * mt2 * t * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0]
y = mt3 * p0[1] + 3 * mt2 * t * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1]
# Validate sampled point is finite
if math.isfinite(x) and math.isfinite(y):
sampled.append([x, y])
return sampled
def rasterize_curve(
mask: np.ndarray,
curve: Dict,
height: int,
width: int
) -> None:
"""
Rasterize a polygon into the mask.
Modifies mask in-place.
Args:
mask: Mask array to draw into
curve: Curve dictionary with points as polygon vertices
height: Mask height
width: Mask width
"""
points = curve["points"]
closed = curve.get("closed", False)
point_types = curve.get("pointTypes", [])
# Convert smooth curves to polygons by sampling
sampled_points = sample_smooth_curve(points, point_types, closed)
# Convert points to integer coordinates and clamp to bounds
polygon = []
for point in sampled_points:
x, y = point[0], point[1]
# Clamp to mask bounds
px = int(np.clip(x, 0, width - 1))
py = int(np.clip(y, 0, height - 1))
polygon.append([px, py])
# Convert to numpy array format for cv2
polygon = np.array(polygon, dtype=np.int32)
# Fill polygon
if closed and len(polygon) >= 3:
cv2.fillPoly(mask, [polygon], 255)
else:
# Draw polyline if not closed
cv2.polylines(mask, [polygon], False, 255, 2)
+5
View File
@@ -0,0 +1,5 @@
numpy>=1.21.0
opencv-python>=4.5.0
torch>=1.9.0
Pillow>=8.0.0
pytest>=7.0.0
+187
View File
@@ -0,0 +1,187 @@
"""
Roto Mask Node - Backend implementation
Converts curve data to rasterized masks
"""
import json
import numpy as np
import torch
from typing import Dict, List, Tuple, Optional, Any
from .core.curve_validator import validate_schema, validate_curve
from .mask.generator import generate_single_mask
from .image.loader import load_image_from_disk
from .image.processor import resize_mask_to_image, apply_mask_overlay
from .image.utils import create_empty_mask
class RotoMaskNode:
"""
ComfyUI node for generating masks from curve data.
Accepts image input, curve JSON data, and frame index.
Outputs a single-channel mask image.
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
"""
Define input parameters for the node.
Returns:
Dictionary specifying required input types and their constraints
"""
return {
"required": {
"curve_json": (
"STRING",
{
"multiline": True,
"default": '{"meta":{"version":1,"width":512,"height":512},"shapes":{}}',
},
),
"frame_index": ("INT", {"default": 1, "min": 1, "max": 9999, "step": 1}),
},
"optional": {
"frame_count": ("INT", {"default": 1, "min": 1, "max": 9999, "step": 1}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "IMAGE", "STRING")
RETURN_NAMES = ("Mask as image", "Mask", "Masked Image", "Loaded Image", "Mask Data")
FUNCTION = "generate_mask"
CATEGORY = "mask"
def generate_mask(
self, curve_json: str, frame_index: int, frame_count: Optional[int] = None
) -> Tuple[torch.Tensor]:
"""
Main execution function for the node.
Args:
curve_json: JSON string containing curve data
frame_index: Starting frame number to render
frame_count: Number of frames to render (1 = single frame, >1 = batch sequence)
Returns:
Tuple containing mask tensor as RGB image (B, H, W, 3)
"""
# Default frame_count to 1 if not provided
if frame_count is None:
frame_count = 1
# For batch rendering (frame_count > 1), always start from frame 1
# frame_index is only used for single-frame preview mode
if frame_count > 1:
frame_index = 1
# Parse curve data
try:
curve_data = json.loads(curve_json)
except json.JSONDecodeError as e:
print(f"[RotoMaskNode] Invalid JSON: {e}")
# Return empty masks on error (default 512x512)
empty_mask = create_empty_mask(frame_count, 512, 512)
empty_mask_single = torch.zeros((frame_count, 512, 512), dtype=torch.float32)
return (empty_mask, empty_mask_single, empty_mask, empty_mask, curve_json)
# Validate schema
if not validate_schema(curve_data):
print("[RotoMaskNode] Invalid curve data schema")
# Return empty masks on error (default 512x512)
empty_mask = create_empty_mask(frame_count, 512, 512)
empty_mask_single = torch.zeros((frame_count, 512, 512), dtype=torch.float32)
return (empty_mask, empty_mask_single, empty_mask, empty_mask, curve_json)
# Get dimensions from curve_json metadata, default to 512x512
meta = curve_data.get("meta", {})
width = meta.get("width", 512)
height = meta.get("height", 512)
# Get feather_amount from metadata, validate and clamp
feather_amount = meta.get("feather_amount", 0.0)
if not isinstance(feather_amount, (int, float)) or not np.isfinite(feather_amount):
feather_amount = 0.0
else:
feather_amount = float(np.clip(feather_amount, 0.0, 100.0))
# Get background_color from metadata, default to black
background_color = meta.get("background_color", [0.0, 0.0, 0.0])
if not isinstance(background_color, list) or len(background_color) != 3:
background_color = [0.0, 0.0, 0.0]
else:
background_color = [
float(np.clip(background_color[0], 0.0, 1.0)),
float(np.clip(background_color[1], 0.0, 1.0)),
float(np.clip(background_color[2], 0.0, 1.0)),
]
# Get frame_paths from metadata
frame_paths = meta.get("frame_paths", [])
# Generate masks for all frames in the sequence
masks = []
for i in range(frame_count):
current_frame = frame_index + i
mask = generate_single_mask(curve_data, current_frame, height, width, feather_amount)
masks.append(mask)
# Stack all masks into batch: list of (H, W) -> (B, H, W)
mask_batch = np.stack(masks, axis=0)
# Output 1: Convert to 3-channel RGB for preview compatibility: (B, H, W) -> (B, H, W, 3)
mask_rgb = np.stack([mask_batch, mask_batch, mask_batch], axis=-1)
mask_tensor = torch.from_numpy(mask_rgb).float()
# Output 2: MASK format (single channel, B, H, W)
mask_single_channel = torch.from_numpy(mask_batch).float()
# Outputs 3 and 4: Load images and apply mask overlay
masked_images = []
raw_images = []
for i in range(frame_count):
current_frame = frame_index + i
# Map 1-based frame_index to 0-based array index
frame_array_index = current_frame - 1
# Load image if available
if frame_paths and 0 <= frame_array_index < len(frame_paths):
frame_info = frame_paths[frame_array_index]
image = load_image_from_disk(
frame_info.get("filename", ""), frame_info.get("subfolder", "")
)
if image is not None:
# Resize mask to match image dimensions
img_h, img_w = image.shape[:2]
mask_resized = resize_mask_to_image(mask_batch[i], img_h, img_w)
# Apply mask overlay
masked_image = apply_mask_overlay(image, mask_resized, background_color)
masked_images.append(masked_image)
raw_images.append(image)
else:
# Image load failed, create empty tensors matching mask dimensions
empty_img = np.zeros((height, width, 3), dtype=np.float32)
masked_images.append(empty_img)
raw_images.append(empty_img)
else:
# No frame_paths or out of bounds, create empty tensors matching mask dimensions
empty_img = np.zeros((height, width, 3), dtype=np.float32)
masked_images.append(empty_img)
raw_images.append(empty_img)
# Stack images into batches
if masked_images:
masked_batch = np.stack(masked_images, axis=0)
masked_tensor = torch.from_numpy(masked_batch).float()
else:
masked_tensor = torch.zeros((frame_count, height, width, 3), dtype=torch.float32)
if raw_images:
raw_batch = np.stack(raw_images, axis=0)
raw_tensor = torch.from_numpy(raw_batch).float()
else:
raw_tensor = torch.zeros((frame_count, height, width, 3), dtype=torch.float32)
return (mask_tensor, mask_single_channel, masked_tensor, raw_tensor, curve_json)
+47
View File
@@ -0,0 +1,47 @@
/**
* Canvas component creation
*/
/**
* Create and configure the canvas element
* @param {Object} state - Editor state
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} Canvas element and context
*/
export function createCanvas(state) {
const canvas = document.createElement("canvas");
canvas.width = state.canvasWidth;
canvas.height = state.canvasHeight;
canvas.style.border = "1px solid var(--border-color)";
canvas.style.backgroundColor = "#1a1a1a";
canvas.style.cursor = "crosshair";
// Don't use flex on canvas - let updateCanvasScale handle sizing
canvas.style.display = "block"; // Remove inline spacing
canvas.tabIndex = 0;
const ctx = canvas.getContext("2d");
return { canvas, ctx };
}
/**
* Resize canvas to match image dimensions
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {HTMLImageElement} img - Image to match
* @param {Function} updateCanvasLayoutFn - Function to update canvas layout
*/
export function resizeCanvasToImage(canvas, state, img, updateCanvasLayoutFn) {
if (!img || !img.width || !img.height) return;
const maxSize = 2048;
const width = Math.min(maxSize, img.width);
const height = Math.min(maxSize, img.height);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
state.canvasWidth = width;
state.canvasHeight = height;
if (updateCanvasLayoutFn) {
updateCanvasLayoutFn();
}
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Curve list component creation and management
*/
/**
* Create the curve list panel
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.setFrame - Set current frame
* @param {Function} callbacks.updateNodeInput - Update node input
* @param {Function} callbacks.redraw - Redraw canvas
* @returns {Object} Curve list elements and update function
*/
export function createCurveList(state, callbacks) {
const curveListContainer = document.createElement("div");
curveListContainer.style.display = "flex";
curveListContainer.style.flexDirection = "column";
curveListContainer.style.gap = "4px";
curveListContainer.style.height = "120px"; // Fixed height to fit ~3 shapes
curveListContainer.style.minHeight = "120px"; // Prevent shrinking
curveListContainer.style.maxHeight = "120px"; // Prevent growing into canvas space
curveListContainer.style.overflow = "auto";
curveListContainer.style.flexShrink = "0"; // Don't shrink when container is tight
// Title
const curveListHeader = document.createElement("div");
curveListHeader.textContent = "Curves";
curveListHeader.style.fontSize = "13px";
curveListHeader.style.fontWeight = "600";
curveListHeader.style.color = "var(--fg-color)";
// Count
const curveListCount = document.createElement("div");
curveListCount.style.fontSize = "12px";
curveListCount.style.opacity = "0.7";
curveListCount.style.marginBottom = "4px";
const curveList = document.createElement("ul");
curveList.style.listStyle = "none";
curveList.style.padding = "0";
curveList.style.margin = "0";
curveListContainer.appendChild(curveListHeader);
curveListContainer.appendChild(curveListCount);
curveListContainer.appendChild(curveList);
function updateCurveList() {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
const currentCurves = shape.frames[frameKey] || [];
const curveMap = new Map();
Object.entries(shape.frames || {}).forEach(([frame, curves]) => {
(curves || []).forEach(curve => {
if (!curve || !curve.id) return;
if (!curveMap.has(curve.id)) {
curveMap.set(curve.id, new Set());
}
curveMap.get(curve.id).add(frame);
});
});
curveList.innerHTML = "";
const ids = Array.from(curveMap.keys());
curveListCount.textContent = `${ids.length} curve(s)`;
// Create a map of curve IDs to display names (Shape 1, Shape 2, etc.)
const curveDisplayNames = new Map();
ids.forEach((id, index) => {
curveDisplayNames.set(id, `Shape ${index + 1}`);
});
ids.forEach(id => {
const item = document.createElement("li");
item.style.display = "flex";
item.style.justifyContent = "space-between";
item.style.alignItems = "center";
item.style.padding = "2px 0";
item.style.gap = "4px";
const label = document.createElement("span");
label.textContent = curveDisplayNames.get(id);
label.style.cursor = "pointer";
const frames = curveMap.get(id);
const frameNumbers = frames ? Array.from(frames).map(f => parseInt(f)).sort((a, b) => a - b) : [];
const hasKeyOnCurrentFrame = frames && frames.has(frameKey);
const dot = document.createElement("span");
dot.style.display = "inline-block";
dot.style.width = "6px";
dot.style.height = "6px";
dot.style.borderRadius = "50%";
dot.style.marginRight = "6px";
dot.style.backgroundColor = hasKeyOnCurrentFrame ? "#ff4d4d" : "transparent";
const left = document.createElement("div");
left.style.display = "flex";
left.style.alignItems = "center";
left.appendChild(dot);
left.appendChild(label);
// Keyframe navigation buttons
const navContainer = document.createElement("div");
navContainer.style.display = "flex";
navContainer.style.gap = "2px";
navContainer.style.alignItems = "center";
// Previous keyframe button
const prevKeyBtn = document.createElement("button");
prevKeyBtn.textContent = "◀";
prevKeyBtn.className = "comfy-button";
prevKeyBtn.type = "button";
prevKeyBtn.style.padding = "0 4px";
prevKeyBtn.style.minWidth = "20px";
prevKeyBtn.style.fontSize = "10px";
prevKeyBtn.title = "Previous keyframe";
prevKeyBtn.onclick = (e) => {
e.stopPropagation();
const currentFrameNum = state.currentFrame;
const prevFrames = frameNumbers.filter(f => f < currentFrameNum);
if (prevFrames.length > 0) {
const prevFrame = prevFrames[prevFrames.length - 1];
callbacks.setFrame(prevFrame);
}
};
// Next keyframe button
const nextKeyBtn = document.createElement("button");
nextKeyBtn.textContent = "▶";
nextKeyBtn.className = "comfy-button";
nextKeyBtn.type = "button";
nextKeyBtn.style.padding = "0 4px";
nextKeyBtn.style.minWidth = "20px";
nextKeyBtn.style.fontSize = "10px";
nextKeyBtn.title = "Next keyframe";
nextKeyBtn.onclick = (e) => {
e.stopPropagation();
const currentFrameNum = state.currentFrame;
const nextFrames = frameNumbers.filter(f => f > currentFrameNum);
if (nextFrames.length > 0) {
const nextFrame = nextFrames[0];
callbacks.setFrame(nextFrame);
}
};
// Delete keyframe button (only for current frame)
const deleteKeyBtn = document.createElement("button");
deleteKeyBtn.textContent = "🔑✕";
deleteKeyBtn.className = "comfy-button";
deleteKeyBtn.type = "button";
deleteKeyBtn.style.padding = "0 4px";
deleteKeyBtn.style.minWidth = "28px";
deleteKeyBtn.style.fontSize = "10px";
deleteKeyBtn.title = "Delete keyframe for this curve";
deleteKeyBtn.disabled = !hasKeyOnCurrentFrame;
deleteKeyBtn.style.opacity = hasKeyOnCurrentFrame ? "1" : "0.4";
deleteKeyBtn.onclick = (e) => {
e.stopPropagation();
if (!hasKeyOnCurrentFrame) return;
// Remove this curve from current frame only
if (shape.frames[frameKey]) {
shape.frames[frameKey] = shape.frames[frameKey].filter(c => c.id !== id);
if (shape.frames[frameKey].length === 0) {
delete shape.frames[frameKey];
}
}
state.selectedCurves.delete(id);
state.selectedPoints.clear();
callbacks.updateNodeInput();
callbacks.redraw();
};
// Delete curve entirely button
const deleteCurveBtn = document.createElement("button");
deleteCurveBtn.textContent = "✕";
deleteCurveBtn.className = "comfy-button";
deleteCurveBtn.type = "button";
deleteCurveBtn.style.padding = "0 6px";
deleteCurveBtn.style.minWidth = "24px";
deleteCurveBtn.title = "Delete curve from all frames";
deleteCurveBtn.onclick = (e) => {
e.stopPropagation();
Object.keys(shape.frames || {}).forEach(frame => {
shape.frames[frame] = (shape.frames[frame] || []).filter(c => c.id !== id);
if (shape.frames[frame].length === 0) {
delete shape.frames[frame];
}
});
state.selectedCurves.delete(id);
state.selectedPoints.clear();
callbacks.updateNodeInput();
callbacks.redraw();
};
navContainer.appendChild(prevKeyBtn);
navContainer.appendChild(nextKeyBtn);
navContainer.appendChild(deleteKeyBtn);
navContainer.appendChild(deleteCurveBtn);
if (state.selectedCurves.has(id)) {
label.style.color = "#4a9eff";
}
label.onclick = (e) => {
e.stopPropagation();
const curve = currentCurves.find(c => c.id === id);
state.selectedCurves.clear();
state.selectedPoints.clear();
if (curve) {
state.selectedCurves.add(id);
}
callbacks.redraw();
};
item.appendChild(left);
item.appendChild(navContainer);
curveList.appendChild(item);
});
}
return {
curveListContainer,
updateCurveList
};
}
+777
View File
@@ -0,0 +1,777 @@
/**
* Toolbar component creation and management
*/
import { uploadImageFile } from "../utils/fileUpload.js";
/**
* Create the toolbar with all controls
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.updateNodeInput - Update node input
* @param {Function} callbacks.setFrame - Set current frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.setMode - Set editor mode
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
* @param {Function} callbacks.updateModeButtons - Update mode button styles
* @param {Function} callbacks.getFrameImageForIndex - Get frame image
* @param {Function} callbacks.resizeCanvasToImage - Resize canvas to image
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} node - ComfyUI node
* @returns {Object} Toolbar elements and controls
*/
export function createToolbar(state, callbacks, canvas, node) {
// Section 1: Load Frames (full width at top)
const loadFramesSection = document.createElement("div");
loadFramesSection.style.display = "flex";
loadFramesSection.style.width = "100%";
loadFramesSection.style.gap = "8px";
// Feather amount slider
const featherLabel = document.createElement("span");
featherLabel.textContent = "Feather Amount:";
featherLabel.style.minWidth = "120px";
featherLabel.style.fontSize = "12px";
const featherSlider = document.createElement("input");
featherSlider.type = "range";
featherSlider.min = "0";
featherSlider.max = "100";
featherSlider.step = "0.1";
featherSlider.value = "0";
featherSlider.style.flex = "1 1 auto";
featherSlider.style.minWidth = "100px";
const featherValueLabel = document.createElement("span");
featherValueLabel.textContent = "0.0";
featherValueLabel.style.minWidth = "40px";
featherValueLabel.style.fontSize = "12px";
featherValueLabel.style.textAlign = "right";
featherSlider.oninput = (e) => {
const value = Math.max(0, Math.min(100, parseFloat(e.target.value) || 0));
state.featherAmount = value;
featherSlider.value = String(value);
featherValueLabel.textContent = value.toFixed(1);
callbacks.updateNodeInput();
};
const featherContainer = document.createElement("div");
featherContainer.style.display = "flex";
featherContainer.style.gap = "8px";
featherContainer.style.alignItems = "center";
featherContainer.style.flex = "1 1 auto";
featherContainer.style.minWidth = "200px";
featherContainer.appendChild(featherLabel);
featherContainer.appendChild(featherSlider);
featherContainer.appendChild(featherValueLabel);
// Background color picker
const bgColorLabel = document.createElement("span");
bgColorLabel.textContent = "Background color:";
bgColorLabel.style.minWidth = "90px";
bgColorLabel.style.fontSize = "12px";
const bgColorInput = document.createElement("input");
bgColorInput.type = "color";
bgColorInput.value = "#000000";
bgColorInput.style.width = "40px";
bgColorInput.style.height = "24px";
bgColorInput.style.cursor = "pointer";
bgColorInput.style.border = "1px solid var(--border-color)";
bgColorInput.style.borderRadius = "2px";
bgColorInput.onchange = (e) => {
state.backgroundColor = e.target.value;
callbacks.updateNodeInput();
};
const bgColorContainer = document.createElement("div");
bgColorContainer.style.display = "flex";
bgColorContainer.style.gap = "8px";
bgColorContainer.style.alignItems = "center";
bgColorContainer.appendChild(bgColorLabel);
bgColorContainer.appendChild(bgColorInput);
// Section 4: Settings - created early so we can append to it
const settingsSection = document.createElement("div");
settingsSection.style.display = "flex";
settingsSection.style.flexDirection = "column";
settingsSection.style.gap = "6px";
settingsSection.style.width = "100%";
// Divider at top of settings section (separates from curve list)
const settingsDivider = document.createElement("div");
settingsDivider.style.width = "100%";
settingsDivider.style.height = "1px";
settingsDivider.style.backgroundColor = "var(--border-color)";
settingsDivider.style.marginBottom = "4px";
// === Mask Visualization Settings ===
const maskVisualizationTitle = document.createElement("div");
maskVisualizationTitle.textContent = "Mask Visualization Settings";
maskVisualizationTitle.style.fontSize = "13px";
maskVisualizationTitle.style.fontWeight = "600";
maskVisualizationTitle.style.color = "var(--fg-color)";
// Mask Color picker
const maskColorLabel = document.createElement("span");
maskColorLabel.textContent = "Mask Color:";
maskColorLabel.style.fontSize = "12px";
maskColorLabel.style.minWidth = "70px";
const maskColorInput = document.createElement("input");
maskColorInput.type = "color";
maskColorInput.value = "#ffffff";
maskColorInput.style.width = "40px";
maskColorInput.style.height = "24px";
maskColorInput.style.cursor = "pointer";
maskColorInput.style.border = "1px solid var(--border-color)";
maskColorInput.style.borderRadius = "2px";
maskColorInput.onchange = (e) => {
state.maskColor = e.target.value;
callbacks.updateNodeInput();
callbacks.redraw();
};
const maskColorContainer = document.createElement("div");
maskColorContainer.style.display = "flex";
maskColorContainer.style.gap = "4px";
maskColorContainer.style.alignItems = "center";
maskColorContainer.appendChild(maskColorLabel);
maskColorContainer.appendChild(maskColorInput);
// Mask Opacity slider
const maskOpacityLabel = document.createElement("span");
maskOpacityLabel.textContent = "Mask Opacity:";
maskOpacityLabel.style.fontSize = "12px";
maskOpacityLabel.style.minWidth = "90px";
const maskOpacitySlider = document.createElement("input");
maskOpacitySlider.type = "range";
maskOpacitySlider.min = "0";
maskOpacitySlider.max = "100";
maskOpacitySlider.step = "1";
maskOpacitySlider.value = "10";
maskOpacitySlider.style.flex = "1 1 auto";
maskOpacitySlider.style.minWidth = "100px";
const maskOpacityValueLabel = document.createElement("span");
maskOpacityValueLabel.textContent = "10";
maskOpacityValueLabel.style.minWidth = "30px";
maskOpacityValueLabel.style.fontSize = "12px";
maskOpacityValueLabel.style.textAlign = "right";
maskOpacitySlider.oninput = (e) => {
const value = Math.max(0, Math.min(100, parseInt(e.target.value) || 0));
state.maskOpacity = value;
maskOpacitySlider.value = String(value);
maskOpacityValueLabel.textContent = String(value);
callbacks.updateNodeInput();
callbacks.redraw();
};
const maskOpacityContainer = document.createElement("div");
maskOpacityContainer.style.display = "flex";
maskOpacityContainer.style.gap = "8px";
maskOpacityContainer.style.alignItems = "center";
maskOpacityContainer.style.flex = "1 1 auto";
maskOpacityContainer.style.minWidth = "200px";
maskOpacityContainer.appendChild(maskOpacityLabel);
maskOpacityContainer.appendChild(maskOpacitySlider);
maskOpacityContainer.appendChild(maskOpacityValueLabel);
// Mask controls row (color and opacity on same line)
const maskControlsRow = document.createElement("div");
maskControlsRow.style.display = "flex";
maskControlsRow.style.gap = "16px";
maskControlsRow.style.alignItems = "center";
maskControlsRow.style.flexWrap = "wrap";
maskControlsRow.appendChild(maskColorContainer);
maskControlsRow.appendChild(maskOpacityContainer);
// Divider between Mask Visualization Settings and Image Output Settings
const maskImageDivider = document.createElement("div");
maskImageDivider.style.width = "100%";
maskImageDivider.style.height = "1px";
maskImageDivider.style.backgroundColor = "var(--border-color)";
maskImageDivider.style.marginTop = "4px";
maskImageDivider.style.marginBottom = "4px";
// === Image Output Settings ===
const imageOutputTitle = document.createElement("div");
imageOutputTitle.textContent = "Image Output Settings";
imageOutputTitle.style.fontSize = "13px";
imageOutputTitle.style.fontWeight = "600";
imageOutputTitle.style.color = "var(--fg-color)";
// Divider before Output Frame Range
const frameRangeDivider = document.createElement("div");
frameRangeDivider.style.width = "100%";
frameRangeDivider.style.height = "1px";
frameRangeDivider.style.backgroundColor = "var(--border-color)";
frameRangeDivider.style.marginTop = "4px";
// === Output Frame Range ===
const frameRangeTitle = document.createElement("div");
frameRangeTitle.textContent = "Output Frame Range";
frameRangeTitle.style.fontSize = "13px";
frameRangeTitle.style.fontWeight = "600";
frameRangeTitle.style.color = "var(--fg-color)";
frameRangeTitle.style.marginTop = "2px";
// Frame output controls - Start Frame
const startFrameLabel = document.createElement("span");
startFrameLabel.textContent = "Start Frame:";
startFrameLabel.style.fontSize = "12px";
startFrameLabel.style.minWidth = "70px";
const startFrameInput = document.createElement("input");
startFrameInput.type = "number";
startFrameInput.min = "1";
startFrameInput.value = "1";
startFrameInput.style.width = "60px";
startFrameInput.style.height = "24px";
startFrameInput.style.padding = "2px 6px";
startFrameInput.style.border = "1px solid var(--border-color, #555)";
startFrameInput.style.borderRadius = "4px";
startFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
startFrameInput.style.color = "var(--input-text, #fff)";
const startFrameContainer = document.createElement("div");
startFrameContainer.style.display = "flex";
startFrameContainer.style.gap = "4px";
startFrameContainer.style.alignItems = "center";
startFrameContainer.appendChild(startFrameLabel);
startFrameContainer.appendChild(startFrameInput);
// Frame output controls - End Frame
const endFrameLabel = document.createElement("span");
endFrameLabel.textContent = "End Frame:";
endFrameLabel.style.fontSize = "12px";
endFrameLabel.style.minWidth = "70px";
const endFrameInput = document.createElement("input");
endFrameInput.type = "number";
endFrameInput.min = "1";
endFrameInput.value = "1";
endFrameInput.style.width = "60px";
endFrameInput.style.height = "24px";
endFrameInput.style.padding = "2px 6px";
endFrameInput.style.border = "1px solid var(--border-color, #555)";
endFrameInput.style.borderRadius = "4px";
endFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
endFrameInput.style.color = "var(--input-text, #fff)";
const endFrameContainer = document.createElement("div");
endFrameContainer.style.display = "flex";
endFrameContainer.style.gap = "4px";
endFrameContainer.style.alignItems = "center";
endFrameContainer.appendChild(endFrameLabel);
endFrameContainer.appendChild(endFrameInput);
// Helper to calculate frame count from start/end
const getFrameCount = () => {
const startFrame = parseInt(startFrameInput.value) || 1;
const endFrame = parseInt(endFrameInput.value) || 1;
return Math.max(1, endFrame - startFrame + 1);
};
// Update frame range (called when frames are loaded)
const updateFrameRangeInfo = () => {
// This function is kept for compatibility but does nothing visual now
};
// Handle frame output setting changes
const updateFrameWidgets = () => {
const startFrame = parseInt(startFrameInput.value) || 1;
const endFrame = parseInt(endFrameInput.value) || 1;
const frameCount = Math.max(1, endFrame - startFrame + 1);
// Update frame_index widget
const frameIndexWidget = (node.widgets || []).find(w => w.name === "frame_index");
if (frameIndexWidget) {
frameIndexWidget.value = startFrame;
if (typeof frameIndexWidget.callback === "function") {
frameIndexWidget.callback(startFrame);
}
}
// Update frame_count widget
const frameCountWidget = (node.widgets || []).find(w => w.name === "frame_count");
if (frameCountWidget) {
frameCountWidget.value = frameCount;
if (typeof frameCountWidget.callback === "function") {
frameCountWidget.callback(frameCount);
}
}
callbacks.updateNodeInput();
};
startFrameInput.onchange = (e) => {
const value = Math.max(1, parseInt(e.target.value) || 1);
startFrameInput.value = String(value);
// Ensure end frame is not less than start frame
const endValue = parseInt(endFrameInput.value) || 1;
if (endValue < value) {
endFrameInput.value = String(value);
}
updateFrameWidgets();
};
endFrameInput.onchange = (e) => {
const startValue = parseInt(startFrameInput.value) || 1;
const value = Math.max(startValue, parseInt(e.target.value) || 1);
endFrameInput.value = String(value);
updateFrameWidgets();
};
// Frame controls row
const frameControlsRow = document.createElement("div");
frameControlsRow.style.display = "flex";
frameControlsRow.style.gap = "16px";
frameControlsRow.style.alignItems = "center";
frameControlsRow.style.flexWrap = "wrap";
frameControlsRow.appendChild(startFrameContainer);
frameControlsRow.appendChild(endFrameContainer);
// Update feather container to be full width row
featherContainer.style.width = "100%";
// Update bg color container to be full width row
bgColorContainer.style.width = "100%";
// Assemble settings section in order:
// 1. Divider (separates from curve list above)
// 2. Mask Visualization Settings title
// 3. Mask Color and Opacity controls (same line)
// 4. Divider (separates Mask Visualization from Image Output)
// 5. Image Output Settings title
// 6. Feather amount (own line)
// 7. Background colour (own line)
// 8. Divider
// 9. Output Frame Range title
// 10. Start/End frame controls
settingsSection.appendChild(settingsDivider);
settingsSection.appendChild(maskVisualizationTitle);
settingsSection.appendChild(maskControlsRow);
settingsSection.appendChild(maskImageDivider);
settingsSection.appendChild(imageOutputTitle);
settingsSection.appendChild(featherContainer);
settingsSection.appendChild(bgColorContainer);
settingsSection.appendChild(frameRangeDivider);
settingsSection.appendChild(frameRangeTitle);
settingsSection.appendChild(frameControlsRow);
// Load frames button + file input
const loadFramesBtn = document.createElement("button");
loadFramesBtn.textContent = "Load Frames";
loadFramesBtn.className = "comfy-button";
loadFramesBtn.type = "button";
loadFramesBtn.style.width = "100%";
const framesInput = document.createElement("input");
framesInput.type = "file";
framesInput.accept = "image/*";
framesInput.multiple = true;
framesInput.style.display = "none";
loadFramesBtn.onclick = () => framesInput.click();
loadFramesSection.appendChild(loadFramesBtn);
loadFramesSection.appendChild(framesInput);
framesInput.onchange = async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
try {
state.frameImages = [];
state.frameImageCache.clear();
for (const file of files) {
const data = await uploadImageFile(file);
const filename = data.name;
const subfolder = data.subfolder || "";
const url = `/view?filename=${filename}&type=input&subfolder=${subfolder}&t=${Date.now()}`;
state.frameImages.push({ filename, subfolder, url });
}
if (state.frameImages.length > 0) {
const first = await callbacks.getFrameImageForIndex(0);
if (first) {
callbacks.resizeCanvasToImage(first);
}
}
frameSlider.max = String(Math.max(1, state.frameImages.length));
// Auto-set frame_count widget to match loaded frames
const widgets = node.widgets || [];
const frameCountWidget = widgets.find(w => w.name === "frame_count");
if (frameCountWidget) {
frameCountWidget.value = state.frameImages.length;
if (typeof frameCountWidget.callback === "function") {
frameCountWidget.callback(state.frameImages.length);
}
}
// Update the frame output controls (Start Frame = 1, End Frame = total frames)
startFrameInput.value = "1";
endFrameInput.value = String(state.frameImages.length);
updateFrameLabel(); // Update frame counter display
callbacks.setFrame(1);
callbacks.redraw();
callbacks.updateNodeInput(); // Store frame_paths in metadata
} catch (error) {
console.error("[ComfyUI Roto] Failed to load frames:", error);
} finally {
framesInput.value = "";
}
};
// Section 2: Tool Buttons (Draw, Edit, Add Point)
const toolButtonsSection = document.createElement("div");
toolButtonsSection.style.display = "flex";
toolButtonsSection.style.gap = "8px";
toolButtonsSection.style.width = "100%";
// Mode buttons
const drawBtn = document.createElement("button");
drawBtn.textContent = "Draw (B)";
drawBtn.className = "comfy-button";
drawBtn.type = "button";
drawBtn.style.flex = "1 1 auto";
drawBtn.onclick = () => {
try {
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.setMode("draw");
} catch (error) {
console.error("[ComfyUI Roto] Error switching to draw mode:", error);
}
};
const editBtn = document.createElement("button");
editBtn.textContent = "Edit (V)";
editBtn.className = "comfy-button";
editBtn.type = "button";
editBtn.style.flex = "1 1 auto";
editBtn.onclick = () => {
try {
callbacks.setMode("edit");
} catch (error) {
console.error("[ComfyUI Roto] Error switching to edit mode:", error);
}
};
// Add Point button
const addPointBtn = document.createElement("button");
addPointBtn.textContent = "Add Point";
addPointBtn.className = "comfy-button";
addPointBtn.type = "button";
addPointBtn.disabled = true;
addPointBtn.style.flex = "1 1 auto";
function updateAddPointButton() {
// Enable/disable based on curve selection
addPointBtn.disabled = state.selectedCurves.size === 0;
if (state.addPointMode) {
addPointBtn.textContent = "Click Edge...";
addPointBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
canvas.style.cursor = "crosshair";
} else {
addPointBtn.textContent = "Add Point";
addPointBtn.style.backgroundColor = "";
canvas.style.cursor = state.mode === "draw" ? "crosshair" : "default";
}
}
addPointBtn.onclick = () => {
if (state.selectedCurves.size === 0) return;
// Toggle add point mode - user must click on edge to add point
state.addPointMode = !state.addPointMode;
updateAddPointButton();
};
// Toggle Smooth button
const toggleSmoothBtn = document.createElement("button");
toggleSmoothBtn.textContent = "Soften Point";
toggleSmoothBtn.className = "comfy-button";
toggleSmoothBtn.type = "button";
toggleSmoothBtn.disabled = true;
toggleSmoothBtn.style.flex = "1 1 auto";
function updateToggleSmoothButton() {
// Button enabled when curves are selected
const hasCurveSelection = state.selectedCurves.size > 0;
const hasPointSelection = state.selectedPoints.size > 0;
toggleSmoothBtn.disabled = !hasCurveSelection || !hasPointSelection;
if (!hasCurveSelection) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
if (!hasPointSelection) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
// Check state of selected points within selected curves
const shape = callbacks.getActiveShape ? callbacks.getActiveShape() : null;
if (!shape) {
toggleSmoothBtn.textContent = "Soften Point";
return;
}
const frameKey = String(state.currentFrame);
const curves = shape.frames && shape.frames[frameKey]
? shape.frames[frameKey]
: (callbacks.evaluateFrame ? callbacks.evaluateFrame(shape, state.currentFrame, { hold: true }) : []);
let allHard = true;
let allSmooth = true;
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
// Only check points in selected curves
if (!state.selectedCurves.has(curveId)) {
return;
}
const curve = curves.find(c => c.id === curveId);
if (!curve) {
// Curve doesn't exist - skip (selection might be stale)
return;
}
// Get pointTypes with validation
let types;
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
types = curve.pointTypes;
} else {
types = curve.points.map(() => "hard");
}
const idx = parseInt(pointIndex);
if (idx >= 0 && idx < curve.points.length && idx < types.length) {
const type = types[idx];
// Validate type is valid
if (type === "smooth") {
allHard = false;
} else if (type === "hard") {
allSmooth = false;
}
// If invalid type, treat as "hard" (defensive)
}
});
if (allHard) {
toggleSmoothBtn.textContent = "Soften Point";
} else if (allSmooth) {
toggleSmoothBtn.textContent = "Sharpen Point";
} else {
toggleSmoothBtn.textContent = "Soften Point"; // Mixed: default to softening
}
}
toggleSmoothBtn.onclick = () => {
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
if (callbacks.togglePointSmoothness) {
callbacks.togglePointSmoothness();
}
};
toolButtonsSection.appendChild(drawBtn);
toolButtonsSection.appendChild(editBtn);
toolButtonsSection.appendChild(addPointBtn);
toolButtonsSection.appendChild(toggleSmoothBtn);
// Section 3: Timeline (slider + play button)
const timelineSection = document.createElement("div");
timelineSection.style.display = "flex";
timelineSection.style.gap = "8px";
timelineSection.style.alignItems = "center";
timelineSection.style.width = "100%";
timelineSection.style.padding = "8px 0";
timelineSection.style.borderTop = "1px solid var(--border-color)";
timelineSection.style.borderBottom = "1px solid var(--border-color)";
timelineSection.style.margin = "4px 0";
// Frame counter display
const frameCounter = document.createElement("div");
frameCounter.style.display = "flex";
frameCounter.style.alignItems = "center";
frameCounter.style.gap = "4px";
frameCounter.style.minWidth = "80px";
frameCounter.style.fontSize = "12px";
frameCounter.style.fontWeight = "500";
frameCounter.style.color = "var(--fg-color)";
const frameNumber = document.createElement("span");
frameNumber.textContent = "1";
frameNumber.style.minWidth = "30px";
frameNumber.style.textAlign = "right";
const frameSeparator = document.createElement("span");
frameSeparator.textContent = "/";
frameSeparator.style.opacity = "0.5";
const frameMax = document.createElement("span");
frameMax.textContent = "1";
frameMax.style.minWidth = "30px";
frameMax.style.textAlign = "left";
frameMax.style.opacity = "0.7";
frameCounter.appendChild(frameNumber);
frameCounter.appendChild(frameSeparator);
frameCounter.appendChild(frameMax);
// Divider line
const divider1 = document.createElement("div");
divider1.style.width = "1px";
divider1.style.height = "24px";
divider1.style.backgroundColor = "var(--border-color)";
divider1.style.opacity = "0.5";
const frameSlider = document.createElement("input");
frameSlider.type = "range";
frameSlider.min = "1";
frameSlider.max = "1";
frameSlider.step = "1";
frameSlider.value = "1";
frameSlider.style.flex = "1 1 auto";
frameSlider.style.height = "6px";
frameSlider.style.cursor = "pointer";
// Divider line
const divider2 = document.createElement("div");
divider2.style.width = "1px";
divider2.style.height = "24px";
divider2.style.backgroundColor = "var(--border-color)";
divider2.style.opacity = "0.5";
const playBtn = document.createElement("button");
playBtn.textContent = "▶";
playBtn.className = "comfy-button";
playBtn.type = "button";
playBtn.style.minWidth = "40px";
playBtn.style.width = "40px";
playBtn.style.padding = "4px 8px";
playBtn.style.fontSize = "14px";
let playing = false;
let lastTick = 0;
const fps = 24;
function updateFrameLabel() {
frameNumber.textContent = String(state.currentFrame);
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
frameMax.textContent = String(maxFrame);
}
function safeWidgetCallback(widget, value) {
if (widget && typeof widget.callback === "function") {
try {
widget.callback.call(widget, value, null, node);
} catch (error) {
console.warn("[ComfyUI Roto] Frame widget callback failed:", error);
}
}
}
function setFrame(frameIndex) {
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
const nextFrame = Math.max(1, Math.min(maxFrame, frameIndex));
if (nextFrame === state.currentFrame) return;
state.currentFrame = nextFrame;
frameSlider.value = String(nextFrame);
updateFrameLabel();
callbacks.redraw();
const widgets = node.widgets || [];
const frameWidget = widgets.find(w => w.name === "frame_index");
if (frameWidget) {
frameWidget.value = nextFrame;
if (!state.updatingFrame) {
state.updatingFrame = true;
safeWidgetCallback(frameWidget, nextFrame);
state.updatingFrame = false;
}
}
}
function tick(ts) {
if (!playing) return;
if (!lastTick) lastTick = ts;
const interval = 1000 / fps;
if (ts - lastTick >= interval) {
lastTick = ts;
const maxFrame = Number(frameSlider.max || 1);
const next = (state.currentFrame + 1) > maxFrame ? 1 : state.currentFrame + 1;
setFrame(next);
}
requestAnimationFrame(tick);
}
playBtn.onclick = () => {
playing = !playing;
playBtn.textContent = playing ? "⏸" : "▶";
if (playing) {
lastTick = 0;
requestAnimationFrame(tick);
}
};
frameSlider.oninput = (e) => {
const value = Number(e.target.value || 0);
setFrame(value);
};
// Assemble timeline section
timelineSection.appendChild(frameCounter);
timelineSection.appendChild(divider1);
timelineSection.appendChild(frameSlider);
timelineSection.appendChild(divider2);
timelineSection.appendChild(playBtn);
return {
// Sections for layout
loadFramesSection,
toolButtonsSection,
timelineSection,
settingsSection,
// Individual controls (for backward compatibility)
featherSlider,
featherValueLabel,
bgColorInput,
maskColorInput,
maskOpacitySlider,
maskOpacityValueLabel,
frameSlider,
addPointBtn,
toggleSmoothBtn,
drawBtn,
editBtn,
playBtn,
// Frame output controls
startFrameInput,
endFrameInput,
// Functions
updateFrameLabel,
updateAddPointButton,
updateToggleSmoothButton,
updateFrameRangeInfo,
setFrame
};
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Curve management functions
*/
import { generateUUID } from "../utils/uuid.js";
import { deepCopy } from "../utils/helpers.js";
import { evaluateFrame } from "./frameEvaluator.js";
/**
* Get the next available shape number
* @param {Object} state - Editor state
* @returns {number} Next available shape number
*/
function getNextShapeNumber(state) {
const existingNumbers = new Set();
// Extract numbers from existing shape names
Object.values(state.shapes || {}).forEach(shape => {
if (shape && shape.name) {
const match = shape.name.match(/^Shape\s+(\d+)$/);
if (match) {
existingNumbers.add(parseInt(match[1], 10));
}
}
});
// Find the next available number
let nextNumber = 1;
while (existingNumbers.has(nextNumber)) {
nextNumber++;
}
return nextNumber;
}
/**
* Initialize default shape in state
* @param {Object} state - Editor state
*/
export function initializeDefaultShape(state) {
const shapeId = generateUUID();
const shapeNumber = getNextShapeNumber(state);
state.activeShapeId = shapeId;
state.shapes[shapeId] = {
name: `Shape ${shapeNumber}`,
frames: {}
};
}
/**
* Get the active shape, initializing if needed
* @param {Object} state - Editor state
* @returns {Object} Active shape object
*/
export function getActiveShape(state) {
if (!state.activeShapeId || !state.shapes[state.activeShapeId]) {
initializeDefaultShape(state);
}
return state.shapes[state.activeShapeId];
}
/**
* Get curves for a specific frame
* @param {Object} state - Editor state
* @param {number} frame - Frame number
* @returns {Array} Curves for the frame
*/
export function getCurvesForFrame(state, frame) {
const shape = getActiveShape(state);
const frameKey = String(frame);
return shape.frames[frameKey] || [];
}
/**
* Promote a specific curve to the current frame as a keyframe.
* If curveId is provided, only that curve is promoted.
* If not provided, only interpolated curves are promoted (not held ones).
*
* @param {Object} state - Editor state
* @param {Function} evaluateFrameFn - Function to evaluate frame (to avoid circular dependency)
* @param {Function} deepCopyFn - Deep copy function
* @param {string|null} curveId - Optional curve ID to promote
*/
export function promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId = null) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// If already a keyframe, check if we need to add a specific curve
if (shape.frames[frameKey]) {
if (curveId) {
// Check if this curve already exists at this keyframe
const exists = shape.frames[frameKey].some(c => c.id === curveId);
if (!exists) {
// Find this curve from evaluated frame and add it
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const curve = allCurves.find(c => c.id === curveId);
if (curve) {
shape.frames[frameKey].push(deepCopyFn(curve));
}
}
}
return;
}
// Frame doesn't exist yet - create it
if (curveId) {
// Only promote the specific curve being edited
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const curve = allCurves.find(c => c.id === curveId);
shape.frames[frameKey] = curve ? [deepCopyFn(curve)] : [];
} else {
// Get interpolated curves only (not held curves)
const curves = evaluateFrameFn(shape, state.currentFrame, { hold: false });
shape.frames[frameKey] = deepCopyFn(curves);
}
}
/**
* Add a point to a curve at a specific edge.
* IMPORTANT: Adds point to ALL keyframes to keep point counts in sync.
*
* @param {Object} state - Editor state
* @param {string} curveId - Curve ID
* @param {number} edgeIndex - Index of the edge (point index)
* @param {{x: number, y: number}|null} position - Optional position to add point at
* @param {Function} promoteToKeyframeFn - Function to promote to keyframe
* @param {Function} evaluateFrameFn - Function to evaluate frame
* @param {Function} deepCopyFn - Deep copy function
* @param {Function} commitFrameFn - Function to commit frame
* @param {Function} redrawFn - Function to redraw
* @param {Function} updateAddPointButtonFn - Function to update add point button
*/
export function addPointToCurveAtEdge(
state,
curveId,
edgeIndex,
position,
promoteToKeyframeFn,
evaluateFrameFn,
deepCopyFn,
commitFrameFn,
redrawFn,
updateAddPointButtonFn
) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// First, ensure current frame has this curve as a keyframe
promoteToKeyframeFn(state, evaluateFrameFn, deepCopyFn, curveId);
// Get the actual keyframe curve (not interpolated) - refresh after promotion
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
const curves = shape.frames[frameKey];
let curve = curves.find(c => c.id === curveId);
// If curve still doesn't exist, something went wrong - try to get from evaluated
if (!curve) {
const evaluatedCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
const evaluatedCurve = evaluatedCurves.find(c => c.id === curveId);
if (evaluatedCurve) {
// Add it to keyframe
curve = deepCopyFn(evaluatedCurve);
curves.push(curve);
}
}
if (!curve || curve.points.length < 2) return;
// Calculate t parameter (0-1) for where on the edge the point is being added
const p1 = curve.points[edgeIndex];
const p2 = curve.points[(edgeIndex + 1) % curve.points.length];
let t = 0.5; // default to midpoint
if (position) {
// Calculate t based on position along the edge
const edgeLength = Math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2);
if (edgeLength > 0) {
const posLength = Math.sqrt((position.x - p1[0]) ** 2 + (position.y - p1[1]) ** 2);
t = Math.max(0, Math.min(1, posLength / edgeLength));
}
}
// Add point to ALL keyframes that contain this curve
Object.keys(shape.frames).forEach(fKey => {
const frameCurves = shape.frames[fKey] || [];
const frameCurve = frameCurves.find(c => c.id === curveId);
if (frameCurve && frameCurve.points.length > edgeIndex) {
const fp1 = frameCurve.points[edgeIndex];
const fp2 = frameCurve.points[(edgeIndex + 1) % frameCurve.points.length];
// Calculate new point position using same t parameter
const newX = fp1[0] + t * (fp2[0] - fp1[0]);
const newY = fp1[1] + t * (fp2[1] - fp1[1]);
// Insert new point after edgeIndex
frameCurve.points.splice(edgeIndex + 1, 0, [newX, newY]);
// Also insert "hard" into pointTypes array at same index
if (!Array.isArray(frameCurve.pointTypes) || frameCurve.pointTypes.length !== frameCurve.points.length - 1) {
// Reinitialize if missing or wrong length (before adding new point)
frameCurve.pointTypes = frameCurve.points.slice(0, -1).map(() => "hard");
}
frameCurve.pointTypes.splice(edgeIndex + 1, 0, "hard");
}
});
// Exit add point mode
state.addPointMode = false;
if (updateAddPointButtonFn) {
updateAddPointButtonFn();
}
if (commitFrameFn) {
commitFrameFn();
}
if (redrawFn) {
redrawFn();
}
}
/**
* Commit current frame (ensure frame exists in shape)
* @param {Object} state - Editor state
* @param {Function} updateNodeInputFn - Function to update node input
*/
export function commitFrame(state, updateNodeInputFn) {
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
if (updateNodeInputFn) {
updateNodeInputFn();
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Frame evaluation and interpolation logic
*/
import { deepCopy } from "../utils/helpers.js";
/**
* Get interpolated pointTypes array from two curves.
* Since pointTypes should be the same across all keyframes, we prefer curveA's types.
* @param {Object} curveA - First curve (earlier keyframe)
* @param {Object} curveB - Second curve (later keyframe)
* @param {number} totalPoints - Total point count in the interpolated result
* @returns {Array<string>} Array of point types
*/
function getInterpolatedPointTypes(curveA, curveB, totalPoints) {
// Since pointTypes should be consistent across keyframes, prefer curveA
// but fall back to curveB if curveA doesn't have valid types
const typesA = Array.isArray(curveA.pointTypes) ? curveA.pointTypes : [];
const typesB = Array.isArray(curveB.pointTypes) ? curveB.pointTypes : [];
const result = [];
for (let i = 0; i < totalPoints; i++) {
if (i < typesA.length && (typesA[i] === "smooth" || typesA[i] === "hard")) {
result.push(typesA[i]);
} else if (i < typesB.length && (typesB[i] === "smooth" || typesB[i] === "hard")) {
result.push(typesB[i]);
} else {
result.push("hard"); // Default for any missing entries
}
}
return result;
}
/**
* Evaluate curves for a given frame.
* Each curve is interpolated independently based on its own keyframes.
*
* @param {Object} shape - Shape object with frames
* @param {number} frameIndex - Frame number to evaluate
* @param {Object} options - Evaluation options
* @param {boolean} options.hold - If true, hold curves from nearest keyframe when no interpolation possible
* @returns {Array} List of curve objects for this frame
*/
export function evaluateFrame(shape, frameIndex, options = {}) {
const frames = shape.frames || {};
const frameKey = String(frameIndex);
const hold = Boolean(options.hold);
// Collect all unique curve IDs across all keyframes
const allCurveIds = new Set();
const curveKeyframes = new Map(); // curveId -> sorted array of frame numbers where it exists
Object.entries(frames).forEach(([frame, curves]) => {
const frameNum = parseInt(frame);
if (isNaN(frameNum)) return;
(curves || []).forEach(c => {
if (c && c.id) {
allCurveIds.add(c.id);
if (!curveKeyframes.has(c.id)) {
curveKeyframes.set(c.id, []);
}
curveKeyframes.get(c.id).push(frameNum);
}
});
});
// Sort keyframes for each curve
curveKeyframes.forEach((frames, id) => {
frames.sort((a, b) => a - b);
});
if (allCurveIds.size === 0) {
return [];
}
const result = [];
// Evaluate each curve independently based on its own keyframes
for (const curveId of allCurveIds) {
const keyframes = curveKeyframes.get(curveId) || [];
if (keyframes.length === 0) continue;
// Check for exact keyframe for this curve
if (keyframes.includes(frameIndex)) {
const frameCurves = frames[frameKey] || [];
const curve = frameCurves.find(c => c.id === curveId);
if (curve) {
result.push(deepCopy(curve));
}
continue;
}
// Find prev and next keyframes FOR THIS CURVE
let prevFrame = null;
let nextFrame = null;
for (const fn of keyframes) {
if (fn < frameIndex) {
prevFrame = fn;
} else if (fn > frameIndex) {
nextFrame = fn;
break;
}
}
// Interpolate or hold based on this curve's keyframes
if (prevFrame !== null && nextFrame !== null) {
// Interpolate between this curve's keyframes
const curveA = (frames[String(prevFrame)] || []).find(c => c.id === curveId);
const curveB = (frames[String(nextFrame)] || []).find(c => c.id === curveId);
if (curveA && curveB) {
const t = (frameIndex - prevFrame) / (nextFrame - prevFrame);
const interpolatedPoints = [];
// Handle different point counts - use the smaller count
const minPoints = Math.min(curveA.points.length, curveB.points.length);
for (let i = 0; i < minPoints; i++) {
const pa = curveA.points[i];
const pb = curveB.points[i];
const x = pa[0] + (pb[0] - pa[0]) * t;
const y = pa[1] + (pb[1] - pa[1]) * t;
interpolatedPoints.push([x, y]);
}
// If one curve has more points, add them at the end (hold from that curve)
if (curveA.points.length > minPoints) {
for (let i = minPoints; i < curveA.points.length; i++) {
interpolatedPoints.push([...curveA.points[i]]);
}
} else if (curveB.points.length > minPoints) {
for (let i = minPoints; i < curveB.points.length; i++) {
interpolatedPoints.push([...curveB.points[i]]);
}
}
result.push({
id: curveA.id,
closed: curveA.closed,
points: interpolatedPoints,
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
});
}
} else if (hold) {
// Hold from nearest keyframe for this curve
const holdFrame = prevFrame !== null ? prevFrame : nextFrame;
if (holdFrame !== null) {
const curve = (frames[String(holdFrame)] || []).find(c => c.id === curveId);
if (curve) {
result.push(deepCopy(curve));
}
}
}
}
return result;
}
/**
* Interpolate curves between two keyframes.
* Curves that only exist in one keyframe are held (included as-is).
*
* @param {Array} curvesA - Curves at earlier keyframe
* @param {Array} curvesB - Curves at later keyframe
* @param {number} frameA - Earlier frame number
* @param {number} frameB - Later frame number
* @param {number} targetFrame - Frame to interpolate to
* @returns {Array} List of interpolated curves
*/
export function interpolateCurves(curvesA, curvesB, frameA, frameB, targetFrame) {
const t = (targetFrame - frameA) / (frameB - frameA);
const result = [];
const processedIds = new Set();
// Process curves from A - interpolate if in both, hold if only in A
for (const curveA of curvesA) {
processedIds.add(curveA.id);
const curveB = curvesB.find(cb => cb.id === curveA.id);
if (curveB) {
// Curve exists in both keyframes - interpolate
const interpolatedPoints = [];
// Handle different point counts - use the smaller count
const minPoints = Math.min(curveA.points.length, curveB.points.length);
for (let i = 0; i < minPoints; i++) {
const pa = curveA.points[i];
const pb = curveB.points[i];
const x = pa[0] + (pb[0] - pa[0]) * t;
const y = pa[1] + (pb[1] - pa[1]) * t;
interpolatedPoints.push([x, y]);
}
// If one curve has more points, add them at the end (hold from that curve)
if (curveA.points.length > minPoints) {
for (let i = minPoints; i < curveA.points.length; i++) {
interpolatedPoints.push([...curveA.points[i]]);
}
} else if (curveB.points.length > minPoints) {
for (let i = minPoints; i < curveB.points.length; i++) {
interpolatedPoints.push([...curveB.points[i]]);
}
}
result.push({
id: curveA.id,
closed: curveA.closed,
points: interpolatedPoints,
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
});
} else {
// Curve only in A - hold it (use A's position)
result.push(deepCopy(curveA));
}
}
// Process curves only in B (not in A) - hold them
for (const curveB of curvesB) {
if (!processedIds.has(curveB.id)) {
result.push(deepCopy(curveB));
}
}
return result;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* State management for the roto editor
*/
import { generateUUID } from "../utils/uuid.js";
/**
* Initialize the editor state
* @returns {Object} Initial state object
*/
export function createState() {
const shapeId = generateUUID();
return {
currentFrame: 1,
activeShapeId: shapeId,
shapes: {
[shapeId]: {
name: "Shape 1",
frames: {}
}
},
selectedCurves: new Set(),
selectedPoints: new Set(), // Format: "curveId:pointIndex"
dragging: false,
dragStart: null,
mode: "draw", // "draw" or "edit"
addPointMode: false, // true when user clicked "Add Point" button and is ready to click on edge
canvasWidth: 512,
canvasHeight: 512,
previewImage: null,
previewSourceId: null,
frameImages: [],
frameImageCache: new Map(),
updatingFrame: false,
featherAmount: 0.0,
backgroundColor: "#000000",
maskColor: "#ffffff",
maskOpacity: 10, // 0-100 integer
selectionBox: null // { start: {x, y}, end: {x, y}, shiftKey: boolean } or null
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Document-level event listeners for dragging outside canvas
*/
/**
* Create document listener management functions
* @param {Object} state - Editor state
* @param {Function} handleDragMoveFn - Function to handle drag move
* @returns {Object} Functions to attach/remove document listeners
*/
export function createDocumentListeners(state, handleDragMoveFn) {
let documentMouseMoveHandler = null;
let documentMouseUpHandler = null;
function attachDocumentListeners() {
if (documentMouseMoveHandler || documentMouseUpHandler) {
// Already attached, skip
return;
}
documentMouseMoveHandler = (e) => {
handleDragMoveFn(e);
};
documentMouseUpHandler = (e) => {
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
removeDocumentListeners();
}
};
document.addEventListener("mousemove", documentMouseMoveHandler);
document.addEventListener("mouseup", documentMouseUpHandler);
}
function removeDocumentListeners() {
if (documentMouseMoveHandler) {
document.removeEventListener("mousemove", documentMouseMoveHandler);
documentMouseMoveHandler = null;
}
if (documentMouseUpHandler) {
document.removeEventListener("mouseup", documentMouseUpHandler);
documentMouseUpHandler = null;
}
}
return {
attachDocumentListeners,
removeDocumentListeners
};
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Keyboard event handlers
*/
/**
* Create keyboard event handler
* @param {HTMLElement} container - Container element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.setMode - Set editor mode
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
* @param {Function} callbacks.commitFrame - Commit frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
* @param {Function} callbacks.updateAddPointButton - Update add point button
*/
export function createKeyboardHandlers(container, state, callbacks) {
function handleKeydown(e) {
// Only handle if not typing in an input
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
return;
}
if (e.key === "b" || e.key === "B") {
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.setMode("draw");
e.preventDefault();
} else if (e.key === "v" || e.key === "V") {
callbacks.setMode("edit");
e.preventDefault();
} else if (e.key === "Delete" || e.key === "Backspace") {
if (state.mode === "edit" && state.selectedCurves.size > 0) {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Promote only the selected curves to be deleted
state.selectedCurves.forEach(curveId => {
callbacks.promoteToKeyframe(curveId);
});
const curves = shape.frames[frameKey] || [];
state.selectedCurves.forEach(curveId => {
const index = curves.findIndex(c => c.id === curveId);
if (index !== -1) {
curves.splice(index, 1);
}
});
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.commitFrame();
callbacks.redraw();
e.preventDefault();
}
} else if (e.key === "Escape") {
// Cancel selection box if active
if (state.selectionBox !== null) {
state.selectionBox = null;
}
// Cancel add point mode if active
if (state.addPointMode) {
state.addPointMode = false;
callbacks.updateAddPointButton();
}
// Cancel dragging if active
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
state.selectedCurves.clear();
state.selectedPoints.clear();
callbacks.redraw();
e.preventDefault();
}
}
// Keyboard shortcuts (scoped to container, with document fallback)
container.addEventListener("keydown", handleKeydown);
document.addEventListener("keydown", handleKeydown);
}
+291
View File
@@ -0,0 +1,291 @@
/**
* Mouse event handlers for canvas
*/
import { getMousePos } from "../utils/canvas.js";
import { generateUUID } from "../utils/uuid.js";
import { evaluateFrame } from "../core/frameEvaluator.js";
import { pointToLineSegmentDistance } from "../renderer/drawCurve.js";
/**
* Create mouse event handlers for canvas
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {HTMLElement} container - Container element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
* @param {Function} callbacks.commitFrame - Commit frame
* @param {Function} callbacks.redraw - Redraw canvas
* @param {Function} callbacks.addPointToCurveAtEdge - Add point to curve at edge
* @param {Function} callbacks.updateAddPointButton - Update add point button
* @param {Function} callbacks.attachDocumentListeners - Attach document listeners
* @returns {Function} Function to handle drag move
*/
export function createMouseHandlers(canvas, container, state, callbacks) {
// Shared function to handle dragging
function handleDragMove(e) {
if (!state.dragging || state.mode !== "edit") return;
const pos = getMousePos(e, canvas);
const deltaX = pos.x - state.dragStart.x;
const deltaY = pos.y - state.dragStart.y;
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Promote only the curves being edited
state.selectedPoints.forEach(pointKey => {
const [curveId] = pointKey.split(":");
callbacks.promoteToKeyframe(curveId);
});
const curves = shape.frames[frameKey] || [];
// Move selected points
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
const curve = curves.find(c => c.id === curveId);
if (curve && curve.points[parseInt(pointIndex)]) {
const point = curve.points[parseInt(pointIndex)];
point[0] = Math.max(0, Math.min(canvas.width, point[0] + deltaX));
point[1] = Math.max(0, Math.min(canvas.height, point[1] + deltaY));
}
});
state.dragStart = pos;
callbacks.commitFrame();
callbacks.redraw();
}
canvas.addEventListener("mousedown", (e) => {
// Ensure keyboard shortcuts work after interaction
container.focus();
const pos = getMousePos(e, canvas);
if (state.mode === "draw") {
// Create new curve - don't promote other curves, just ensure frame exists
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
if (!shape.frames[frameKey]) {
shape.frames[frameKey] = [];
}
// Create 4-point box (rectangle) centered on click
// Clamp to canvas bounds
const clampedX = Math.max(0, Math.min(canvas.width, pos.x));
const clampedY = Math.max(0, Math.min(canvas.height, pos.y));
const size = 50;
const curve = {
id: generateUUID(),
closed: true,
points: [
[Math.max(0, clampedX - size), Math.max(0, clampedY - size)], // top-left
[Math.min(canvas.width, clampedX + size), Math.max(0, clampedY - size)], // top-right
[Math.min(canvas.width, clampedX + size), Math.min(canvas.height, clampedY + size)], // bottom-right
[Math.max(0, clampedX - size), Math.min(canvas.height, clampedY + size)] // bottom-left
],
pointTypes: ["hard", "hard", "hard", "hard"] // Initialize all points as hard
};
shape.frames[frameKey].push(curve);
callbacks.commitFrame();
callbacks.redraw();
} else if (state.mode === "edit") {
const shape = callbacks.getActiveShape();
const frameKey = String(state.currentFrame);
// Check for point hit using EVALUATED curves (includes held curves)
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
let hitPoint = null;
let hitCurve = null;
for (const curve of evaluatedCurves) {
for (let i = 0; i < curve.points.length; i++) {
const point = curve.points[i];
const dx = pos.x - point[0];
const dy = pos.y - point[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10) {
hitPoint = { curve, index: i };
hitCurve = curve;
break;
}
}
if (hitPoint) break;
}
if (hitPoint) {
// Start dragging point
state.dragging = true;
state.dragStart = pos;
const pointKey = `${hitPoint.curve.id}:${hitPoint.index}`;
// Check if the clicked point is already selected
const isPointAlreadySelected = state.selectedPoints.has(pointKey);
if (!e.shiftKey && !isPointAlreadySelected) {
// Only clear selection if clicking a new point (not already selected)
state.selectedPoints.clear();
state.selectedCurves.clear();
}
// Add the point to selection (if not already there)
state.selectedPoints.add(pointKey);
state.selectedCurves.add(hitPoint.curve.id);
// Attach document listeners to allow dragging outside canvas
callbacks.attachDocumentListeners();
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
} else {
// Only check for edge hit if in add point mode
if (state.addPointMode) {
let hitEdge = null;
let minDist = Infinity;
const edgeHitThreshold = 15; // pixels - slightly larger for easier clicking
// Only check selected curves
const selectedCurveId = Array.from(state.selectedCurves)[0];
for (const curve of evaluatedCurves) {
if (curve.id !== selectedCurveId) continue;
for (let i = 0; i < curve.points.length; i++) {
const p1 = curve.points[i];
const p2 = curve.points[(i + 1) % curve.points.length];
// Calculate distance from mouse to line segment
const dist = pointToLineSegmentDistance(pos, p1, p2);
if (dist < edgeHitThreshold && dist < minDist) {
minDist = dist;
// Calculate closest point on line segment
const t = Math.max(0, Math.min(1,
((pos.x - p1[0]) * (p2[0] - p1[0]) + (pos.y - p1[1]) * (p2[1] - p1[1])) /
((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
));
const closestPoint = {
x: p1[0] + t * (p2[0] - p1[0]),
y: p1[1] + t * (p2[1] - p1[1])
};
hitEdge = {
curve: curve,
edgeIndex: i,
position: closestPoint
};
}
}
}
if (hitEdge) {
// Add point at clicked location on edge
if (callbacks.addPointToCurveAtEdge) {
callbacks.addPointToCurveAtEdge(hitEdge.curve.id, hitEdge.edgeIndex, hitEdge.position);
} else {
console.error("[ComfyUI Roto] addPointToCurveAtEdge callback not available");
}
} else {
// Clicked but missed the edge - cancel add point mode
state.addPointMode = false;
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
}
} else {
// Normal edit mode - start selection box if clicking empty space
// Clear selection box if it exists (shouldn't happen, but safety check)
if (state.selectionBox) {
state.selectionBox = null;
}
// Start selection box
state.selectionBox = {
start: pos,
end: pos,
shiftKey: e.shiftKey
};
// If Shift not held, clear existing selection
if (!e.shiftKey) {
state.selectedCurves.clear();
state.selectedPoints.clear();
}
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
}
}
}
});
canvas.addEventListener("mousemove", (e) => {
// Check for selection box first (before dragging)
if (state.selectionBox !== null && state.mode === "edit") {
const pos = getMousePos(e, canvas);
state.selectionBox.end = pos;
callbacks.redraw();
} else {
// Handle point dragging
handleDragMove(e);
}
});
canvas.addEventListener("mouseup", (e) => {
// Handle selection box completion
if (state.selectionBox !== null && state.mode === "edit") {
const shape = callbacks.getActiveShape();
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
// Calculate normalized box bounds
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
// Find all points within selection box
for (const curve of evaluatedCurves) {
for (let i = 0; i < curve.points.length; i++) {
const point = curve.points[i];
const x = point[0];
const y = point[1];
// Check if point center is within box bounds
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
const pointKey = `${curve.id}:${i}`;
state.selectedPoints.add(pointKey);
state.selectedCurves.add(curve.id);
}
}
}
// Clear selection box
state.selectionBox = null;
callbacks.redraw();
// Update toggle smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
} else if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
});
return handleDragMove;
}
+132
View File
@@ -0,0 +1,132 @@
/**
* Background image drawing functions
*/
import { loadImageAsync } from "../utils/fileUpload.js";
import { resizeCanvasToImage } from "../components/Canvas.js";
/**
* Resolve preview image from a ComfyUI node
* @param {Object} targetNode - ComfyUI node
* @returns {HTMLImageElement|null} Preview image or null
*/
export function resolveNodePreviewImage(targetNode) {
if (!targetNode) return null;
if (targetNode.imgs && targetNode.imgs.length > 0) return targetNode.imgs[0];
if (targetNode.images && targetNode.images.length > 0) return targetNode.images[0];
if (targetNode._bgImage) return targetNode._bgImage;
return null;
}
/**
* Get connected input node from ComfyUI graph
* @param {Object} node - Current node
* @param {Object} app - ComfyUI app instance
* @returns {Object|null} Connected input node or null
*/
export function getConnectedInputNode(node, app) {
const input = node.inputs && node.inputs[0];
if (!input || input.link == null) return null;
const link = app.graph && app.graph.links && app.graph.links[input.link];
if (!link) return null;
return app.graph.getNodeById(link.origin_id);
}
/**
* Get preview image from connected input node
* @param {Object} node - Current node
* @param {Object} app - ComfyUI app instance
* @returns {HTMLImageElement|null} Preview image or null
*/
export function getInputPreviewImage(node, app) {
const inputNode = getConnectedInputNode(node, app);
if (!inputNode) return null;
return resolveNodePreviewImage(inputNode);
}
/**
* Get frame image for a given index
* @param {Object} state - Editor state
* @param {number} index - Frame index (0-based)
* @returns {Promise<HTMLImageElement|null>} Loaded image or null
*/
export async function getFrameImageForIndex(state, index) {
if (!state.frameImages.length) return null;
const safeIndex = Math.max(0, Math.min(state.frameImages.length - 1, index));
const entry = state.frameImages[safeIndex];
if (!entry) return null;
if (state.frameImageCache.has(entry.url)) {
return state.frameImageCache.get(entry.url);
}
const img = await loadImageAsync(entry.url);
state.frameImageCache.set(entry.url, img);
return img;
}
/**
* Draw background image on canvas
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {Object} node - ComfyUI node
* @param {Object} app - ComfyUI app instance
* @param {Function} resizeCanvasToImageFn - Function to resize canvas
* @param {Function} redrawFn - Function to trigger redraw
*/
export function drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, redrawFn) {
try {
// If user loaded frame images, use those first
if (state.frameImages.length > 0) {
// Convert 1-based frame number to 0-based array index
const index = (state.currentFrame - 1) % state.frameImages.length;
const cached = state.frameImages[index];
if (cached && state.frameImageCache.has(cached.url)) {
const img = state.frameImageCache.get(cached.url);
resizeCanvasToImageFn(canvas, state, img);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return;
} else {
// Kick off async load; will be drawn on next redraw
getFrameImageForIndex(state, index).then(() => {
if (redrawFn) redrawFn();
});
return;
}
}
// Preferred: use upstream node preview image (ComfyUI uses imgs/images)
const preview = getInputPreviewImage(node, app);
if (preview) {
// Cache preview to avoid repeated lookups
if (preview !== state.previewImage) {
state.previewImage = preview;
}
resizeCanvasToImageFn(canvas, state, state.previewImage);
ctx.drawImage(state.previewImage, 0, 0, canvas.width, canvas.height);
return;
}
// Fallback: attempt to use raw input data if available
if (node && typeof node.getInputData === "function") {
const input = node.getInputData(0);
if (!input) return;
if (input instanceof HTMLImageElement || input instanceof HTMLCanvasElement || input instanceof ImageBitmap) {
ctx.drawImage(input, 0, 0, canvas.width, canvas.height);
return;
}
if (input.data && input.width && input.height) {
const imageData = new ImageData(new Uint8ClampedArray(input.data), input.width, input.height);
const tempCanvas = document.createElement("canvas");
tempCanvas.width = input.width;
tempCanvas.height = input.height;
const tempCtx = tempCanvas.getContext("2d");
tempCtx.putImageData(imageData, 0, 0);
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);
}
}
} catch (error) {
console.warn("[ComfyUI Roto] Unable to render background image:", error);
}
}
+224
View File
@@ -0,0 +1,224 @@
/**
* Curve drawing functions
*/
import { hexToRgb } from "../utils/helpers.js";
/**
* Calculate distance from point to line segment
* @param {{x: number, y: number}} point - Point coordinates
* @param {Array<number>} lineStart - Line start point [x, y]
* @param {Array<number>} lineEnd - Line end point [x, y]
* @returns {number} Distance from point to line segment
*/
export function pointToLineSegmentDistance(point, lineStart, lineEnd) {
const A = point.x - lineStart[0];
const B = point.y - lineStart[1];
const C = lineEnd[0] - lineStart[0];
const D = lineEnd[1] - lineStart[1];
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = lineStart[0];
yy = lineStart[1];
} else if (param > 1) {
xx = lineEnd[0];
yy = lineEnd[1];
} else {
xx = lineStart[0] + param * C;
yy = lineStart[1] + param * D;
}
const dx = point.x - xx;
const dy = point.y - yy;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Draw control points for a curve
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {Object} curve - Curve object
* @param {Object} state - Editor state
* @param {boolean} isSelected - Whether curve is selected
*/
export function drawPoints(ctx, curve, state, isSelected = false) {
const pointRadius = 5;
const selectedRadius = 7;
curve.points.forEach((point, index) => {
const pointKey = `${curve.id}:${index}`;
const isPointSelected = state.selectedPoints.has(pointKey);
ctx.beginPath();
ctx.arc(point[0], point[1], isPointSelected ? selectedRadius : pointRadius, 0, Math.PI * 2);
if (isPointSelected || isSelected) {
ctx.fillStyle = "#4a9eff";
ctx.fill();
} else {
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = "#000000";
ctx.lineWidth = 1;
ctx.stroke();
}
// Add stroke outline only for specifically selected points (not just selected curves)
if (isPointSelected) {
ctx.beginPath();
ctx.arc(point[0], point[1], selectedRadius, 0, Math.PI * 2);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
/**
* Draw a single curve (polygon)
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {Object} curve - Curve object
* @param {Object} state - Editor state
* @param {boolean} isSelected - Whether curve is selected
*/
export function drawCurve(ctx, curve, state, isSelected = false) {
if (!curve || !curve.points || curve.points.length < 2) return;
ctx.save();
// Get pointTypes with validation (default to all "hard" if missing or invalid)
let pointTypes;
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
pointTypes = curve.pointTypes;
} else {
pointTypes = curve.points.map(() => "hard");
}
// Draw polygon path with smooth curves where needed using Catmull-Rom style interpolation
ctx.beginPath();
const n = curve.points.length;
const points = curve.points;
// Helper to get point with wrap-around for closed curves
const getPoint = (index) => {
if (curve.closed) {
return points[((index % n) + n) % n];
} else {
if (index < 0) return points[0];
if (index >= n) return points[n - 1];
return points[index];
}
};
// Helper to get point type with wrap-around
const getType = (index) => {
if (curve.closed) {
return pointTypes[((index % n) + n) % n];
} else {
if (index < 0) return pointTypes[0];
if (index >= n) return pointTypes[n - 1];
return pointTypes[index];
}
};
// Start at first point
ctx.moveTo(points[0][0], points[0][1]);
// For each segment
const segmentCount = curve.closed ? n : n - 1;
for (let i = 0; i < segmentCount; i++) {
const nextI = (i + 1) % n;
const p0 = points[i];
const p1 = points[nextI];
const type0 = getType(i);
const type1 = getType(nextI);
// If both points are hard, use straight line
if (type0 === "hard" && type1 === "hard") {
ctx.lineTo(p1[0], p1[1]);
} else {
// At least one point is smooth - use cubic curve
// Calculate control points based on neighboring points (Catmull-Rom style)
const prevP = getPoint(i - 1);
const nextNextP = getPoint(i + 2);
// Tension factor (0.5 is Catmull-Rom default)
const tension = 0.5;
// Calculate control point 1 (based on p0's tangent if smooth)
let cp1x, cp1y;
if (type0 === "smooth") {
// Tangent at p0 based on prevP to p1
cp1x = p0[0] + (p1[0] - prevP[0]) * tension / 2;
cp1y = p0[1] + (p1[1] - prevP[1]) * tension / 2;
} else {
// Hard point - control point is closer to p0
cp1x = p0[0] + (p1[0] - p0[0]) * 0.25;
cp1y = p0[1] + (p1[1] - p0[1]) * 0.25;
}
// Calculate control point 2 (based on p1's tangent if smooth)
let cp2x, cp2y;
if (type1 === "smooth") {
// Tangent at p1 based on p0 to nextNextP
cp2x = p1[0] - (nextNextP[0] - p0[0]) * tension / 2;
cp2y = p1[1] - (nextNextP[1] - p0[1]) * tension / 2;
} else {
// Hard point - control point is closer to p1
cp2x = p1[0] - (p1[0] - p0[0]) * 0.25;
cp2y = p1[1] - (p1[1] - p0[1]) * 0.25;
}
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1[0], p1[1]);
}
}
if (curve.closed) {
ctx.closePath();
}
// Fill with semi-transparent overlay
if (curve.closed) {
// Convert maskOpacity from 0-100 to 0-1 alpha
const alpha = (state.maskOpacity || 10) / 100;
if (isSelected) {
// Selected curves use blue color with custom opacity
ctx.fillStyle = `rgba(74, 158, 255, ${alpha})`;
} else {
// Unselected curves use mask color with custom opacity
const rgb = hexToRgb(state.maskColor || "#ffffff");
// Convert from 0-1 to 0-255 range
const r = Math.round(rgb[0] * 255);
const g = Math.round(rgb[1] * 255);
const b = Math.round(rgb[2] * 255);
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
ctx.fill();
}
// Stroke style
if (isSelected) {
ctx.strokeStyle = "#4a9eff";
ctx.lineWidth = 3;
} else {
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
}
ctx.stroke();
// Draw control points
drawPoints(ctx, curve, state, isSelected);
ctx.restore();
}
+97
View File
@@ -0,0 +1,97 @@
/**
* Main redraw orchestration
*/
import { drawCurve } from "./drawCurve.js";
import { drawBackgroundImage } from "./drawBackground.js";
import { evaluateFrame } from "../core/frameEvaluator.js";
/**
* Redraw the canvas with all curves and background
* @param {CanvasRenderingContext2D} ctx - Canvas context
* @param {HTMLCanvasElement} canvas - Canvas element
* @param {Object} state - Editor state
* @param {Object} callbacks - Callback functions
* @param {Function} callbacks.getActiveShape - Get active shape
* @param {Function} callbacks.drawBackgroundImage - Draw background image
* @param {Function} callbacks.updateCurveList - Update curve list
* @param {Function} callbacks.updateAddPointButton - Update add point button
* @param {Object} node - ComfyUI node
* @param {Object} app - ComfyUI app instance
*/
export function redraw(ctx, canvas, state, callbacks, node, app) {
try {
// Clear canvas
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw input image if available
if (callbacks.drawBackgroundImage) {
callbacks.drawBackgroundImage(ctx, canvas, state, node, app);
}
// Get curves for current frame
const shape = callbacks.getActiveShape();
const curves = evaluateFrame(shape, state.currentFrame, { hold: true });
// Draw all curves
curves.forEach(curve => {
if (curve && curve.points && curve.points.length >= 2) {
const isSelected = state.selectedCurves.has(curve.id);
drawCurve(ctx, curve, state, isSelected);
}
});
// Draw selection box if active
if (state.selectionBox !== null) {
ctx.save();
// Calculate normalized box bounds
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
const width = maxX - minX;
const height = maxY - minY;
// Draw filled rectangle with semi-transparent fill
ctx.fillStyle = "rgba(74, 158, 255, 0.2)";
ctx.fillRect(minX, minY, width, height);
// Draw dashed border
ctx.strokeStyle = "rgba(74, 158, 255, 0.8)";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.strokeRect(minX, minY, width, height);
// Restore line dash
ctx.setLineDash([]);
ctx.restore();
}
// Update Add Point button state
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
// Update Toggle Smooth button state
if (callbacks.updateToggleSmoothButton) {
callbacks.updateToggleSmoothButton();
}
// Cancel add point mode if no curve is selected
if (state.selectedCurves.size === 0 && state.addPointMode) {
state.addPointMode = false;
if (callbacks.updateAddPointButton) {
callbacks.updateAddPointButton();
}
}
// Update curve list
if (callbacks.updateCurveList) {
callbacks.updateCurveList();
}
} catch (error) {
console.error("[ComfyUI Roto] Error during redraw:", error);
}
}
+755
View File
@@ -0,0 +1,755 @@
/**
* ComfyUI Roto - Frontend Extension
* Interactive curve editor for rotoscoping
*/
import { app } from "/scripts/app.js";
// Core modules
import { createState } from "./core/state.js";
import { evaluateFrame } from "./core/frameEvaluator.js";
import { getActiveShape, getCurvesForFrame, promoteToKeyframe, addPointToCurveAtEdge, commitFrame } from "./core/curveManager.js";
// Utils
import { hexToRgb } from "./utils/helpers.js";
import { deepCopy } from "./utils/helpers.js";
// Components
import { createCanvas, resizeCanvasToImage } from "./components/Canvas.js";
import { createToolbar } from "./components/Toolbar.js";
import { createCurveList } from "./components/CurveList.js";
// Renderer
import { redraw } from "./renderer/redraw.js";
import { drawBackgroundImage, getFrameImageForIndex } from "./renderer/drawBackground.js";
// Events
import { createMouseHandlers } from "./events/mouseHandlers.js";
import { createKeyboardHandlers } from "./events/keyboardHandlers.js";
import { createDocumentListeners } from "./events/documentListeners.js";
app.registerExtension({
name: "ComfyUI.Roto",
async nodeCreated(node) {
// Only attach to RotoMaskNode
// Check both title and class name for compatibility
const nodeTitle = node.getTitle ? node.getTitle() : node.title;
const nodeType = node.type || node.comfyClass;
if (nodeTitle !== "Roto Mask" && nodeType !== "RotoMaskNode") {
return;
}
// Default node size constants (enforced via setTimeout after widget setup)
const defaultWidth = 800;
const defaultHeight = 1200;
// Initialize state
const state = createState();
// List of widgets to hide
const widgetsToHide = ['curve_json', 'frame_index', 'frame_count'];
// Hide widgets early - before creating UI to prevent layout issues
const hideWidgetsEarly = () => {
try {
const widgets = node.widgets || [];
widgetsToHide.forEach(widgetName => {
const widgetIndex = widgets.findIndex(w => w.name === widgetName);
if (widgetIndex !== -1) {
const widget = widgets[widgetIndex];
// ComfyUI/LiteGraph widget hiding - set all size-related properties
widget.computeSize = function() { return [0, -4]; };
widget.hidden = true;
widget.type = "hidden";
widget.options = widget.options || {};
widget.options.hidden = true;
widget.options.height = 0;
widget.y = -100; // Push off screen
widget.last_y = -100;
widget.size = [0, 0];
// Override draw to do nothing
widget.draw = function() { return; };
widget.mouse = function() { return false; };
// Hide DOM element if present
if (widget.element) {
widget.element.style.display = 'none';
widget.element.style.height = '0';
widget.element.style.overflow = 'hidden';
widget.element.style.visibility = 'hidden';
widget.element.style.position = 'absolute';
widget.element.style.pointerEvents = 'none';
}
}
});
// Force node to recalculate size
if (node.setSize) {
node.setSize(node.computeSize());
}
} catch (err) {
console.debug("[ComfyUI Roto] Could not hide widgets early:", err);
}
};
// Hide widgets immediately
hideWidgetsEarly();
// Hook into node's onConfigure to hide widgets when node is loaded
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) {
originalOnConfigure.call(this, config);
}
hideWidgetsEarly();
};
// Hook into onDrawForeground to ensure widgets stay hidden
const originalOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx, graphCanvas) {
// Hide widgets before drawing
const widgets = this.widgets || [];
widgetsToHide.forEach(widgetName => {
const widget = widgets.find(w => w.name === widgetName);
if (widget) {
widget.hidden = true;
widget.y = -100;
}
});
if (originalOnDrawForeground) {
originalOnDrawForeground.call(this, ctx, graphCanvas);
}
};
// Create container - ensure it's a proper DOM element
const container = document.createElement("div");
if (!container || !(container instanceof HTMLElement)) {
console.error("[ComfyUI Roto] Failed to create container element");
return;
}
// Set styles before adding to DOM
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.gap = "8px";
container.style.padding = "8px";
container.style.marginTop = "4px"; // Add margin to prevent overlap with hidden widgets
container.style.backgroundColor = "var(--comfy-menu-bg)";
container.style.border = "1px solid var(--border-color)";
container.style.borderRadius = "4px";
container.style.width = "100%";
container.style.minHeight = "540px";
container.style.boxSizing = "border-box";
container.style.overflow = "hidden"; // Prevent content from poking out
container.style.position = "relative"; // Ensure proper positioning
// Function to update container height based on node size
const updateContainerHeight = () => {
if (!node || !node.size) return;
// node.size is [width, height]
// Use a generous offset to ensure content stays within node bounds
const nodeHeight = node.size[1];
const totalOffset = 135; // Header + margins + borders + padding + buffer
const availableHeight = nodeHeight - totalOffset;
if (availableHeight > 500) {
container.style.height = `${availableHeight}px`;
} else {
container.style.height = ''; // Let minHeight take over
}
};
// Make container focusable for keyboard shortcuts
container.tabIndex = 0;
// Create canvas
const { canvas, ctx } = createCanvas(state);
// Widget reference (will be set later)
let widgetRef = null;
// Canvas scale update function (will be defined after canvasWrapper)
let updateCanvasScale = null;
// Helper functions
function updateCanvasLayout() {
container.style.minHeight = `${canvas.height + 56}px`;
// Update canvas display scale
if (updateCanvasScale) {
updateCanvasScale();
}
// Force node to recalculate size when canvas changes
if (node && typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
}
function resizeCanvasToImageFn(img) {
resizeCanvasToImage(canvas, state, img, updateCanvasLayout);
}
// Create callbacks object for components
const callbacks = {
getActiveShape: () => getActiveShape(state),
getCurvesForFrame: (frame) => getCurvesForFrame(state, frame),
evaluateFrame: (shape, frameIndex, options) => evaluateFrame(shape, frameIndex, options),
promoteToKeyframe: (curveId) => promoteToKeyframe(state, evaluateFrame, deepCopy, curveId),
commitFrame: () => commitFrame(state, updateNodeInput),
redraw: () => redraw(ctx, canvas, state, callbacks, node, app),
updateNodeInput,
setMode,
removeDocumentListeners: null, // Will be set after document listeners are created
attachDocumentListeners: null, // Will be set after document listeners are created
drawBackgroundImage: (ctx, canvas, state, node, app) => drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, callbacks.redraw),
getFrameImageForIndex: (index) => getFrameImageForIndex(state, index),
resizeCanvasToImage: resizeCanvasToImageFn,
addPointToCurveAtEdge: null, // Will be set after toolbar is created
updateAddPointButton: null, // Will be set after toolbar is created
updateCurveList: null, // Will be set after curve list is created
setFrame: null // Will be set after toolbar is created
};
// Create toolbar
const toolbarControls = createToolbar(state, callbacks, canvas, node);
callbacks.updateAddPointButton = toolbarControls.updateAddPointButton;
callbacks.updateToggleSmoothButton = toolbarControls.updateToggleSmoothButton;
callbacks.setFrame = toolbarControls.setFrame;
// Initial button state update
if (toolbarControls.updateToggleSmoothButton) {
toolbarControls.updateToggleSmoothButton();
}
// Add toggle smooth callback
callbacks.togglePointSmoothness = () => {
// Require both curve and point selection
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
const shape = getActiveShape(state);
const frameKey = String(state.currentFrame);
// Promote affected curves to keyframes
const affectedCurveIds = new Set();
state.selectedPoints.forEach(pointKey => {
const [curveId] = pointKey.split(":");
if (state.selectedCurves.has(curveId)) {
affectedCurveIds.add(curveId);
promoteToKeyframe(state, evaluateFrame, deepCopy, curveId);
}
});
// Get keyframe curves
if (!shape.frames[frameKey]) shape.frames[frameKey] = [];
const curves = shape.frames[frameKey];
// Determine target state (if all hard, make smooth; otherwise make hard)
let allHard = true;
let allSmooth = true;
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
// Only check points in selected curves
if (!state.selectedCurves.has(curveId)) {
return;
}
const curve = curves.find(c => c.id === curveId);
if (!curve) {
// Curve doesn't exist - skip
return;
}
// Get pointTypes with validation
let types;
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
types = curve.pointTypes;
} else {
types = curve.points.map(() => "hard");
}
const idx = parseInt(pointIndex);
if (idx >= 0 && idx < curve.points.length && idx < types.length) {
const type = types[idx];
if (type === "smooth") {
allHard = false;
} else if (type === "hard") {
allSmooth = false;
}
// Invalid type treated as "hard" (defensive)
}
});
const targetType = allHard ? "smooth" : "hard";
// Collect point indices to toggle per curve
const curvePointsToToggle = new Map(); // curveId -> Set of point indices
state.selectedPoints.forEach(pointKey => {
const [curveId, pointIndex] = pointKey.split(":");
// Only toggle points in selected curves
if (!state.selectedCurves.has(curveId)) {
return;
}
if (!curvePointsToToggle.has(curveId)) {
curvePointsToToggle.set(curveId, new Set());
}
curvePointsToToggle.set(curveId, curvePointsToToggle.get(curveId).add(parseInt(pointIndex)));
});
// Update pointTypes in ALL keyframes for each affected curve
curvePointsToToggle.forEach((pointIndices, curveId) => {
// Iterate through all keyframes
Object.keys(shape.frames).forEach(fKey => {
const frameCurves = shape.frames[fKey] || [];
const curve = frameCurves.find(c => c.id === curveId);
if (!curve) return;
// Ensure pointTypes array exists and matches points length
if (!Array.isArray(curve.pointTypes) || curve.pointTypes.length !== curve.points.length) {
curve.pointTypes = curve.points.map(() => "hard");
}
// Toggle each selected point index
pointIndices.forEach(idx => {
if (idx >= 0 && idx < curve.points.length && idx < curve.pointTypes.length) {
curve.pointTypes[idx] = targetType;
}
});
});
});
commitFrame(state, updateNodeInput);
callbacks.redraw();
if (toolbarControls.updateToggleSmoothButton) {
toolbarControls.updateToggleSmoothButton();
}
};
// Set up addPointToCurveAtEdge callback now that toolbarControls exists
callbacks.addPointToCurveAtEdge = (curveId, edgeIndex, position) => {
addPointToCurveAtEdge(
state,
curveId,
edgeIndex,
position,
(state, evaluateFrameFn, deepCopyFn, curveId) => promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId),
evaluateFrame,
deepCopy,
() => commitFrame(state, updateNodeInput),
callbacks.redraw,
toolbarControls.updateAddPointButton
);
};
// Create curve list
const curveListControls = createCurveList(state, callbacks);
callbacks.updateCurveList = curveListControls.updateCurveList;
// Create mouse handlers first (returns handleDragMove function)
const handleDragMoveFn = createMouseHandlers(canvas, container, state, callbacks);
// Create document listeners with the handleDragMove function
const documentListeners = createDocumentListeners(state, handleDragMoveFn);
callbacks.removeDocumentListeners = documentListeners.removeDocumentListeners;
callbacks.attachDocumentListeners = documentListeners.attachDocumentListeners;
// Create keyboard handlers
createKeyboardHandlers(container, state, callbacks);
// Mode management
function setMode(mode) {
// Clean up dragging if switching modes
if (state.dragging) {
state.dragging = false;
state.dragStart = null;
callbacks.removeDocumentListeners();
}
state.mode = mode;
updateModeButtons();
callbacks.redraw();
}
function updateModeButtons() {
if (state.mode === "draw") {
toolbarControls.drawBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
toolbarControls.editBtn.style.backgroundColor = "";
} else {
toolbarControls.editBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
toolbarControls.drawBtn.style.backgroundColor = "";
}
}
// Update node input with current state
function updateNodeInput() {
try {
const shape = getActiveShape(state);
// Extract frame_paths from state.frameImages
const frame_paths = state.frameImages.map(frame => ({
filename: frame.filename,
subfolder: frame.subfolder || ""
}));
// Convert background color from hex to RGB [0-1]
const background_color = hexToRgb(state.backgroundColor);
const meta = {
version: 1,
width: state.canvasWidth,
height: state.canvasHeight,
feather_amount: state.featherAmount,
background_color: background_color,
mask_color: state.maskColor || "#ffffff",
mask_opacity: state.maskOpacity || 10,
frame_paths: frame_paths
};
const curveData = {
meta: meta,
shapes: {}
};
// Add all shapes
Object.keys(state.shapes).forEach(shapeId => {
curveData.shapes[shapeId] = state.shapes[shapeId];
});
const jsonString = JSON.stringify(curveData);
// Find curve_json widget
const widgets = node.widgets || [];
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
if (curveJsonWidget) {
curveJsonWidget.value = jsonString;
// Trigger widget change event
if (curveJsonWidget.callback) {
curveJsonWidget.callback(jsonString);
}
} else {
console.warn("[ComfyUI Roto] curve_json widget not found");
}
// Sync current frame from widget if present
const frameWidget = widgets.find(w => w.name === "frame_index");
if (frameWidget && frameWidget.value !== undefined) {
const nextFrame = Number(frameWidget.value) || 1;
if (nextFrame !== state.currentFrame) {
state.currentFrame = nextFrame;
toolbarControls.frameSlider.value = String(nextFrame);
}
}
} catch (error) {
console.error("[ComfyUI Roto] Error updating node input:", error);
}
}
// Initialize feather amount and background color from existing curve_json
function initializeFeatherAmount() {
try {
const widgets = node.widgets || [];
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
if (curveJsonWidget && curveJsonWidget.value) {
try {
const curveData = JSON.parse(curveJsonWidget.value);
const meta = curveData?.meta || {};
// Initialize feather amount
const featherAmount = typeof meta.feather_amount === 'number' && !isNaN(meta.feather_amount)
? Math.max(0, Math.min(100, meta.feather_amount))
: 0.0;
state.featherAmount = featherAmount;
toolbarControls.featherSlider.value = String(featherAmount);
toolbarControls.featherValueLabel.textContent = featherAmount.toFixed(1);
// Initialize background color
if (Array.isArray(meta.background_color) && meta.background_color.length === 3) {
const [r, g, b] = meta.background_color;
const rInt = Math.round(Math.max(0, Math.min(255, r * 255)));
const gInt = Math.round(Math.max(0, Math.min(255, g * 255)));
const bInt = Math.round(Math.max(0, Math.min(255, b * 255)));
const hexColor = `#${rInt.toString(16).padStart(2, '0')}${gInt.toString(16).padStart(2, '0')}${bInt.toString(16).padStart(2, '0')}`;
state.backgroundColor = hexColor;
toolbarControls.bgColorInput.value = hexColor;
}
// Initialize mask color
if (typeof meta.mask_color === 'string') {
state.maskColor = meta.mask_color;
toolbarControls.maskColorInput.value = meta.mask_color;
} else {
state.maskColor = "#ffffff";
toolbarControls.maskColorInput.value = "#ffffff";
}
// Initialize mask opacity
const maskOpacity = typeof meta.mask_opacity === 'number' && !isNaN(meta.mask_opacity)
? Math.max(0, Math.min(100, meta.mask_opacity))
: 10;
state.maskOpacity = maskOpacity;
toolbarControls.maskOpacitySlider.value = String(maskOpacity);
toolbarControls.maskOpacityValueLabel.textContent = String(maskOpacity);
// Initialize frame_paths if present
if (Array.isArray(meta.frame_paths) && meta.frame_paths.length > 0) {
state.frameImages = meta.frame_paths.map(fp => ({
filename: fp.filename || "",
subfolder: fp.subfolder || "",
url: `/view?filename=${fp.filename}&type=input&subfolder=${fp.subfolder || ""}&t=${Date.now()}`
}));
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
}
// Initialize frame output controls from widgets
const frameIndexWidget = widgets.find(w => w.name === "frame_index");
const frameCountWidget = widgets.find(w => w.name === "frame_count");
const startFrame = (frameIndexWidget && frameIndexWidget.value) ? Number(frameIndexWidget.value) : 1;
const frameCount = (frameCountWidget && frameCountWidget.value) ? Number(frameCountWidget.value) : 1;
const endFrame = startFrame + frameCount - 1;
toolbarControls.startFrameInput.value = String(startFrame);
toolbarControls.endFrameInput.value = String(endFrame);
} catch (parseError) {
// Invalid JSON, use default
console.warn("[ComfyUI Roto] Failed to parse curve_json for initialization:", parseError);
}
}
} catch (error) {
console.warn("[ComfyUI Roto] Error initializing from curve_json:", error);
}
}
// Create canvas wrapper for flexible sizing
const canvasWrapper = document.createElement("div");
canvasWrapper.style.flex = "1 1 0"; // Grow to fill available space, start from 0
canvasWrapper.style.minHeight = "200px";
canvasWrapper.style.display = "flex";
canvasWrapper.style.alignItems = "center";
canvasWrapper.style.justifyContent = "center";
canvasWrapper.style.overflow = "hidden";
canvasWrapper.style.position = "relative";
canvasWrapper.style.width = "100%"; // Ensure full width
canvasWrapper.appendChild(canvas);
// Make canvas scale to fit wrapper while maintaining aspect ratio
updateCanvasScale = () => {
if (!canvasWrapper.offsetWidth || !canvasWrapper.offsetHeight) return;
const wrapperWidth = canvasWrapper.offsetWidth;
const wrapperHeight = canvasWrapper.offsetHeight;
const canvasAspect = canvas.width / canvas.height;
const wrapperAspect = wrapperWidth / wrapperHeight;
let displayWidth, displayHeight;
if (canvasAspect > wrapperAspect) {
// Canvas is wider - fit to width
displayWidth = wrapperWidth;
displayHeight = wrapperWidth / canvasAspect;
} else {
// Canvas is taller - fit to height
displayHeight = wrapperHeight;
displayWidth = wrapperHeight * canvasAspect;
}
canvas.style.width = `${displayWidth}px`;
canvas.style.height = `${displayHeight}px`;
};
// Use ResizeObserver to detect wrapper size changes
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
updateCanvasScale();
});
resizeObserver.observe(canvasWrapper);
// Also observe container size changes to update canvas when node is resized
const containerResizeObserver = new ResizeObserver(() => {
updateCanvasScale();
});
containerResizeObserver.observe(container);
}
// Assemble UI in new layout order
container.appendChild(toolbarControls.loadFramesSection);
container.appendChild(toolbarControls.toolButtonsSection);
container.appendChild(canvasWrapper);
container.appendChild(toolbarControls.timelineSection);
container.appendChild(curveListControls.curveListContainer);
container.appendChild(toolbarControls.settingsSection);
// Verify container is still valid before adding widget
if (!container || !(container instanceof HTMLElement)) {
console.error("[ComfyUI Roto] Container is not a valid HTMLElement");
return;
}
// Add widget to node
try {
if (typeof node.addDOMWidget !== "function") {
console.error("[ComfyUI Roto] node.addDOMWidget is not a function");
return;
}
// Calculate MINIMUM widget size (fixed values to prevent feedback loop)
const calculateWidgetSize = () => {
const padding = 16; // 8px padding on each side
const gaps = 8 * 6; // 6 gaps between sections (8px each)
// Use fixed estimates for UI sections to prevent feedback loop
const loadFramesHeight = 40;
const toolButtonsHeight = 32;
const timelineHeight = 40;
const curveListHeight = 120;
const settingsHeight = 80;
// Use canvas native height as minimum (not wrapper height!)
const canvasHeight = Math.max(canvas.height, 300);
const totalHeight = loadFramesHeight + toolButtonsHeight + canvasHeight +
timelineHeight + curveListHeight + settingsHeight + gaps + padding;
const totalWidth = Math.max(canvas.width + padding, 1200); // Minimum width (3x wider)
return [totalWidth, totalHeight];
};
const widget = node.addDOMWidget("roto_editor", "custom", null, { element: container });
widgetRef = widget; // Store reference for use in other functions
if (widget) {
widget.element = container;
widget.computeSize = () => {
// Use actual measured heights if available, otherwise estimate
return calculateWidgetSize();
};
}
// Hide widgets - multiple approaches
const hideWidgetByName = (widgetName) => {
try {
const widget = (node.widgets || []).find(w => w.name === widgetName);
if (widget) {
// ComfyUI/LiteGraph widget hiding - set all size-related properties
widget.computeSize = function() { return [0, -4]; };
widget.hidden = true;
widget.type = "hidden";
widget.options = widget.options || {};
widget.options.hidden = true;
widget.options.height = 0;
widget.y = -100; // Push off screen
widget.last_y = -100;
widget.size = [0, 0];
// Override draw to do nothing
widget.draw = function() { return; };
widget.mouse = function() { return false; };
// Hide DOM element if present
if (widget.element) {
widget.element.style.display = 'none';
widget.element.style.height = '0';
widget.element.style.overflow = 'hidden';
widget.element.style.visibility = 'hidden';
widget.element.style.position = 'absolute';
widget.element.style.pointerEvents = 'none';
}
}
} catch (err) {
console.debug(`[ComfyUI Roto] Could not hide ${widgetName} widget:`, err);
}
};
// Hide all internal widgets
const hideAllWidgets = () => {
hideWidgetByName('curve_json');
hideWidgetByName('frame_index');
hideWidgetByName('frame_count');
};
hideAllWidgets();
// Hide again after short delays to catch widgets rendered later
[100, 500, 1000].forEach(delay => {
setTimeout(hideAllWidgets, delay);
});
// Hook frame_index widget to update current frame
const frameWidget = (node.widgets || []).find(w => w.name === "frame_index");
if (frameWidget && typeof frameWidget.callback === "function") {
const originalCallback = frameWidget.callback;
frameWidget.callback = function (v) {
if (state.updatingFrame) {
return originalCallback.call(frameWidget, v, null, node);
}
state.updatingFrame = true;
state.currentFrame = Number(v) || 1;
toolbarControls.frameSlider.value = String(state.currentFrame);
callbacks.redraw();
const result = originalCallback.call(frameWidget, v, null, node);
state.updatingFrame = false;
return result;
};
}
// Initial setup after widget is attached
requestAnimationFrame(() => {
try {
initializeFeatherAmount();
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
toolbarControls.frameSlider.value = String(state.currentFrame);
toolbarControls.updateFrameLabel();
callbacks.redraw();
updateNodeInput();
// Initial canvas scale update
if (updateCanvasScale) {
updateCanvasScale();
}
// Hook into node's onResize to update container height
const originalOnResize = node.onResize;
node.onResize = function(size) {
if (originalOnResize) {
originalOnResize.call(this, size);
}
updateContainerHeight();
if (updateCanvasScale) {
updateCanvasScale();
}
};
// Initial height update
updateContainerHeight();
// Force minimum size after everything is initialized (multiple attempts)
const enforceMinSize = () => {
if (node.size && node.size[0] < defaultWidth) {
const newSize = [defaultWidth, Math.max(node.size[1], defaultHeight)];
node.size = newSize;
if (node.setSize) node.setSize(newSize);
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, true);
updateContainerHeight();
if (updateCanvasScale) updateCanvasScale();
}
};
setTimeout(enforceMinSize, 50);
setTimeout(enforceMinSize, 200);
setTimeout(enforceMinSize, 500);
} catch (error) {
console.error("[ComfyUI Roto] Error in initial setup:", error);
}
});
} catch (error) {
console.error("[ComfyUI Roto] Error adding DOM widget:", error);
console.error("[ComfyUI Roto] Error details:", error.stack);
}
}
});
+23
View File
@@ -0,0 +1,23 @@
/**
* Canvas utility functions
*/
/**
* Get mouse position relative to canvas, accounting for scaling
* @param {MouseEvent} e - Mouse event
* @param {HTMLCanvasElement} canvas - Canvas element
* @returns {{x: number, y: number}} Mouse position clamped to canvas bounds
*/
export function getMousePos(e, canvas) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
// Calculate position even if mouse is outside canvas bounds
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Clamp to canvas bounds
return {
x: Math.max(0, Math.min(canvas.width, x)),
y: Math.max(0, Math.min(canvas.height, y))
};
}
+33
View File
@@ -0,0 +1,33 @@
/**
* File upload and image loading utilities
*/
/**
* Upload an image file to ComfyUI server
* @param {File} file - Image file to upload
* @returns {Promise<Object>} Upload response with filename and subfolder
*/
export async function uploadImageFile(file) {
const body = new FormData();
body.append("image", file);
body.append("overwrite", "true");
const resp = await fetch("/upload/image", { method: "POST", body });
if (!resp.ok) {
throw new Error(`Failed to upload image: ${resp.statusText}`);
}
return resp.json();
}
/**
* Load an image asynchronously from a URL
* @param {string} imageURL - URL of the image to load
* @returns {Promise<HTMLImageElement>} Loaded image element
*/
export async function loadImageAsync(imageURL) {
return new Promise((resolve) => {
const img = new Image();
img.setAttribute("crossorigin", "anonymous");
img.addEventListener("load", () => resolve(img));
img.src = imageURL;
});
}
+27
View File
@@ -0,0 +1,27 @@
/**
* General helper utilities
*/
/**
* Deep copy an object using JSON serialization
* @param {any} obj - Object to deep copy
* @returns {any} Deep copy of the object
*/
export function deepCopy(obj) {
return JSON.parse(JSON.stringify(obj));
}
/**
* Convert hex color string to RGB array [0-1]
* @param {string} hex - Hex color string (e.g., "#ff0000" or "ff0000")
* @returns {number[]} RGB array with values in range [0, 1]
*/
export function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return [0.0, 0.0, 0.0];
return [
parseInt(result[1], 16) / 255.0,
parseInt(result[2], 16) / 255.0,
parseInt(result[3], 16) / 255.0
];
}
+11
View File
@@ -0,0 +1,11 @@
/**
* UUID generation utility
*/
export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}