Initial commit
This commit is contained in:
+33
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Core modules for roto node
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Image processing modules
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Mask generation modules
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Canvas component creation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create and configure the canvas element
|
||||
* @param {Object} state - Editor state
|
||||
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D}} Canvas element and context
|
||||
*/
|
||||
export function createCanvas(state) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = state.canvasWidth;
|
||||
canvas.height = state.canvasHeight;
|
||||
canvas.style.border = "1px solid var(--border-color)";
|
||||
canvas.style.backgroundColor = "#1a1a1a";
|
||||
canvas.style.cursor = "crosshair";
|
||||
// Don't use flex on canvas - let updateCanvasScale handle sizing
|
||||
canvas.style.display = "block"; // Remove inline spacing
|
||||
canvas.tabIndex = 0;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas to match image dimensions
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @param {Object} state - Editor state
|
||||
* @param {HTMLImageElement} img - Image to match
|
||||
* @param {Function} updateCanvasLayoutFn - Function to update canvas layout
|
||||
*/
|
||||
export function resizeCanvasToImage(canvas, state, img, updateCanvasLayoutFn) {
|
||||
if (!img || !img.width || !img.height) return;
|
||||
const maxSize = 2048;
|
||||
const width = Math.min(maxSize, img.width);
|
||||
const height = Math.min(maxSize, img.height);
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
state.canvasWidth = width;
|
||||
state.canvasHeight = height;
|
||||
if (updateCanvasLayoutFn) {
|
||||
updateCanvasLayoutFn();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Curve list component creation and management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create the curve list panel
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @param {Function} callbacks.getActiveShape - Get active shape
|
||||
* @param {Function} callbacks.setFrame - Set current frame
|
||||
* @param {Function} callbacks.updateNodeInput - Update node input
|
||||
* @param {Function} callbacks.redraw - Redraw canvas
|
||||
* @returns {Object} Curve list elements and update function
|
||||
*/
|
||||
export function createCurveList(state, callbacks) {
|
||||
const curveListContainer = document.createElement("div");
|
||||
curveListContainer.style.display = "flex";
|
||||
curveListContainer.style.flexDirection = "column";
|
||||
curveListContainer.style.gap = "4px";
|
||||
curveListContainer.style.height = "120px"; // Fixed height to fit ~3 shapes
|
||||
curveListContainer.style.minHeight = "120px"; // Prevent shrinking
|
||||
curveListContainer.style.maxHeight = "120px"; // Prevent growing into canvas space
|
||||
curveListContainer.style.overflow = "auto";
|
||||
curveListContainer.style.flexShrink = "0"; // Don't shrink when container is tight
|
||||
|
||||
// Title
|
||||
const curveListHeader = document.createElement("div");
|
||||
curveListHeader.textContent = "Curves";
|
||||
curveListHeader.style.fontSize = "13px";
|
||||
curveListHeader.style.fontWeight = "600";
|
||||
curveListHeader.style.color = "var(--fg-color)";
|
||||
|
||||
// Count
|
||||
const curveListCount = document.createElement("div");
|
||||
curveListCount.style.fontSize = "12px";
|
||||
curveListCount.style.opacity = "0.7";
|
||||
curveListCount.style.marginBottom = "4px";
|
||||
|
||||
const curveList = document.createElement("ul");
|
||||
curveList.style.listStyle = "none";
|
||||
curveList.style.padding = "0";
|
||||
curveList.style.margin = "0";
|
||||
|
||||
curveListContainer.appendChild(curveListHeader);
|
||||
curveListContainer.appendChild(curveListCount);
|
||||
curveListContainer.appendChild(curveList);
|
||||
|
||||
function updateCurveList() {
|
||||
const shape = callbacks.getActiveShape();
|
||||
const frameKey = String(state.currentFrame);
|
||||
const currentCurves = shape.frames[frameKey] || [];
|
||||
|
||||
const curveMap = new Map();
|
||||
Object.entries(shape.frames || {}).forEach(([frame, curves]) => {
|
||||
(curves || []).forEach(curve => {
|
||||
if (!curve || !curve.id) return;
|
||||
if (!curveMap.has(curve.id)) {
|
||||
curveMap.set(curve.id, new Set());
|
||||
}
|
||||
curveMap.get(curve.id).add(frame);
|
||||
});
|
||||
});
|
||||
|
||||
curveList.innerHTML = "";
|
||||
const ids = Array.from(curveMap.keys());
|
||||
curveListCount.textContent = `${ids.length} curve(s)`;
|
||||
|
||||
// Create a map of curve IDs to display names (Shape 1, Shape 2, etc.)
|
||||
const curveDisplayNames = new Map();
|
||||
ids.forEach((id, index) => {
|
||||
curveDisplayNames.set(id, `Shape ${index + 1}`);
|
||||
});
|
||||
|
||||
ids.forEach(id => {
|
||||
const item = document.createElement("li");
|
||||
item.style.display = "flex";
|
||||
item.style.justifyContent = "space-between";
|
||||
item.style.alignItems = "center";
|
||||
item.style.padding = "2px 0";
|
||||
item.style.gap = "4px";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = curveDisplayNames.get(id);
|
||||
label.style.cursor = "pointer";
|
||||
|
||||
const frames = curveMap.get(id);
|
||||
const frameNumbers = frames ? Array.from(frames).map(f => parseInt(f)).sort((a, b) => a - b) : [];
|
||||
const hasKeyOnCurrentFrame = frames && frames.has(frameKey);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.style.display = "inline-block";
|
||||
dot.style.width = "6px";
|
||||
dot.style.height = "6px";
|
||||
dot.style.borderRadius = "50%";
|
||||
dot.style.marginRight = "6px";
|
||||
dot.style.backgroundColor = hasKeyOnCurrentFrame ? "#ff4d4d" : "transparent";
|
||||
|
||||
const left = document.createElement("div");
|
||||
left.style.display = "flex";
|
||||
left.style.alignItems = "center";
|
||||
left.appendChild(dot);
|
||||
left.appendChild(label);
|
||||
|
||||
// Keyframe navigation buttons
|
||||
const navContainer = document.createElement("div");
|
||||
navContainer.style.display = "flex";
|
||||
navContainer.style.gap = "2px";
|
||||
navContainer.style.alignItems = "center";
|
||||
|
||||
// Previous keyframe button
|
||||
const prevKeyBtn = document.createElement("button");
|
||||
prevKeyBtn.textContent = "◀";
|
||||
prevKeyBtn.className = "comfy-button";
|
||||
prevKeyBtn.type = "button";
|
||||
prevKeyBtn.style.padding = "0 4px";
|
||||
prevKeyBtn.style.minWidth = "20px";
|
||||
prevKeyBtn.style.fontSize = "10px";
|
||||
prevKeyBtn.title = "Previous keyframe";
|
||||
prevKeyBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const currentFrameNum = state.currentFrame;
|
||||
const prevFrames = frameNumbers.filter(f => f < currentFrameNum);
|
||||
if (prevFrames.length > 0) {
|
||||
const prevFrame = prevFrames[prevFrames.length - 1];
|
||||
callbacks.setFrame(prevFrame);
|
||||
}
|
||||
};
|
||||
|
||||
// Next keyframe button
|
||||
const nextKeyBtn = document.createElement("button");
|
||||
nextKeyBtn.textContent = "▶";
|
||||
nextKeyBtn.className = "comfy-button";
|
||||
nextKeyBtn.type = "button";
|
||||
nextKeyBtn.style.padding = "0 4px";
|
||||
nextKeyBtn.style.minWidth = "20px";
|
||||
nextKeyBtn.style.fontSize = "10px";
|
||||
nextKeyBtn.title = "Next keyframe";
|
||||
nextKeyBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const currentFrameNum = state.currentFrame;
|
||||
const nextFrames = frameNumbers.filter(f => f > currentFrameNum);
|
||||
if (nextFrames.length > 0) {
|
||||
const nextFrame = nextFrames[0];
|
||||
callbacks.setFrame(nextFrame);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete keyframe button (only for current frame)
|
||||
const deleteKeyBtn = document.createElement("button");
|
||||
deleteKeyBtn.textContent = "🔑✕";
|
||||
deleteKeyBtn.className = "comfy-button";
|
||||
deleteKeyBtn.type = "button";
|
||||
deleteKeyBtn.style.padding = "0 4px";
|
||||
deleteKeyBtn.style.minWidth = "28px";
|
||||
deleteKeyBtn.style.fontSize = "10px";
|
||||
deleteKeyBtn.title = "Delete keyframe for this curve";
|
||||
deleteKeyBtn.disabled = !hasKeyOnCurrentFrame;
|
||||
deleteKeyBtn.style.opacity = hasKeyOnCurrentFrame ? "1" : "0.4";
|
||||
deleteKeyBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasKeyOnCurrentFrame) return;
|
||||
// Remove this curve from current frame only
|
||||
if (shape.frames[frameKey]) {
|
||||
shape.frames[frameKey] = shape.frames[frameKey].filter(c => c.id !== id);
|
||||
if (shape.frames[frameKey].length === 0) {
|
||||
delete shape.frames[frameKey];
|
||||
}
|
||||
}
|
||||
state.selectedCurves.delete(id);
|
||||
state.selectedPoints.clear();
|
||||
callbacks.updateNodeInput();
|
||||
callbacks.redraw();
|
||||
};
|
||||
|
||||
// Delete curve entirely button
|
||||
const deleteCurveBtn = document.createElement("button");
|
||||
deleteCurveBtn.textContent = "✕";
|
||||
deleteCurveBtn.className = "comfy-button";
|
||||
deleteCurveBtn.type = "button";
|
||||
deleteCurveBtn.style.padding = "0 6px";
|
||||
deleteCurveBtn.style.minWidth = "24px";
|
||||
deleteCurveBtn.title = "Delete curve from all frames";
|
||||
deleteCurveBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
Object.keys(shape.frames || {}).forEach(frame => {
|
||||
shape.frames[frame] = (shape.frames[frame] || []).filter(c => c.id !== id);
|
||||
if (shape.frames[frame].length === 0) {
|
||||
delete shape.frames[frame];
|
||||
}
|
||||
});
|
||||
state.selectedCurves.delete(id);
|
||||
state.selectedPoints.clear();
|
||||
callbacks.updateNodeInput();
|
||||
callbacks.redraw();
|
||||
};
|
||||
|
||||
navContainer.appendChild(prevKeyBtn);
|
||||
navContainer.appendChild(nextKeyBtn);
|
||||
navContainer.appendChild(deleteKeyBtn);
|
||||
navContainer.appendChild(deleteCurveBtn);
|
||||
|
||||
if (state.selectedCurves.has(id)) {
|
||||
label.style.color = "#4a9eff";
|
||||
}
|
||||
|
||||
label.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const curve = currentCurves.find(c => c.id === id);
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
if (curve) {
|
||||
state.selectedCurves.add(id);
|
||||
}
|
||||
callbacks.redraw();
|
||||
};
|
||||
|
||||
item.appendChild(left);
|
||||
item.appendChild(navContainer);
|
||||
curveList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
curveListContainer,
|
||||
updateCurveList
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
/**
|
||||
* Toolbar component creation and management
|
||||
*/
|
||||
|
||||
import { uploadImageFile } from "../utils/fileUpload.js";
|
||||
|
||||
/**
|
||||
* Create the toolbar with all controls
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @param {Function} callbacks.updateNodeInput - Update node input
|
||||
* @param {Function} callbacks.setFrame - Set current frame
|
||||
* @param {Function} callbacks.redraw - Redraw canvas
|
||||
* @param {Function} callbacks.setMode - Set editor mode
|
||||
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
|
||||
* @param {Function} callbacks.updateModeButtons - Update mode button styles
|
||||
* @param {Function} callbacks.getFrameImageForIndex - Get frame image
|
||||
* @param {Function} callbacks.resizeCanvasToImage - Resize canvas to image
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @param {Object} node - ComfyUI node
|
||||
* @returns {Object} Toolbar elements and controls
|
||||
*/
|
||||
export function createToolbar(state, callbacks, canvas, node) {
|
||||
// Section 1: Load Frames (full width at top)
|
||||
const loadFramesSection = document.createElement("div");
|
||||
loadFramesSection.style.display = "flex";
|
||||
loadFramesSection.style.width = "100%";
|
||||
loadFramesSection.style.gap = "8px";
|
||||
|
||||
// Feather amount slider
|
||||
const featherLabel = document.createElement("span");
|
||||
featherLabel.textContent = "Feather Amount:";
|
||||
featherLabel.style.minWidth = "120px";
|
||||
featherLabel.style.fontSize = "12px";
|
||||
|
||||
const featherSlider = document.createElement("input");
|
||||
featherSlider.type = "range";
|
||||
featherSlider.min = "0";
|
||||
featherSlider.max = "100";
|
||||
featherSlider.step = "0.1";
|
||||
featherSlider.value = "0";
|
||||
featherSlider.style.flex = "1 1 auto";
|
||||
featherSlider.style.minWidth = "100px";
|
||||
|
||||
const featherValueLabel = document.createElement("span");
|
||||
featherValueLabel.textContent = "0.0";
|
||||
featherValueLabel.style.minWidth = "40px";
|
||||
featherValueLabel.style.fontSize = "12px";
|
||||
featherValueLabel.style.textAlign = "right";
|
||||
|
||||
featherSlider.oninput = (e) => {
|
||||
const value = Math.max(0, Math.min(100, parseFloat(e.target.value) || 0));
|
||||
state.featherAmount = value;
|
||||
featherSlider.value = String(value);
|
||||
featherValueLabel.textContent = value.toFixed(1);
|
||||
callbacks.updateNodeInput();
|
||||
};
|
||||
|
||||
const featherContainer = document.createElement("div");
|
||||
featherContainer.style.display = "flex";
|
||||
featherContainer.style.gap = "8px";
|
||||
featherContainer.style.alignItems = "center";
|
||||
featherContainer.style.flex = "1 1 auto";
|
||||
featherContainer.style.minWidth = "200px";
|
||||
featherContainer.appendChild(featherLabel);
|
||||
featherContainer.appendChild(featherSlider);
|
||||
featherContainer.appendChild(featherValueLabel);
|
||||
|
||||
// Background color picker
|
||||
const bgColorLabel = document.createElement("span");
|
||||
bgColorLabel.textContent = "Background color:";
|
||||
bgColorLabel.style.minWidth = "90px";
|
||||
bgColorLabel.style.fontSize = "12px";
|
||||
|
||||
const bgColorInput = document.createElement("input");
|
||||
bgColorInput.type = "color";
|
||||
bgColorInput.value = "#000000";
|
||||
bgColorInput.style.width = "40px";
|
||||
bgColorInput.style.height = "24px";
|
||||
bgColorInput.style.cursor = "pointer";
|
||||
bgColorInput.style.border = "1px solid var(--border-color)";
|
||||
bgColorInput.style.borderRadius = "2px";
|
||||
|
||||
bgColorInput.onchange = (e) => {
|
||||
state.backgroundColor = e.target.value;
|
||||
callbacks.updateNodeInput();
|
||||
};
|
||||
|
||||
const bgColorContainer = document.createElement("div");
|
||||
bgColorContainer.style.display = "flex";
|
||||
bgColorContainer.style.gap = "8px";
|
||||
bgColorContainer.style.alignItems = "center";
|
||||
bgColorContainer.appendChild(bgColorLabel);
|
||||
bgColorContainer.appendChild(bgColorInput);
|
||||
|
||||
// Section 4: Settings - created early so we can append to it
|
||||
const settingsSection = document.createElement("div");
|
||||
settingsSection.style.display = "flex";
|
||||
settingsSection.style.flexDirection = "column";
|
||||
settingsSection.style.gap = "6px";
|
||||
settingsSection.style.width = "100%";
|
||||
|
||||
// Divider at top of settings section (separates from curve list)
|
||||
const settingsDivider = document.createElement("div");
|
||||
settingsDivider.style.width = "100%";
|
||||
settingsDivider.style.height = "1px";
|
||||
settingsDivider.style.backgroundColor = "var(--border-color)";
|
||||
settingsDivider.style.marginBottom = "4px";
|
||||
|
||||
// === Mask Visualization Settings ===
|
||||
const maskVisualizationTitle = document.createElement("div");
|
||||
maskVisualizationTitle.textContent = "Mask Visualization Settings";
|
||||
maskVisualizationTitle.style.fontSize = "13px";
|
||||
maskVisualizationTitle.style.fontWeight = "600";
|
||||
maskVisualizationTitle.style.color = "var(--fg-color)";
|
||||
|
||||
// Mask Color picker
|
||||
const maskColorLabel = document.createElement("span");
|
||||
maskColorLabel.textContent = "Mask Color:";
|
||||
maskColorLabel.style.fontSize = "12px";
|
||||
maskColorLabel.style.minWidth = "70px";
|
||||
|
||||
const maskColorInput = document.createElement("input");
|
||||
maskColorInput.type = "color";
|
||||
maskColorInput.value = "#ffffff";
|
||||
maskColorInput.style.width = "40px";
|
||||
maskColorInput.style.height = "24px";
|
||||
maskColorInput.style.cursor = "pointer";
|
||||
maskColorInput.style.border = "1px solid var(--border-color)";
|
||||
maskColorInput.style.borderRadius = "2px";
|
||||
|
||||
maskColorInput.onchange = (e) => {
|
||||
state.maskColor = e.target.value;
|
||||
callbacks.updateNodeInput();
|
||||
callbacks.redraw();
|
||||
};
|
||||
|
||||
const maskColorContainer = document.createElement("div");
|
||||
maskColorContainer.style.display = "flex";
|
||||
maskColorContainer.style.gap = "4px";
|
||||
maskColorContainer.style.alignItems = "center";
|
||||
maskColorContainer.appendChild(maskColorLabel);
|
||||
maskColorContainer.appendChild(maskColorInput);
|
||||
|
||||
// Mask Opacity slider
|
||||
const maskOpacityLabel = document.createElement("span");
|
||||
maskOpacityLabel.textContent = "Mask Opacity:";
|
||||
maskOpacityLabel.style.fontSize = "12px";
|
||||
maskOpacityLabel.style.minWidth = "90px";
|
||||
|
||||
const maskOpacitySlider = document.createElement("input");
|
||||
maskOpacitySlider.type = "range";
|
||||
maskOpacitySlider.min = "0";
|
||||
maskOpacitySlider.max = "100";
|
||||
maskOpacitySlider.step = "1";
|
||||
maskOpacitySlider.value = "10";
|
||||
maskOpacitySlider.style.flex = "1 1 auto";
|
||||
maskOpacitySlider.style.minWidth = "100px";
|
||||
|
||||
const maskOpacityValueLabel = document.createElement("span");
|
||||
maskOpacityValueLabel.textContent = "10";
|
||||
maskOpacityValueLabel.style.minWidth = "30px";
|
||||
maskOpacityValueLabel.style.fontSize = "12px";
|
||||
maskOpacityValueLabel.style.textAlign = "right";
|
||||
|
||||
maskOpacitySlider.oninput = (e) => {
|
||||
const value = Math.max(0, Math.min(100, parseInt(e.target.value) || 0));
|
||||
state.maskOpacity = value;
|
||||
maskOpacitySlider.value = String(value);
|
||||
maskOpacityValueLabel.textContent = String(value);
|
||||
callbacks.updateNodeInput();
|
||||
callbacks.redraw();
|
||||
};
|
||||
|
||||
const maskOpacityContainer = document.createElement("div");
|
||||
maskOpacityContainer.style.display = "flex";
|
||||
maskOpacityContainer.style.gap = "8px";
|
||||
maskOpacityContainer.style.alignItems = "center";
|
||||
maskOpacityContainer.style.flex = "1 1 auto";
|
||||
maskOpacityContainer.style.minWidth = "200px";
|
||||
maskOpacityContainer.appendChild(maskOpacityLabel);
|
||||
maskOpacityContainer.appendChild(maskOpacitySlider);
|
||||
maskOpacityContainer.appendChild(maskOpacityValueLabel);
|
||||
|
||||
// Mask controls row (color and opacity on same line)
|
||||
const maskControlsRow = document.createElement("div");
|
||||
maskControlsRow.style.display = "flex";
|
||||
maskControlsRow.style.gap = "16px";
|
||||
maskControlsRow.style.alignItems = "center";
|
||||
maskControlsRow.style.flexWrap = "wrap";
|
||||
maskControlsRow.appendChild(maskColorContainer);
|
||||
maskControlsRow.appendChild(maskOpacityContainer);
|
||||
|
||||
// Divider between Mask Visualization Settings and Image Output Settings
|
||||
const maskImageDivider = document.createElement("div");
|
||||
maskImageDivider.style.width = "100%";
|
||||
maskImageDivider.style.height = "1px";
|
||||
maskImageDivider.style.backgroundColor = "var(--border-color)";
|
||||
maskImageDivider.style.marginTop = "4px";
|
||||
maskImageDivider.style.marginBottom = "4px";
|
||||
|
||||
// === Image Output Settings ===
|
||||
const imageOutputTitle = document.createElement("div");
|
||||
imageOutputTitle.textContent = "Image Output Settings";
|
||||
imageOutputTitle.style.fontSize = "13px";
|
||||
imageOutputTitle.style.fontWeight = "600";
|
||||
imageOutputTitle.style.color = "var(--fg-color)";
|
||||
|
||||
|
||||
// Divider before Output Frame Range
|
||||
const frameRangeDivider = document.createElement("div");
|
||||
frameRangeDivider.style.width = "100%";
|
||||
frameRangeDivider.style.height = "1px";
|
||||
frameRangeDivider.style.backgroundColor = "var(--border-color)";
|
||||
frameRangeDivider.style.marginTop = "4px";
|
||||
|
||||
// === Output Frame Range ===
|
||||
const frameRangeTitle = document.createElement("div");
|
||||
frameRangeTitle.textContent = "Output Frame Range";
|
||||
frameRangeTitle.style.fontSize = "13px";
|
||||
frameRangeTitle.style.fontWeight = "600";
|
||||
frameRangeTitle.style.color = "var(--fg-color)";
|
||||
frameRangeTitle.style.marginTop = "2px";
|
||||
|
||||
// Frame output controls - Start Frame
|
||||
const startFrameLabel = document.createElement("span");
|
||||
startFrameLabel.textContent = "Start Frame:";
|
||||
startFrameLabel.style.fontSize = "12px";
|
||||
startFrameLabel.style.minWidth = "70px";
|
||||
|
||||
const startFrameInput = document.createElement("input");
|
||||
startFrameInput.type = "number";
|
||||
startFrameInput.min = "1";
|
||||
startFrameInput.value = "1";
|
||||
startFrameInput.style.width = "60px";
|
||||
startFrameInput.style.height = "24px";
|
||||
startFrameInput.style.padding = "2px 6px";
|
||||
startFrameInput.style.border = "1px solid var(--border-color, #555)";
|
||||
startFrameInput.style.borderRadius = "4px";
|
||||
startFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
|
||||
startFrameInput.style.color = "var(--input-text, #fff)";
|
||||
|
||||
const startFrameContainer = document.createElement("div");
|
||||
startFrameContainer.style.display = "flex";
|
||||
startFrameContainer.style.gap = "4px";
|
||||
startFrameContainer.style.alignItems = "center";
|
||||
startFrameContainer.appendChild(startFrameLabel);
|
||||
startFrameContainer.appendChild(startFrameInput);
|
||||
|
||||
// Frame output controls - End Frame
|
||||
const endFrameLabel = document.createElement("span");
|
||||
endFrameLabel.textContent = "End Frame:";
|
||||
endFrameLabel.style.fontSize = "12px";
|
||||
endFrameLabel.style.minWidth = "70px";
|
||||
|
||||
const endFrameInput = document.createElement("input");
|
||||
endFrameInput.type = "number";
|
||||
endFrameInput.min = "1";
|
||||
endFrameInput.value = "1";
|
||||
endFrameInput.style.width = "60px";
|
||||
endFrameInput.style.height = "24px";
|
||||
endFrameInput.style.padding = "2px 6px";
|
||||
endFrameInput.style.border = "1px solid var(--border-color, #555)";
|
||||
endFrameInput.style.borderRadius = "4px";
|
||||
endFrameInput.style.backgroundColor = "var(--comfy-input-bg, #333)";
|
||||
endFrameInput.style.color = "var(--input-text, #fff)";
|
||||
|
||||
const endFrameContainer = document.createElement("div");
|
||||
endFrameContainer.style.display = "flex";
|
||||
endFrameContainer.style.gap = "4px";
|
||||
endFrameContainer.style.alignItems = "center";
|
||||
endFrameContainer.appendChild(endFrameLabel);
|
||||
endFrameContainer.appendChild(endFrameInput);
|
||||
|
||||
// Helper to calculate frame count from start/end
|
||||
const getFrameCount = () => {
|
||||
const startFrame = parseInt(startFrameInput.value) || 1;
|
||||
const endFrame = parseInt(endFrameInput.value) || 1;
|
||||
return Math.max(1, endFrame - startFrame + 1);
|
||||
};
|
||||
|
||||
// Update frame range (called when frames are loaded)
|
||||
const updateFrameRangeInfo = () => {
|
||||
// This function is kept for compatibility but does nothing visual now
|
||||
};
|
||||
|
||||
// Handle frame output setting changes
|
||||
const updateFrameWidgets = () => {
|
||||
const startFrame = parseInt(startFrameInput.value) || 1;
|
||||
const endFrame = parseInt(endFrameInput.value) || 1;
|
||||
const frameCount = Math.max(1, endFrame - startFrame + 1);
|
||||
|
||||
// Update frame_index widget
|
||||
const frameIndexWidget = (node.widgets || []).find(w => w.name === "frame_index");
|
||||
if (frameIndexWidget) {
|
||||
frameIndexWidget.value = startFrame;
|
||||
if (typeof frameIndexWidget.callback === "function") {
|
||||
frameIndexWidget.callback(startFrame);
|
||||
}
|
||||
}
|
||||
|
||||
// Update frame_count widget
|
||||
const frameCountWidget = (node.widgets || []).find(w => w.name === "frame_count");
|
||||
if (frameCountWidget) {
|
||||
frameCountWidget.value = frameCount;
|
||||
if (typeof frameCountWidget.callback === "function") {
|
||||
frameCountWidget.callback(frameCount);
|
||||
}
|
||||
}
|
||||
|
||||
callbacks.updateNodeInput();
|
||||
};
|
||||
|
||||
startFrameInput.onchange = (e) => {
|
||||
const value = Math.max(1, parseInt(e.target.value) || 1);
|
||||
startFrameInput.value = String(value);
|
||||
|
||||
// Ensure end frame is not less than start frame
|
||||
const endValue = parseInt(endFrameInput.value) || 1;
|
||||
if (endValue < value) {
|
||||
endFrameInput.value = String(value);
|
||||
}
|
||||
|
||||
updateFrameWidgets();
|
||||
};
|
||||
|
||||
endFrameInput.onchange = (e) => {
|
||||
const startValue = parseInt(startFrameInput.value) || 1;
|
||||
const value = Math.max(startValue, parseInt(e.target.value) || 1);
|
||||
endFrameInput.value = String(value);
|
||||
|
||||
updateFrameWidgets();
|
||||
};
|
||||
|
||||
// Frame controls row
|
||||
const frameControlsRow = document.createElement("div");
|
||||
frameControlsRow.style.display = "flex";
|
||||
frameControlsRow.style.gap = "16px";
|
||||
frameControlsRow.style.alignItems = "center";
|
||||
frameControlsRow.style.flexWrap = "wrap";
|
||||
frameControlsRow.appendChild(startFrameContainer);
|
||||
frameControlsRow.appendChild(endFrameContainer);
|
||||
|
||||
// Update feather container to be full width row
|
||||
featherContainer.style.width = "100%";
|
||||
|
||||
// Update bg color container to be full width row
|
||||
bgColorContainer.style.width = "100%";
|
||||
|
||||
// Assemble settings section in order:
|
||||
// 1. Divider (separates from curve list above)
|
||||
// 2. Mask Visualization Settings title
|
||||
// 3. Mask Color and Opacity controls (same line)
|
||||
// 4. Divider (separates Mask Visualization from Image Output)
|
||||
// 5. Image Output Settings title
|
||||
// 6. Feather amount (own line)
|
||||
// 7. Background colour (own line)
|
||||
// 8. Divider
|
||||
// 9. Output Frame Range title
|
||||
// 10. Start/End frame controls
|
||||
settingsSection.appendChild(settingsDivider);
|
||||
settingsSection.appendChild(maskVisualizationTitle);
|
||||
settingsSection.appendChild(maskControlsRow);
|
||||
settingsSection.appendChild(maskImageDivider);
|
||||
settingsSection.appendChild(imageOutputTitle);
|
||||
settingsSection.appendChild(featherContainer);
|
||||
settingsSection.appendChild(bgColorContainer);
|
||||
settingsSection.appendChild(frameRangeDivider);
|
||||
settingsSection.appendChild(frameRangeTitle);
|
||||
settingsSection.appendChild(frameControlsRow);
|
||||
|
||||
// Load frames button + file input
|
||||
const loadFramesBtn = document.createElement("button");
|
||||
loadFramesBtn.textContent = "Load Frames";
|
||||
loadFramesBtn.className = "comfy-button";
|
||||
loadFramesBtn.type = "button";
|
||||
loadFramesBtn.style.width = "100%";
|
||||
|
||||
const framesInput = document.createElement("input");
|
||||
framesInput.type = "file";
|
||||
framesInput.accept = "image/*";
|
||||
framesInput.multiple = true;
|
||||
framesInput.style.display = "none";
|
||||
|
||||
loadFramesBtn.onclick = () => framesInput.click();
|
||||
|
||||
loadFramesSection.appendChild(loadFramesBtn);
|
||||
loadFramesSection.appendChild(framesInput);
|
||||
|
||||
framesInput.onchange = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
|
||||
try {
|
||||
state.frameImages = [];
|
||||
state.frameImageCache.clear();
|
||||
|
||||
for (const file of files) {
|
||||
const data = await uploadImageFile(file);
|
||||
const filename = data.name;
|
||||
const subfolder = data.subfolder || "";
|
||||
const url = `/view?filename=${filename}&type=input&subfolder=${subfolder}&t=${Date.now()}`;
|
||||
state.frameImages.push({ filename, subfolder, url });
|
||||
}
|
||||
|
||||
if (state.frameImages.length > 0) {
|
||||
const first = await callbacks.getFrameImageForIndex(0);
|
||||
if (first) {
|
||||
callbacks.resizeCanvasToImage(first);
|
||||
}
|
||||
}
|
||||
frameSlider.max = String(Math.max(1, state.frameImages.length));
|
||||
|
||||
// Auto-set frame_count widget to match loaded frames
|
||||
const widgets = node.widgets || [];
|
||||
const frameCountWidget = widgets.find(w => w.name === "frame_count");
|
||||
if (frameCountWidget) {
|
||||
frameCountWidget.value = state.frameImages.length;
|
||||
if (typeof frameCountWidget.callback === "function") {
|
||||
frameCountWidget.callback(state.frameImages.length);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the frame output controls (Start Frame = 1, End Frame = total frames)
|
||||
startFrameInput.value = "1";
|
||||
endFrameInput.value = String(state.frameImages.length);
|
||||
|
||||
updateFrameLabel(); // Update frame counter display
|
||||
callbacks.setFrame(1);
|
||||
callbacks.redraw();
|
||||
callbacks.updateNodeInput(); // Store frame_paths in metadata
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Failed to load frames:", error);
|
||||
} finally {
|
||||
framesInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Section 2: Tool Buttons (Draw, Edit, Add Point)
|
||||
const toolButtonsSection = document.createElement("div");
|
||||
toolButtonsSection.style.display = "flex";
|
||||
toolButtonsSection.style.gap = "8px";
|
||||
toolButtonsSection.style.width = "100%";
|
||||
|
||||
// Mode buttons
|
||||
const drawBtn = document.createElement("button");
|
||||
drawBtn.textContent = "Draw (B)";
|
||||
drawBtn.className = "comfy-button";
|
||||
drawBtn.type = "button";
|
||||
drawBtn.style.flex = "1 1 auto";
|
||||
drawBtn.onclick = () => {
|
||||
try {
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
callbacks.setMode("draw");
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error switching to draw mode:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.textContent = "Edit (V)";
|
||||
editBtn.className = "comfy-button";
|
||||
editBtn.type = "button";
|
||||
editBtn.style.flex = "1 1 auto";
|
||||
editBtn.onclick = () => {
|
||||
try {
|
||||
callbacks.setMode("edit");
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error switching to edit mode:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add Point button
|
||||
const addPointBtn = document.createElement("button");
|
||||
addPointBtn.textContent = "Add Point";
|
||||
addPointBtn.className = "comfy-button";
|
||||
addPointBtn.type = "button";
|
||||
addPointBtn.disabled = true;
|
||||
addPointBtn.style.flex = "1 1 auto";
|
||||
|
||||
function updateAddPointButton() {
|
||||
// Enable/disable based on curve selection
|
||||
addPointBtn.disabled = state.selectedCurves.size === 0;
|
||||
|
||||
if (state.addPointMode) {
|
||||
addPointBtn.textContent = "Click Edge...";
|
||||
addPointBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
|
||||
canvas.style.cursor = "crosshair";
|
||||
} else {
|
||||
addPointBtn.textContent = "Add Point";
|
||||
addPointBtn.style.backgroundColor = "";
|
||||
canvas.style.cursor = state.mode === "draw" ? "crosshair" : "default";
|
||||
}
|
||||
}
|
||||
|
||||
addPointBtn.onclick = () => {
|
||||
if (state.selectedCurves.size === 0) return;
|
||||
|
||||
// Toggle add point mode - user must click on edge to add point
|
||||
state.addPointMode = !state.addPointMode;
|
||||
updateAddPointButton();
|
||||
};
|
||||
|
||||
// Toggle Smooth button
|
||||
const toggleSmoothBtn = document.createElement("button");
|
||||
toggleSmoothBtn.textContent = "Soften Point";
|
||||
toggleSmoothBtn.className = "comfy-button";
|
||||
toggleSmoothBtn.type = "button";
|
||||
toggleSmoothBtn.disabled = true;
|
||||
toggleSmoothBtn.style.flex = "1 1 auto";
|
||||
|
||||
function updateToggleSmoothButton() {
|
||||
// Button enabled when curves are selected
|
||||
const hasCurveSelection = state.selectedCurves.size > 0;
|
||||
const hasPointSelection = state.selectedPoints.size > 0;
|
||||
toggleSmoothBtn.disabled = !hasCurveSelection || !hasPointSelection;
|
||||
|
||||
if (!hasCurveSelection) {
|
||||
toggleSmoothBtn.textContent = "Soften Point";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasPointSelection) {
|
||||
toggleSmoothBtn.textContent = "Soften Point";
|
||||
return;
|
||||
}
|
||||
|
||||
// Check state of selected points within selected curves
|
||||
const shape = callbacks.getActiveShape ? callbacks.getActiveShape() : null;
|
||||
if (!shape) {
|
||||
toggleSmoothBtn.textContent = "Soften Point";
|
||||
return;
|
||||
}
|
||||
|
||||
const frameKey = String(state.currentFrame);
|
||||
const curves = shape.frames && shape.frames[frameKey]
|
||||
? shape.frames[frameKey]
|
||||
: (callbacks.evaluateFrame ? callbacks.evaluateFrame(shape, state.currentFrame, { hold: true }) : []);
|
||||
|
||||
let allHard = true;
|
||||
let allSmooth = true;
|
||||
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId, pointIndex] = pointKey.split(":");
|
||||
|
||||
// Only check points in selected curves
|
||||
if (!state.selectedCurves.has(curveId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const curve = curves.find(c => c.id === curveId);
|
||||
if (!curve) {
|
||||
// Curve doesn't exist - skip (selection might be stale)
|
||||
return;
|
||||
}
|
||||
|
||||
// Get pointTypes with validation
|
||||
let types;
|
||||
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
|
||||
types = curve.pointTypes;
|
||||
} else {
|
||||
types = curve.points.map(() => "hard");
|
||||
}
|
||||
|
||||
const idx = parseInt(pointIndex);
|
||||
if (idx >= 0 && idx < curve.points.length && idx < types.length) {
|
||||
const type = types[idx];
|
||||
// Validate type is valid
|
||||
if (type === "smooth") {
|
||||
allHard = false;
|
||||
} else if (type === "hard") {
|
||||
allSmooth = false;
|
||||
}
|
||||
// If invalid type, treat as "hard" (defensive)
|
||||
}
|
||||
});
|
||||
|
||||
if (allHard) {
|
||||
toggleSmoothBtn.textContent = "Soften Point";
|
||||
} else if (allSmooth) {
|
||||
toggleSmoothBtn.textContent = "Sharpen Point";
|
||||
} else {
|
||||
toggleSmoothBtn.textContent = "Soften Point"; // Mixed: default to softening
|
||||
}
|
||||
}
|
||||
|
||||
toggleSmoothBtn.onclick = () => {
|
||||
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
|
||||
if (callbacks.togglePointSmoothness) {
|
||||
callbacks.togglePointSmoothness();
|
||||
}
|
||||
};
|
||||
|
||||
toolButtonsSection.appendChild(drawBtn);
|
||||
toolButtonsSection.appendChild(editBtn);
|
||||
toolButtonsSection.appendChild(addPointBtn);
|
||||
toolButtonsSection.appendChild(toggleSmoothBtn);
|
||||
|
||||
// Section 3: Timeline (slider + play button)
|
||||
const timelineSection = document.createElement("div");
|
||||
timelineSection.style.display = "flex";
|
||||
timelineSection.style.gap = "8px";
|
||||
timelineSection.style.alignItems = "center";
|
||||
timelineSection.style.width = "100%";
|
||||
timelineSection.style.padding = "8px 0";
|
||||
timelineSection.style.borderTop = "1px solid var(--border-color)";
|
||||
timelineSection.style.borderBottom = "1px solid var(--border-color)";
|
||||
timelineSection.style.margin = "4px 0";
|
||||
|
||||
// Frame counter display
|
||||
const frameCounter = document.createElement("div");
|
||||
frameCounter.style.display = "flex";
|
||||
frameCounter.style.alignItems = "center";
|
||||
frameCounter.style.gap = "4px";
|
||||
frameCounter.style.minWidth = "80px";
|
||||
frameCounter.style.fontSize = "12px";
|
||||
frameCounter.style.fontWeight = "500";
|
||||
frameCounter.style.color = "var(--fg-color)";
|
||||
|
||||
const frameNumber = document.createElement("span");
|
||||
frameNumber.textContent = "1";
|
||||
frameNumber.style.minWidth = "30px";
|
||||
frameNumber.style.textAlign = "right";
|
||||
|
||||
const frameSeparator = document.createElement("span");
|
||||
frameSeparator.textContent = "/";
|
||||
frameSeparator.style.opacity = "0.5";
|
||||
|
||||
const frameMax = document.createElement("span");
|
||||
frameMax.textContent = "1";
|
||||
frameMax.style.minWidth = "30px";
|
||||
frameMax.style.textAlign = "left";
|
||||
frameMax.style.opacity = "0.7";
|
||||
|
||||
frameCounter.appendChild(frameNumber);
|
||||
frameCounter.appendChild(frameSeparator);
|
||||
frameCounter.appendChild(frameMax);
|
||||
|
||||
// Divider line
|
||||
const divider1 = document.createElement("div");
|
||||
divider1.style.width = "1px";
|
||||
divider1.style.height = "24px";
|
||||
divider1.style.backgroundColor = "var(--border-color)";
|
||||
divider1.style.opacity = "0.5";
|
||||
|
||||
const frameSlider = document.createElement("input");
|
||||
frameSlider.type = "range";
|
||||
frameSlider.min = "1";
|
||||
frameSlider.max = "1";
|
||||
frameSlider.step = "1";
|
||||
frameSlider.value = "1";
|
||||
frameSlider.style.flex = "1 1 auto";
|
||||
frameSlider.style.height = "6px";
|
||||
frameSlider.style.cursor = "pointer";
|
||||
|
||||
// Divider line
|
||||
const divider2 = document.createElement("div");
|
||||
divider2.style.width = "1px";
|
||||
divider2.style.height = "24px";
|
||||
divider2.style.backgroundColor = "var(--border-color)";
|
||||
divider2.style.opacity = "0.5";
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
playBtn.textContent = "▶";
|
||||
playBtn.className = "comfy-button";
|
||||
playBtn.type = "button";
|
||||
playBtn.style.minWidth = "40px";
|
||||
playBtn.style.width = "40px";
|
||||
playBtn.style.padding = "4px 8px";
|
||||
playBtn.style.fontSize = "14px";
|
||||
|
||||
let playing = false;
|
||||
let lastTick = 0;
|
||||
const fps = 24;
|
||||
|
||||
function updateFrameLabel() {
|
||||
frameNumber.textContent = String(state.currentFrame);
|
||||
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
|
||||
frameMax.textContent = String(maxFrame);
|
||||
}
|
||||
|
||||
function safeWidgetCallback(widget, value) {
|
||||
if (widget && typeof widget.callback === "function") {
|
||||
try {
|
||||
widget.callback.call(widget, value, null, node);
|
||||
} catch (error) {
|
||||
console.warn("[ComfyUI Roto] Frame widget callback failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setFrame(frameIndex) {
|
||||
const maxFrame = Math.max(1, Number(frameSlider.max || 1));
|
||||
const nextFrame = Math.max(1, Math.min(maxFrame, frameIndex));
|
||||
if (nextFrame === state.currentFrame) return;
|
||||
state.currentFrame = nextFrame;
|
||||
frameSlider.value = String(nextFrame);
|
||||
updateFrameLabel();
|
||||
callbacks.redraw();
|
||||
|
||||
const widgets = node.widgets || [];
|
||||
const frameWidget = widgets.find(w => w.name === "frame_index");
|
||||
if (frameWidget) {
|
||||
frameWidget.value = nextFrame;
|
||||
if (!state.updatingFrame) {
|
||||
state.updatingFrame = true;
|
||||
safeWidgetCallback(frameWidget, nextFrame);
|
||||
state.updatingFrame = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tick(ts) {
|
||||
if (!playing) return;
|
||||
if (!lastTick) lastTick = ts;
|
||||
const interval = 1000 / fps;
|
||||
if (ts - lastTick >= interval) {
|
||||
lastTick = ts;
|
||||
const maxFrame = Number(frameSlider.max || 1);
|
||||
const next = (state.currentFrame + 1) > maxFrame ? 1 : state.currentFrame + 1;
|
||||
setFrame(next);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
playBtn.onclick = () => {
|
||||
playing = !playing;
|
||||
playBtn.textContent = playing ? "⏸" : "▶";
|
||||
if (playing) {
|
||||
lastTick = 0;
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
frameSlider.oninput = (e) => {
|
||||
const value = Number(e.target.value || 0);
|
||||
setFrame(value);
|
||||
};
|
||||
|
||||
// Assemble timeline section
|
||||
timelineSection.appendChild(frameCounter);
|
||||
timelineSection.appendChild(divider1);
|
||||
timelineSection.appendChild(frameSlider);
|
||||
timelineSection.appendChild(divider2);
|
||||
timelineSection.appendChild(playBtn);
|
||||
|
||||
return {
|
||||
// Sections for layout
|
||||
loadFramesSection,
|
||||
toolButtonsSection,
|
||||
timelineSection,
|
||||
settingsSection,
|
||||
// Individual controls (for backward compatibility)
|
||||
featherSlider,
|
||||
featherValueLabel,
|
||||
bgColorInput,
|
||||
maskColorInput,
|
||||
maskOpacitySlider,
|
||||
maskOpacityValueLabel,
|
||||
frameSlider,
|
||||
addPointBtn,
|
||||
toggleSmoothBtn,
|
||||
drawBtn,
|
||||
editBtn,
|
||||
playBtn,
|
||||
// Frame output controls
|
||||
startFrameInput,
|
||||
endFrameInput,
|
||||
// Functions
|
||||
updateFrameLabel,
|
||||
updateAddPointButton,
|
||||
updateToggleSmoothButton,
|
||||
updateFrameRangeInfo,
|
||||
setFrame
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Curve management functions
|
||||
*/
|
||||
|
||||
import { generateUUID } from "../utils/uuid.js";
|
||||
import { deepCopy } from "../utils/helpers.js";
|
||||
import { evaluateFrame } from "./frameEvaluator.js";
|
||||
|
||||
/**
|
||||
* Get the next available shape number
|
||||
* @param {Object} state - Editor state
|
||||
* @returns {number} Next available shape number
|
||||
*/
|
||||
function getNextShapeNumber(state) {
|
||||
const existingNumbers = new Set();
|
||||
|
||||
// Extract numbers from existing shape names
|
||||
Object.values(state.shapes || {}).forEach(shape => {
|
||||
if (shape && shape.name) {
|
||||
const match = shape.name.match(/^Shape\s+(\d+)$/);
|
||||
if (match) {
|
||||
existingNumbers.add(parseInt(match[1], 10));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the next available number
|
||||
let nextNumber = 1;
|
||||
while (existingNumbers.has(nextNumber)) {
|
||||
nextNumber++;
|
||||
}
|
||||
|
||||
return nextNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default shape in state
|
||||
* @param {Object} state - Editor state
|
||||
*/
|
||||
export function initializeDefaultShape(state) {
|
||||
const shapeId = generateUUID();
|
||||
const shapeNumber = getNextShapeNumber(state);
|
||||
state.activeShapeId = shapeId;
|
||||
state.shapes[shapeId] = {
|
||||
name: `Shape ${shapeNumber}`,
|
||||
frames: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active shape, initializing if needed
|
||||
* @param {Object} state - Editor state
|
||||
* @returns {Object} Active shape object
|
||||
*/
|
||||
export function getActiveShape(state) {
|
||||
if (!state.activeShapeId || !state.shapes[state.activeShapeId]) {
|
||||
initializeDefaultShape(state);
|
||||
}
|
||||
return state.shapes[state.activeShapeId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get curves for a specific frame
|
||||
* @param {Object} state - Editor state
|
||||
* @param {number} frame - Frame number
|
||||
* @returns {Array} Curves for the frame
|
||||
*/
|
||||
export function getCurvesForFrame(state, frame) {
|
||||
const shape = getActiveShape(state);
|
||||
const frameKey = String(frame);
|
||||
return shape.frames[frameKey] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote a specific curve to the current frame as a keyframe.
|
||||
* If curveId is provided, only that curve is promoted.
|
||||
* If not provided, only interpolated curves are promoted (not held ones).
|
||||
*
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Function} evaluateFrameFn - Function to evaluate frame (to avoid circular dependency)
|
||||
* @param {Function} deepCopyFn - Deep copy function
|
||||
* @param {string|null} curveId - Optional curve ID to promote
|
||||
*/
|
||||
export function promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId = null) {
|
||||
const shape = getActiveShape(state);
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// If already a keyframe, check if we need to add a specific curve
|
||||
if (shape.frames[frameKey]) {
|
||||
if (curveId) {
|
||||
// Check if this curve already exists at this keyframe
|
||||
const exists = shape.frames[frameKey].some(c => c.id === curveId);
|
||||
if (!exists) {
|
||||
// Find this curve from evaluated frame and add it
|
||||
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
|
||||
const curve = allCurves.find(c => c.id === curveId);
|
||||
if (curve) {
|
||||
shape.frames[frameKey].push(deepCopyFn(curve));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Frame doesn't exist yet - create it
|
||||
if (curveId) {
|
||||
// Only promote the specific curve being edited
|
||||
const allCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
|
||||
const curve = allCurves.find(c => c.id === curveId);
|
||||
shape.frames[frameKey] = curve ? [deepCopyFn(curve)] : [];
|
||||
} else {
|
||||
// Get interpolated curves only (not held curves)
|
||||
const curves = evaluateFrameFn(shape, state.currentFrame, { hold: false });
|
||||
shape.frames[frameKey] = deepCopyFn(curves);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a point to a curve at a specific edge.
|
||||
* IMPORTANT: Adds point to ALL keyframes to keep point counts in sync.
|
||||
*
|
||||
* @param {Object} state - Editor state
|
||||
* @param {string} curveId - Curve ID
|
||||
* @param {number} edgeIndex - Index of the edge (point index)
|
||||
* @param {{x: number, y: number}|null} position - Optional position to add point at
|
||||
* @param {Function} promoteToKeyframeFn - Function to promote to keyframe
|
||||
* @param {Function} evaluateFrameFn - Function to evaluate frame
|
||||
* @param {Function} deepCopyFn - Deep copy function
|
||||
* @param {Function} commitFrameFn - Function to commit frame
|
||||
* @param {Function} redrawFn - Function to redraw
|
||||
* @param {Function} updateAddPointButtonFn - Function to update add point button
|
||||
*/
|
||||
export function addPointToCurveAtEdge(
|
||||
state,
|
||||
curveId,
|
||||
edgeIndex,
|
||||
position,
|
||||
promoteToKeyframeFn,
|
||||
evaluateFrameFn,
|
||||
deepCopyFn,
|
||||
commitFrameFn,
|
||||
redrawFn,
|
||||
updateAddPointButtonFn
|
||||
) {
|
||||
const shape = getActiveShape(state);
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// First, ensure current frame has this curve as a keyframe
|
||||
promoteToKeyframeFn(state, evaluateFrameFn, deepCopyFn, curveId);
|
||||
|
||||
// Get the actual keyframe curve (not interpolated) - refresh after promotion
|
||||
if (!shape.frames[frameKey]) {
|
||||
shape.frames[frameKey] = [];
|
||||
}
|
||||
const curves = shape.frames[frameKey];
|
||||
let curve = curves.find(c => c.id === curveId);
|
||||
|
||||
// If curve still doesn't exist, something went wrong - try to get from evaluated
|
||||
if (!curve) {
|
||||
const evaluatedCurves = evaluateFrameFn(shape, state.currentFrame, { hold: true });
|
||||
const evaluatedCurve = evaluatedCurves.find(c => c.id === curveId);
|
||||
if (evaluatedCurve) {
|
||||
// Add it to keyframe
|
||||
curve = deepCopyFn(evaluatedCurve);
|
||||
curves.push(curve);
|
||||
}
|
||||
}
|
||||
|
||||
if (!curve || curve.points.length < 2) return;
|
||||
|
||||
// Calculate t parameter (0-1) for where on the edge the point is being added
|
||||
const p1 = curve.points[edgeIndex];
|
||||
const p2 = curve.points[(edgeIndex + 1) % curve.points.length];
|
||||
let t = 0.5; // default to midpoint
|
||||
|
||||
if (position) {
|
||||
// Calculate t based on position along the edge
|
||||
const edgeLength = Math.sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2);
|
||||
if (edgeLength > 0) {
|
||||
const posLength = Math.sqrt((position.x - p1[0]) ** 2 + (position.y - p1[1]) ** 2);
|
||||
t = Math.max(0, Math.min(1, posLength / edgeLength));
|
||||
}
|
||||
}
|
||||
|
||||
// Add point to ALL keyframes that contain this curve
|
||||
Object.keys(shape.frames).forEach(fKey => {
|
||||
const frameCurves = shape.frames[fKey] || [];
|
||||
const frameCurve = frameCurves.find(c => c.id === curveId);
|
||||
|
||||
if (frameCurve && frameCurve.points.length > edgeIndex) {
|
||||
const fp1 = frameCurve.points[edgeIndex];
|
||||
const fp2 = frameCurve.points[(edgeIndex + 1) % frameCurve.points.length];
|
||||
|
||||
// Calculate new point position using same t parameter
|
||||
const newX = fp1[0] + t * (fp2[0] - fp1[0]);
|
||||
const newY = fp1[1] + t * (fp2[1] - fp1[1]);
|
||||
|
||||
// Insert new point after edgeIndex
|
||||
frameCurve.points.splice(edgeIndex + 1, 0, [newX, newY]);
|
||||
|
||||
// Also insert "hard" into pointTypes array at same index
|
||||
if (!Array.isArray(frameCurve.pointTypes) || frameCurve.pointTypes.length !== frameCurve.points.length - 1) {
|
||||
// Reinitialize if missing or wrong length (before adding new point)
|
||||
frameCurve.pointTypes = frameCurve.points.slice(0, -1).map(() => "hard");
|
||||
}
|
||||
frameCurve.pointTypes.splice(edgeIndex + 1, 0, "hard");
|
||||
}
|
||||
});
|
||||
|
||||
// Exit add point mode
|
||||
state.addPointMode = false;
|
||||
if (updateAddPointButtonFn) {
|
||||
updateAddPointButtonFn();
|
||||
}
|
||||
|
||||
if (commitFrameFn) {
|
||||
commitFrameFn();
|
||||
}
|
||||
if (redrawFn) {
|
||||
redrawFn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit current frame (ensure frame exists in shape)
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Function} updateNodeInputFn - Function to update node input
|
||||
*/
|
||||
export function commitFrame(state, updateNodeInputFn) {
|
||||
const shape = getActiveShape(state);
|
||||
const frameKey = String(state.currentFrame);
|
||||
if (!shape.frames[frameKey]) {
|
||||
shape.frames[frameKey] = [];
|
||||
}
|
||||
if (updateNodeInputFn) {
|
||||
updateNodeInputFn();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Frame evaluation and interpolation logic
|
||||
*/
|
||||
|
||||
import { deepCopy } from "../utils/helpers.js";
|
||||
|
||||
/**
|
||||
* Get interpolated pointTypes array from two curves.
|
||||
* Since pointTypes should be the same across all keyframes, we prefer curveA's types.
|
||||
* @param {Object} curveA - First curve (earlier keyframe)
|
||||
* @param {Object} curveB - Second curve (later keyframe)
|
||||
* @param {number} totalPoints - Total point count in the interpolated result
|
||||
* @returns {Array<string>} Array of point types
|
||||
*/
|
||||
function getInterpolatedPointTypes(curveA, curveB, totalPoints) {
|
||||
// Since pointTypes should be consistent across keyframes, prefer curveA
|
||||
// but fall back to curveB if curveA doesn't have valid types
|
||||
const typesA = Array.isArray(curveA.pointTypes) ? curveA.pointTypes : [];
|
||||
const typesB = Array.isArray(curveB.pointTypes) ? curveB.pointTypes : [];
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < totalPoints; i++) {
|
||||
if (i < typesA.length && (typesA[i] === "smooth" || typesA[i] === "hard")) {
|
||||
result.push(typesA[i]);
|
||||
} else if (i < typesB.length && (typesB[i] === "smooth" || typesB[i] === "hard")) {
|
||||
result.push(typesB[i]);
|
||||
} else {
|
||||
result.push("hard"); // Default for any missing entries
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate curves for a given frame.
|
||||
* Each curve is interpolated independently based on its own keyframes.
|
||||
*
|
||||
* @param {Object} shape - Shape object with frames
|
||||
* @param {number} frameIndex - Frame number to evaluate
|
||||
* @param {Object} options - Evaluation options
|
||||
* @param {boolean} options.hold - If true, hold curves from nearest keyframe when no interpolation possible
|
||||
* @returns {Array} List of curve objects for this frame
|
||||
*/
|
||||
export function evaluateFrame(shape, frameIndex, options = {}) {
|
||||
const frames = shape.frames || {};
|
||||
const frameKey = String(frameIndex);
|
||||
const hold = Boolean(options.hold);
|
||||
|
||||
// Collect all unique curve IDs across all keyframes
|
||||
const allCurveIds = new Set();
|
||||
const curveKeyframes = new Map(); // curveId -> sorted array of frame numbers where it exists
|
||||
|
||||
Object.entries(frames).forEach(([frame, curves]) => {
|
||||
const frameNum = parseInt(frame);
|
||||
if (isNaN(frameNum)) return;
|
||||
(curves || []).forEach(c => {
|
||||
if (c && c.id) {
|
||||
allCurveIds.add(c.id);
|
||||
if (!curveKeyframes.has(c.id)) {
|
||||
curveKeyframes.set(c.id, []);
|
||||
}
|
||||
curveKeyframes.get(c.id).push(frameNum);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort keyframes for each curve
|
||||
curveKeyframes.forEach((frames, id) => {
|
||||
frames.sort((a, b) => a - b);
|
||||
});
|
||||
|
||||
if (allCurveIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
// Evaluate each curve independently based on its own keyframes
|
||||
for (const curveId of allCurveIds) {
|
||||
const keyframes = curveKeyframes.get(curveId) || [];
|
||||
if (keyframes.length === 0) continue;
|
||||
|
||||
// Check for exact keyframe for this curve
|
||||
if (keyframes.includes(frameIndex)) {
|
||||
const frameCurves = frames[frameKey] || [];
|
||||
const curve = frameCurves.find(c => c.id === curveId);
|
||||
if (curve) {
|
||||
result.push(deepCopy(curve));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find prev and next keyframes FOR THIS CURVE
|
||||
let prevFrame = null;
|
||||
let nextFrame = null;
|
||||
|
||||
for (const fn of keyframes) {
|
||||
if (fn < frameIndex) {
|
||||
prevFrame = fn;
|
||||
} else if (fn > frameIndex) {
|
||||
nextFrame = fn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate or hold based on this curve's keyframes
|
||||
if (prevFrame !== null && nextFrame !== null) {
|
||||
// Interpolate between this curve's keyframes
|
||||
const curveA = (frames[String(prevFrame)] || []).find(c => c.id === curveId);
|
||||
const curveB = (frames[String(nextFrame)] || []).find(c => c.id === curveId);
|
||||
|
||||
if (curveA && curveB) {
|
||||
const t = (frameIndex - prevFrame) / (nextFrame - prevFrame);
|
||||
const interpolatedPoints = [];
|
||||
|
||||
// Handle different point counts - use the smaller count
|
||||
const minPoints = Math.min(curveA.points.length, curveB.points.length);
|
||||
for (let i = 0; i < minPoints; i++) {
|
||||
const pa = curveA.points[i];
|
||||
const pb = curveB.points[i];
|
||||
const x = pa[0] + (pb[0] - pa[0]) * t;
|
||||
const y = pa[1] + (pb[1] - pa[1]) * t;
|
||||
interpolatedPoints.push([x, y]);
|
||||
}
|
||||
|
||||
// If one curve has more points, add them at the end (hold from that curve)
|
||||
if (curveA.points.length > minPoints) {
|
||||
for (let i = minPoints; i < curveA.points.length; i++) {
|
||||
interpolatedPoints.push([...curveA.points[i]]);
|
||||
}
|
||||
} else if (curveB.points.length > minPoints) {
|
||||
for (let i = minPoints; i < curveB.points.length; i++) {
|
||||
interpolatedPoints.push([...curveB.points[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: curveA.id,
|
||||
closed: curveA.closed,
|
||||
points: interpolatedPoints,
|
||||
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
|
||||
});
|
||||
}
|
||||
} else if (hold) {
|
||||
// Hold from nearest keyframe for this curve
|
||||
const holdFrame = prevFrame !== null ? prevFrame : nextFrame;
|
||||
if (holdFrame !== null) {
|
||||
const curve = (frames[String(holdFrame)] || []).find(c => c.id === curveId);
|
||||
if (curve) {
|
||||
result.push(deepCopy(curve));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate curves between two keyframes.
|
||||
* Curves that only exist in one keyframe are held (included as-is).
|
||||
*
|
||||
* @param {Array} curvesA - Curves at earlier keyframe
|
||||
* @param {Array} curvesB - Curves at later keyframe
|
||||
* @param {number} frameA - Earlier frame number
|
||||
* @param {number} frameB - Later frame number
|
||||
* @param {number} targetFrame - Frame to interpolate to
|
||||
* @returns {Array} List of interpolated curves
|
||||
*/
|
||||
export function interpolateCurves(curvesA, curvesB, frameA, frameB, targetFrame) {
|
||||
const t = (targetFrame - frameA) / (frameB - frameA);
|
||||
const result = [];
|
||||
const processedIds = new Set();
|
||||
|
||||
// Process curves from A - interpolate if in both, hold if only in A
|
||||
for (const curveA of curvesA) {
|
||||
processedIds.add(curveA.id);
|
||||
const curveB = curvesB.find(cb => cb.id === curveA.id);
|
||||
|
||||
if (curveB) {
|
||||
// Curve exists in both keyframes - interpolate
|
||||
const interpolatedPoints = [];
|
||||
|
||||
// Handle different point counts - use the smaller count
|
||||
const minPoints = Math.min(curveA.points.length, curveB.points.length);
|
||||
for (let i = 0; i < minPoints; i++) {
|
||||
const pa = curveA.points[i];
|
||||
const pb = curveB.points[i];
|
||||
const x = pa[0] + (pb[0] - pa[0]) * t;
|
||||
const y = pa[1] + (pb[1] - pa[1]) * t;
|
||||
interpolatedPoints.push([x, y]);
|
||||
}
|
||||
|
||||
// If one curve has more points, add them at the end (hold from that curve)
|
||||
if (curveA.points.length > minPoints) {
|
||||
for (let i = minPoints; i < curveA.points.length; i++) {
|
||||
interpolatedPoints.push([...curveA.points[i]]);
|
||||
}
|
||||
} else if (curveB.points.length > minPoints) {
|
||||
for (let i = minPoints; i < curveB.points.length; i++) {
|
||||
interpolatedPoints.push([...curveB.points[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: curveA.id,
|
||||
closed: curveA.closed,
|
||||
points: interpolatedPoints,
|
||||
pointTypes: getInterpolatedPointTypes(curveA, curveB, interpolatedPoints.length)
|
||||
});
|
||||
} else {
|
||||
// Curve only in A - hold it (use A's position)
|
||||
result.push(deepCopy(curveA));
|
||||
}
|
||||
}
|
||||
|
||||
// Process curves only in B (not in A) - hold them
|
||||
for (const curveB of curvesB) {
|
||||
if (!processedIds.has(curveB.id)) {
|
||||
result.push(deepCopy(curveB));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* State management for the roto editor
|
||||
*/
|
||||
|
||||
import { generateUUID } from "../utils/uuid.js";
|
||||
|
||||
/**
|
||||
* Initialize the editor state
|
||||
* @returns {Object} Initial state object
|
||||
*/
|
||||
export function createState() {
|
||||
const shapeId = generateUUID();
|
||||
return {
|
||||
currentFrame: 1,
|
||||
activeShapeId: shapeId,
|
||||
shapes: {
|
||||
[shapeId]: {
|
||||
name: "Shape 1",
|
||||
frames: {}
|
||||
}
|
||||
},
|
||||
selectedCurves: new Set(),
|
||||
selectedPoints: new Set(), // Format: "curveId:pointIndex"
|
||||
dragging: false,
|
||||
dragStart: null,
|
||||
mode: "draw", // "draw" or "edit"
|
||||
addPointMode: false, // true when user clicked "Add Point" button and is ready to click on edge
|
||||
canvasWidth: 512,
|
||||
canvasHeight: 512,
|
||||
previewImage: null,
|
||||
previewSourceId: null,
|
||||
frameImages: [],
|
||||
frameImageCache: new Map(),
|
||||
updatingFrame: false,
|
||||
featherAmount: 0.0,
|
||||
backgroundColor: "#000000",
|
||||
maskColor: "#ffffff",
|
||||
maskOpacity: 10, // 0-100 integer
|
||||
selectionBox: null // { start: {x, y}, end: {x, y}, shiftKey: boolean } or null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Document-level event listeners for dragging outside canvas
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create document listener management functions
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Function} handleDragMoveFn - Function to handle drag move
|
||||
* @returns {Object} Functions to attach/remove document listeners
|
||||
*/
|
||||
export function createDocumentListeners(state, handleDragMoveFn) {
|
||||
let documentMouseMoveHandler = null;
|
||||
let documentMouseUpHandler = null;
|
||||
|
||||
function attachDocumentListeners() {
|
||||
if (documentMouseMoveHandler || documentMouseUpHandler) {
|
||||
// Already attached, skip
|
||||
return;
|
||||
}
|
||||
|
||||
documentMouseMoveHandler = (e) => {
|
||||
handleDragMoveFn(e);
|
||||
};
|
||||
|
||||
documentMouseUpHandler = (e) => {
|
||||
if (state.dragging) {
|
||||
state.dragging = false;
|
||||
state.dragStart = null;
|
||||
removeDocumentListeners();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", documentMouseMoveHandler);
|
||||
document.addEventListener("mouseup", documentMouseUpHandler);
|
||||
}
|
||||
|
||||
function removeDocumentListeners() {
|
||||
if (documentMouseMoveHandler) {
|
||||
document.removeEventListener("mousemove", documentMouseMoveHandler);
|
||||
documentMouseMoveHandler = null;
|
||||
}
|
||||
if (documentMouseUpHandler) {
|
||||
document.removeEventListener("mouseup", documentMouseUpHandler);
|
||||
documentMouseUpHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
attachDocumentListeners,
|
||||
removeDocumentListeners
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Keyboard event handlers
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create keyboard event handler
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @param {Function} callbacks.setMode - Set editor mode
|
||||
* @param {Function} callbacks.getActiveShape - Get active shape
|
||||
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
|
||||
* @param {Function} callbacks.commitFrame - Commit frame
|
||||
* @param {Function} callbacks.redraw - Redraw canvas
|
||||
* @param {Function} callbacks.removeDocumentListeners - Remove document listeners
|
||||
* @param {Function} callbacks.updateAddPointButton - Update add point button
|
||||
*/
|
||||
export function createKeyboardHandlers(container, state, callbacks) {
|
||||
function handleKeydown(e) {
|
||||
// Only handle if not typing in an input
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "b" || e.key === "B") {
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
callbacks.setMode("draw");
|
||||
e.preventDefault();
|
||||
} else if (e.key === "v" || e.key === "V") {
|
||||
callbacks.setMode("edit");
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (state.mode === "edit" && state.selectedCurves.size > 0) {
|
||||
const shape = callbacks.getActiveShape();
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// Promote only the selected curves to be deleted
|
||||
state.selectedCurves.forEach(curveId => {
|
||||
callbacks.promoteToKeyframe(curveId);
|
||||
});
|
||||
|
||||
const curves = shape.frames[frameKey] || [];
|
||||
|
||||
state.selectedCurves.forEach(curveId => {
|
||||
const index = curves.findIndex(c => c.id === curveId);
|
||||
if (index !== -1) {
|
||||
curves.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
callbacks.commitFrame();
|
||||
callbacks.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
// Cancel selection box if active
|
||||
if (state.selectionBox !== null) {
|
||||
state.selectionBox = null;
|
||||
}
|
||||
// Cancel add point mode if active
|
||||
if (state.addPointMode) {
|
||||
state.addPointMode = false;
|
||||
callbacks.updateAddPointButton();
|
||||
}
|
||||
// Cancel dragging if active
|
||||
if (state.dragging) {
|
||||
state.dragging = false;
|
||||
state.dragStart = null;
|
||||
callbacks.removeDocumentListeners();
|
||||
}
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
callbacks.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts (scoped to container, with document fallback)
|
||||
container.addEventListener("keydown", handleKeydown);
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Mouse event handlers for canvas
|
||||
*/
|
||||
|
||||
import { getMousePos } from "../utils/canvas.js";
|
||||
import { generateUUID } from "../utils/uuid.js";
|
||||
import { evaluateFrame } from "../core/frameEvaluator.js";
|
||||
import { pointToLineSegmentDistance } from "../renderer/drawCurve.js";
|
||||
|
||||
/**
|
||||
* Create mouse event handlers for canvas
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @param {HTMLElement} container - Container element
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @param {Function} callbacks.getActiveShape - Get active shape
|
||||
* @param {Function} callbacks.promoteToKeyframe - Promote to keyframe
|
||||
* @param {Function} callbacks.commitFrame - Commit frame
|
||||
* @param {Function} callbacks.redraw - Redraw canvas
|
||||
* @param {Function} callbacks.addPointToCurveAtEdge - Add point to curve at edge
|
||||
* @param {Function} callbacks.updateAddPointButton - Update add point button
|
||||
* @param {Function} callbacks.attachDocumentListeners - Attach document listeners
|
||||
* @returns {Function} Function to handle drag move
|
||||
*/
|
||||
export function createMouseHandlers(canvas, container, state, callbacks) {
|
||||
// Shared function to handle dragging
|
||||
function handleDragMove(e) {
|
||||
if (!state.dragging || state.mode !== "edit") return;
|
||||
|
||||
const pos = getMousePos(e, canvas);
|
||||
|
||||
const deltaX = pos.x - state.dragStart.x;
|
||||
const deltaY = pos.y - state.dragStart.y;
|
||||
|
||||
const shape = callbacks.getActiveShape();
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// Promote only the curves being edited
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId] = pointKey.split(":");
|
||||
callbacks.promoteToKeyframe(curveId);
|
||||
});
|
||||
|
||||
const curves = shape.frames[frameKey] || [];
|
||||
|
||||
// Move selected points
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId, pointIndex] = pointKey.split(":");
|
||||
const curve = curves.find(c => c.id === curveId);
|
||||
if (curve && curve.points[parseInt(pointIndex)]) {
|
||||
const point = curve.points[parseInt(pointIndex)];
|
||||
point[0] = Math.max(0, Math.min(canvas.width, point[0] + deltaX));
|
||||
point[1] = Math.max(0, Math.min(canvas.height, point[1] + deltaY));
|
||||
}
|
||||
});
|
||||
|
||||
state.dragStart = pos;
|
||||
callbacks.commitFrame();
|
||||
callbacks.redraw();
|
||||
}
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
// Ensure keyboard shortcuts work after interaction
|
||||
container.focus();
|
||||
const pos = getMousePos(e, canvas);
|
||||
|
||||
if (state.mode === "draw") {
|
||||
// Create new curve - don't promote other curves, just ensure frame exists
|
||||
const shape = callbacks.getActiveShape();
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
if (!shape.frames[frameKey]) {
|
||||
shape.frames[frameKey] = [];
|
||||
}
|
||||
|
||||
// Create 4-point box (rectangle) centered on click
|
||||
// Clamp to canvas bounds
|
||||
const clampedX = Math.max(0, Math.min(canvas.width, pos.x));
|
||||
const clampedY = Math.max(0, Math.min(canvas.height, pos.y));
|
||||
const size = 50;
|
||||
const curve = {
|
||||
id: generateUUID(),
|
||||
closed: true,
|
||||
points: [
|
||||
[Math.max(0, clampedX - size), Math.max(0, clampedY - size)], // top-left
|
||||
[Math.min(canvas.width, clampedX + size), Math.max(0, clampedY - size)], // top-right
|
||||
[Math.min(canvas.width, clampedX + size), Math.min(canvas.height, clampedY + size)], // bottom-right
|
||||
[Math.max(0, clampedX - size), Math.min(canvas.height, clampedY + size)] // bottom-left
|
||||
],
|
||||
pointTypes: ["hard", "hard", "hard", "hard"] // Initialize all points as hard
|
||||
};
|
||||
|
||||
shape.frames[frameKey].push(curve);
|
||||
callbacks.commitFrame();
|
||||
callbacks.redraw();
|
||||
} else if (state.mode === "edit") {
|
||||
const shape = callbacks.getActiveShape();
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// Check for point hit using EVALUATED curves (includes held curves)
|
||||
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
|
||||
let hitPoint = null;
|
||||
let hitCurve = null;
|
||||
|
||||
for (const curve of evaluatedCurves) {
|
||||
for (let i = 0; i < curve.points.length; i++) {
|
||||
const point = curve.points[i];
|
||||
const dx = pos.x - point[0];
|
||||
const dy = pos.y - point[1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist < 10) {
|
||||
hitPoint = { curve, index: i };
|
||||
hitCurve = curve;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hitPoint) break;
|
||||
}
|
||||
|
||||
if (hitPoint) {
|
||||
// Start dragging point
|
||||
state.dragging = true;
|
||||
state.dragStart = pos;
|
||||
const pointKey = `${hitPoint.curve.id}:${hitPoint.index}`;
|
||||
|
||||
// Check if the clicked point is already selected
|
||||
const isPointAlreadySelected = state.selectedPoints.has(pointKey);
|
||||
|
||||
if (!e.shiftKey && !isPointAlreadySelected) {
|
||||
// Only clear selection if clicking a new point (not already selected)
|
||||
state.selectedPoints.clear();
|
||||
state.selectedCurves.clear();
|
||||
}
|
||||
|
||||
// Add the point to selection (if not already there)
|
||||
state.selectedPoints.add(pointKey);
|
||||
state.selectedCurves.add(hitPoint.curve.id);
|
||||
|
||||
// Attach document listeners to allow dragging outside canvas
|
||||
callbacks.attachDocumentListeners();
|
||||
|
||||
callbacks.redraw();
|
||||
|
||||
// Update toggle smooth button state
|
||||
if (callbacks.updateToggleSmoothButton) {
|
||||
callbacks.updateToggleSmoothButton();
|
||||
}
|
||||
} else {
|
||||
// Only check for edge hit if in add point mode
|
||||
if (state.addPointMode) {
|
||||
let hitEdge = null;
|
||||
let minDist = Infinity;
|
||||
const edgeHitThreshold = 15; // pixels - slightly larger for easier clicking
|
||||
|
||||
// Only check selected curves
|
||||
const selectedCurveId = Array.from(state.selectedCurves)[0];
|
||||
for (const curve of evaluatedCurves) {
|
||||
if (curve.id !== selectedCurveId) continue;
|
||||
|
||||
for (let i = 0; i < curve.points.length; i++) {
|
||||
const p1 = curve.points[i];
|
||||
const p2 = curve.points[(i + 1) % curve.points.length];
|
||||
|
||||
// Calculate distance from mouse to line segment
|
||||
const dist = pointToLineSegmentDistance(pos, p1, p2);
|
||||
|
||||
if (dist < edgeHitThreshold && dist < minDist) {
|
||||
minDist = dist;
|
||||
// Calculate closest point on line segment
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((pos.x - p1[0]) * (p2[0] - p1[0]) + (pos.y - p1[1]) * (p2[1] - p1[1])) /
|
||||
((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2)
|
||||
));
|
||||
const closestPoint = {
|
||||
x: p1[0] + t * (p2[0] - p1[0]),
|
||||
y: p1[1] + t * (p2[1] - p1[1])
|
||||
};
|
||||
|
||||
hitEdge = {
|
||||
curve: curve,
|
||||
edgeIndex: i,
|
||||
position: closestPoint
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitEdge) {
|
||||
// Add point at clicked location on edge
|
||||
if (callbacks.addPointToCurveAtEdge) {
|
||||
callbacks.addPointToCurveAtEdge(hitEdge.curve.id, hitEdge.edgeIndex, hitEdge.position);
|
||||
} else {
|
||||
console.error("[ComfyUI Roto] addPointToCurveAtEdge callback not available");
|
||||
}
|
||||
} else {
|
||||
// Clicked but missed the edge - cancel add point mode
|
||||
state.addPointMode = false;
|
||||
if (callbacks.updateAddPointButton) {
|
||||
callbacks.updateAddPointButton();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal edit mode - start selection box if clicking empty space
|
||||
// Clear selection box if it exists (shouldn't happen, but safety check)
|
||||
if (state.selectionBox) {
|
||||
state.selectionBox = null;
|
||||
}
|
||||
|
||||
// Start selection box
|
||||
state.selectionBox = {
|
||||
start: pos,
|
||||
end: pos,
|
||||
shiftKey: e.shiftKey
|
||||
};
|
||||
|
||||
// If Shift not held, clear existing selection
|
||||
if (!e.shiftKey) {
|
||||
state.selectedCurves.clear();
|
||||
state.selectedPoints.clear();
|
||||
}
|
||||
|
||||
callbacks.redraw();
|
||||
|
||||
// Update toggle smooth button state
|
||||
if (callbacks.updateToggleSmoothButton) {
|
||||
callbacks.updateToggleSmoothButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
// Check for selection box first (before dragging)
|
||||
if (state.selectionBox !== null && state.mode === "edit") {
|
||||
const pos = getMousePos(e, canvas);
|
||||
state.selectionBox.end = pos;
|
||||
callbacks.redraw();
|
||||
} else {
|
||||
// Handle point dragging
|
||||
handleDragMove(e);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
// Handle selection box completion
|
||||
if (state.selectionBox !== null && state.mode === "edit") {
|
||||
const shape = callbacks.getActiveShape();
|
||||
const evaluatedCurves = evaluateFrame(shape, state.currentFrame, { hold: true });
|
||||
|
||||
// Calculate normalized box bounds
|
||||
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
|
||||
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
|
||||
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
|
||||
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
|
||||
|
||||
// Find all points within selection box
|
||||
for (const curve of evaluatedCurves) {
|
||||
for (let i = 0; i < curve.points.length; i++) {
|
||||
const point = curve.points[i];
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
|
||||
// Check if point center is within box bounds
|
||||
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
|
||||
const pointKey = `${curve.id}:${i}`;
|
||||
state.selectedPoints.add(pointKey);
|
||||
state.selectedCurves.add(curve.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selection box
|
||||
state.selectionBox = null;
|
||||
callbacks.redraw();
|
||||
|
||||
// Update toggle smooth button state
|
||||
if (callbacks.updateToggleSmoothButton) {
|
||||
callbacks.updateToggleSmoothButton();
|
||||
}
|
||||
} else if (state.dragging) {
|
||||
state.dragging = false;
|
||||
state.dragStart = null;
|
||||
callbacks.removeDocumentListeners();
|
||||
}
|
||||
});
|
||||
|
||||
return handleDragMove;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Background image drawing functions
|
||||
*/
|
||||
|
||||
import { loadImageAsync } from "../utils/fileUpload.js";
|
||||
import { resizeCanvasToImage } from "../components/Canvas.js";
|
||||
|
||||
/**
|
||||
* Resolve preview image from a ComfyUI node
|
||||
* @param {Object} targetNode - ComfyUI node
|
||||
* @returns {HTMLImageElement|null} Preview image or null
|
||||
*/
|
||||
export function resolveNodePreviewImage(targetNode) {
|
||||
if (!targetNode) return null;
|
||||
if (targetNode.imgs && targetNode.imgs.length > 0) return targetNode.imgs[0];
|
||||
if (targetNode.images && targetNode.images.length > 0) return targetNode.images[0];
|
||||
if (targetNode._bgImage) return targetNode._bgImage;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected input node from ComfyUI graph
|
||||
* @param {Object} node - Current node
|
||||
* @param {Object} app - ComfyUI app instance
|
||||
* @returns {Object|null} Connected input node or null
|
||||
*/
|
||||
export function getConnectedInputNode(node, app) {
|
||||
const input = node.inputs && node.inputs[0];
|
||||
if (!input || input.link == null) return null;
|
||||
const link = app.graph && app.graph.links && app.graph.links[input.link];
|
||||
if (!link) return null;
|
||||
return app.graph.getNodeById(link.origin_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview image from connected input node
|
||||
* @param {Object} node - Current node
|
||||
* @param {Object} app - ComfyUI app instance
|
||||
* @returns {HTMLImageElement|null} Preview image or null
|
||||
*/
|
||||
export function getInputPreviewImage(node, app) {
|
||||
const inputNode = getConnectedInputNode(node, app);
|
||||
if (!inputNode) return null;
|
||||
return resolveNodePreviewImage(inputNode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get frame image for a given index
|
||||
* @param {Object} state - Editor state
|
||||
* @param {number} index - Frame index (0-based)
|
||||
* @returns {Promise<HTMLImageElement|null>} Loaded image or null
|
||||
*/
|
||||
export async function getFrameImageForIndex(state, index) {
|
||||
if (!state.frameImages.length) return null;
|
||||
const safeIndex = Math.max(0, Math.min(state.frameImages.length - 1, index));
|
||||
const entry = state.frameImages[safeIndex];
|
||||
if (!entry) return null;
|
||||
if (state.frameImageCache.has(entry.url)) {
|
||||
return state.frameImageCache.get(entry.url);
|
||||
}
|
||||
const img = await loadImageAsync(entry.url);
|
||||
state.frameImageCache.set(entry.url, img);
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw background image on canvas
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} node - ComfyUI node
|
||||
* @param {Object} app - ComfyUI app instance
|
||||
* @param {Function} resizeCanvasToImageFn - Function to resize canvas
|
||||
* @param {Function} redrawFn - Function to trigger redraw
|
||||
*/
|
||||
export function drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, redrawFn) {
|
||||
try {
|
||||
// If user loaded frame images, use those first
|
||||
if (state.frameImages.length > 0) {
|
||||
// Convert 1-based frame number to 0-based array index
|
||||
const index = (state.currentFrame - 1) % state.frameImages.length;
|
||||
const cached = state.frameImages[index];
|
||||
if (cached && state.frameImageCache.has(cached.url)) {
|
||||
const img = state.frameImageCache.get(cached.url);
|
||||
resizeCanvasToImageFn(canvas, state, img);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
} else {
|
||||
// Kick off async load; will be drawn on next redraw
|
||||
getFrameImageForIndex(state, index).then(() => {
|
||||
if (redrawFn) redrawFn();
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preferred: use upstream node preview image (ComfyUI uses imgs/images)
|
||||
const preview = getInputPreviewImage(node, app);
|
||||
if (preview) {
|
||||
// Cache preview to avoid repeated lookups
|
||||
if (preview !== state.previewImage) {
|
||||
state.previewImage = preview;
|
||||
}
|
||||
resizeCanvasToImageFn(canvas, state, state.previewImage);
|
||||
ctx.drawImage(state.previewImage, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: attempt to use raw input data if available
|
||||
if (node && typeof node.getInputData === "function") {
|
||||
const input = node.getInputData(0);
|
||||
if (!input) return;
|
||||
|
||||
if (input instanceof HTMLImageElement || input instanceof HTMLCanvasElement || input instanceof ImageBitmap) {
|
||||
ctx.drawImage(input, 0, 0, canvas.width, canvas.height);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.data && input.width && input.height) {
|
||||
const imageData = new ImageData(new Uint8ClampedArray(input.data), input.width, input.height);
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = input.width;
|
||||
tempCanvas.height = input.height;
|
||||
const tempCtx = tempCanvas.getContext("2d");
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
ctx.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[ComfyUI Roto] Unable to render background image:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Curve drawing functions
|
||||
*/
|
||||
|
||||
import { hexToRgb } from "../utils/helpers.js";
|
||||
|
||||
/**
|
||||
* Calculate distance from point to line segment
|
||||
* @param {{x: number, y: number}} point - Point coordinates
|
||||
* @param {Array<number>} lineStart - Line start point [x, y]
|
||||
* @param {Array<number>} lineEnd - Line end point [x, y]
|
||||
* @returns {number} Distance from point to line segment
|
||||
*/
|
||||
export function pointToLineSegmentDistance(point, lineStart, lineEnd) {
|
||||
const A = point.x - lineStart[0];
|
||||
const B = point.y - lineStart[1];
|
||||
const C = lineEnd[0] - lineStart[0];
|
||||
const D = lineEnd[1] - lineStart[1];
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const lenSq = C * C + D * D;
|
||||
let param = -1;
|
||||
if (lenSq !== 0) param = dot / lenSq;
|
||||
|
||||
let xx, yy;
|
||||
|
||||
if (param < 0) {
|
||||
xx = lineStart[0];
|
||||
yy = lineStart[1];
|
||||
} else if (param > 1) {
|
||||
xx = lineEnd[0];
|
||||
yy = lineEnd[1];
|
||||
} else {
|
||||
xx = lineStart[0] + param * C;
|
||||
yy = lineStart[1] + param * D;
|
||||
}
|
||||
|
||||
const dx = point.x - xx;
|
||||
const dy = point.y - yy;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw control points for a curve
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {Object} curve - Curve object
|
||||
* @param {Object} state - Editor state
|
||||
* @param {boolean} isSelected - Whether curve is selected
|
||||
*/
|
||||
export function drawPoints(ctx, curve, state, isSelected = false) {
|
||||
const pointRadius = 5;
|
||||
const selectedRadius = 7;
|
||||
|
||||
curve.points.forEach((point, index) => {
|
||||
const pointKey = `${curve.id}:${index}`;
|
||||
const isPointSelected = state.selectedPoints.has(pointKey);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(point[0], point[1], isPointSelected ? selectedRadius : pointRadius, 0, Math.PI * 2);
|
||||
|
||||
if (isPointSelected || isSelected) {
|
||||
ctx.fillStyle = "#4a9eff";
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#000000";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Add stroke outline only for specifically selected points (not just selected curves)
|
||||
if (isPointSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(point[0], point[1], selectedRadius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a single curve (polygon)
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {Object} curve - Curve object
|
||||
* @param {Object} state - Editor state
|
||||
* @param {boolean} isSelected - Whether curve is selected
|
||||
*/
|
||||
export function drawCurve(ctx, curve, state, isSelected = false) {
|
||||
if (!curve || !curve.points || curve.points.length < 2) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Get pointTypes with validation (default to all "hard" if missing or invalid)
|
||||
let pointTypes;
|
||||
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
|
||||
pointTypes = curve.pointTypes;
|
||||
} else {
|
||||
pointTypes = curve.points.map(() => "hard");
|
||||
}
|
||||
|
||||
// Draw polygon path with smooth curves where needed using Catmull-Rom style interpolation
|
||||
ctx.beginPath();
|
||||
|
||||
const n = curve.points.length;
|
||||
const points = curve.points;
|
||||
|
||||
// Helper to get point with wrap-around for closed curves
|
||||
const getPoint = (index) => {
|
||||
if (curve.closed) {
|
||||
return points[((index % n) + n) % n];
|
||||
} else {
|
||||
if (index < 0) return points[0];
|
||||
if (index >= n) return points[n - 1];
|
||||
return points[index];
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get point type with wrap-around
|
||||
const getType = (index) => {
|
||||
if (curve.closed) {
|
||||
return pointTypes[((index % n) + n) % n];
|
||||
} else {
|
||||
if (index < 0) return pointTypes[0];
|
||||
if (index >= n) return pointTypes[n - 1];
|
||||
return pointTypes[index];
|
||||
}
|
||||
};
|
||||
|
||||
// Start at first point
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
|
||||
// For each segment
|
||||
const segmentCount = curve.closed ? n : n - 1;
|
||||
for (let i = 0; i < segmentCount; i++) {
|
||||
const nextI = (i + 1) % n;
|
||||
|
||||
const p0 = points[i];
|
||||
const p1 = points[nextI];
|
||||
const type0 = getType(i);
|
||||
const type1 = getType(nextI);
|
||||
|
||||
// If both points are hard, use straight line
|
||||
if (type0 === "hard" && type1 === "hard") {
|
||||
ctx.lineTo(p1[0], p1[1]);
|
||||
} else {
|
||||
// At least one point is smooth - use cubic curve
|
||||
// Calculate control points based on neighboring points (Catmull-Rom style)
|
||||
|
||||
const prevP = getPoint(i - 1);
|
||||
const nextNextP = getPoint(i + 2);
|
||||
|
||||
// Tension factor (0.5 is Catmull-Rom default)
|
||||
const tension = 0.5;
|
||||
|
||||
// Calculate control point 1 (based on p0's tangent if smooth)
|
||||
let cp1x, cp1y;
|
||||
if (type0 === "smooth") {
|
||||
// Tangent at p0 based on prevP to p1
|
||||
cp1x = p0[0] + (p1[0] - prevP[0]) * tension / 2;
|
||||
cp1y = p0[1] + (p1[1] - prevP[1]) * tension / 2;
|
||||
} else {
|
||||
// Hard point - control point is closer to p0
|
||||
cp1x = p0[0] + (p1[0] - p0[0]) * 0.25;
|
||||
cp1y = p0[1] + (p1[1] - p0[1]) * 0.25;
|
||||
}
|
||||
|
||||
// Calculate control point 2 (based on p1's tangent if smooth)
|
||||
let cp2x, cp2y;
|
||||
if (type1 === "smooth") {
|
||||
// Tangent at p1 based on p0 to nextNextP
|
||||
cp2x = p1[0] - (nextNextP[0] - p0[0]) * tension / 2;
|
||||
cp2y = p1[1] - (nextNextP[1] - p0[1]) * tension / 2;
|
||||
} else {
|
||||
// Hard point - control point is closer to p1
|
||||
cp2x = p1[0] - (p1[0] - p0[0]) * 0.25;
|
||||
cp2y = p1[1] - (p1[1] - p0[1]) * 0.25;
|
||||
}
|
||||
|
||||
ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p1[0], p1[1]);
|
||||
}
|
||||
}
|
||||
|
||||
if (curve.closed) {
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// Fill with semi-transparent overlay
|
||||
if (curve.closed) {
|
||||
// Convert maskOpacity from 0-100 to 0-1 alpha
|
||||
const alpha = (state.maskOpacity || 10) / 100;
|
||||
|
||||
if (isSelected) {
|
||||
// Selected curves use blue color with custom opacity
|
||||
ctx.fillStyle = `rgba(74, 158, 255, ${alpha})`;
|
||||
} else {
|
||||
// Unselected curves use mask color with custom opacity
|
||||
const rgb = hexToRgb(state.maskColor || "#ffffff");
|
||||
// Convert from 0-1 to 0-255 range
|
||||
const r = Math.round(rgb[0] * 255);
|
||||
const g = Math.round(rgb[1] * 255);
|
||||
const b = Math.round(rgb[2] * 255);
|
||||
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Stroke style
|
||||
if (isSelected) {
|
||||
ctx.strokeStyle = "#4a9eff";
|
||||
ctx.lineWidth = 3;
|
||||
} else {
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2;
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Draw control points
|
||||
drawPoints(ctx, curve, state, isSelected);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Main redraw orchestration
|
||||
*/
|
||||
|
||||
import { drawCurve } from "./drawCurve.js";
|
||||
import { drawBackgroundImage } from "./drawBackground.js";
|
||||
import { evaluateFrame } from "../core/frameEvaluator.js";
|
||||
|
||||
/**
|
||||
* Redraw the canvas with all curves and background
|
||||
* @param {CanvasRenderingContext2D} ctx - Canvas context
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @param {Object} state - Editor state
|
||||
* @param {Object} callbacks - Callback functions
|
||||
* @param {Function} callbacks.getActiveShape - Get active shape
|
||||
* @param {Function} callbacks.drawBackgroundImage - Draw background image
|
||||
* @param {Function} callbacks.updateCurveList - Update curve list
|
||||
* @param {Function} callbacks.updateAddPointButton - Update add point button
|
||||
* @param {Object} node - ComfyUI node
|
||||
* @param {Object} app - ComfyUI app instance
|
||||
*/
|
||||
export function redraw(ctx, canvas, state, callbacks, node, app) {
|
||||
try {
|
||||
// Clear canvas
|
||||
ctx.fillStyle = "#1a1a1a";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw input image if available
|
||||
if (callbacks.drawBackgroundImage) {
|
||||
callbacks.drawBackgroundImage(ctx, canvas, state, node, app);
|
||||
}
|
||||
|
||||
// Get curves for current frame
|
||||
const shape = callbacks.getActiveShape();
|
||||
const curves = evaluateFrame(shape, state.currentFrame, { hold: true });
|
||||
|
||||
// Draw all curves
|
||||
curves.forEach(curve => {
|
||||
if (curve && curve.points && curve.points.length >= 2) {
|
||||
const isSelected = state.selectedCurves.has(curve.id);
|
||||
drawCurve(ctx, curve, state, isSelected);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw selection box if active
|
||||
if (state.selectionBox !== null) {
|
||||
ctx.save();
|
||||
|
||||
// Calculate normalized box bounds
|
||||
const minX = Math.min(state.selectionBox.start.x, state.selectionBox.end.x);
|
||||
const maxX = Math.max(state.selectionBox.start.x, state.selectionBox.end.x);
|
||||
const minY = Math.min(state.selectionBox.start.y, state.selectionBox.end.y);
|
||||
const maxY = Math.max(state.selectionBox.start.y, state.selectionBox.end.y);
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
// Draw filled rectangle with semi-transparent fill
|
||||
ctx.fillStyle = "rgba(74, 158, 255, 0.2)";
|
||||
ctx.fillRect(minX, minY, width, height);
|
||||
|
||||
// Draw dashed border
|
||||
ctx.strokeStyle = "rgba(74, 158, 255, 0.8)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(minX, minY, width, height);
|
||||
|
||||
// Restore line dash
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Update Add Point button state
|
||||
if (callbacks.updateAddPointButton) {
|
||||
callbacks.updateAddPointButton();
|
||||
}
|
||||
|
||||
// Update Toggle Smooth button state
|
||||
if (callbacks.updateToggleSmoothButton) {
|
||||
callbacks.updateToggleSmoothButton();
|
||||
}
|
||||
|
||||
// Cancel add point mode if no curve is selected
|
||||
if (state.selectedCurves.size === 0 && state.addPointMode) {
|
||||
state.addPointMode = false;
|
||||
if (callbacks.updateAddPointButton) {
|
||||
callbacks.updateAddPointButton();
|
||||
}
|
||||
}
|
||||
|
||||
// Update curve list
|
||||
if (callbacks.updateCurveList) {
|
||||
callbacks.updateCurveList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error during redraw:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* ComfyUI Roto - Frontend Extension
|
||||
* Interactive curve editor for rotoscoping
|
||||
*/
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// Core modules
|
||||
import { createState } from "./core/state.js";
|
||||
import { evaluateFrame } from "./core/frameEvaluator.js";
|
||||
import { getActiveShape, getCurvesForFrame, promoteToKeyframe, addPointToCurveAtEdge, commitFrame } from "./core/curveManager.js";
|
||||
|
||||
// Utils
|
||||
import { hexToRgb } from "./utils/helpers.js";
|
||||
import { deepCopy } from "./utils/helpers.js";
|
||||
|
||||
// Components
|
||||
import { createCanvas, resizeCanvasToImage } from "./components/Canvas.js";
|
||||
import { createToolbar } from "./components/Toolbar.js";
|
||||
import { createCurveList } from "./components/CurveList.js";
|
||||
|
||||
// Renderer
|
||||
import { redraw } from "./renderer/redraw.js";
|
||||
import { drawBackgroundImage, getFrameImageForIndex } from "./renderer/drawBackground.js";
|
||||
|
||||
// Events
|
||||
import { createMouseHandlers } from "./events/mouseHandlers.js";
|
||||
import { createKeyboardHandlers } from "./events/keyboardHandlers.js";
|
||||
import { createDocumentListeners } from "./events/documentListeners.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.Roto",
|
||||
async nodeCreated(node) {
|
||||
// Only attach to RotoMaskNode
|
||||
// Check both title and class name for compatibility
|
||||
const nodeTitle = node.getTitle ? node.getTitle() : node.title;
|
||||
const nodeType = node.type || node.comfyClass;
|
||||
|
||||
if (nodeTitle !== "Roto Mask" && nodeType !== "RotoMaskNode") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Default node size constants (enforced via setTimeout after widget setup)
|
||||
const defaultWidth = 800;
|
||||
const defaultHeight = 1200;
|
||||
|
||||
// Initialize state
|
||||
const state = createState();
|
||||
|
||||
// List of widgets to hide
|
||||
const widgetsToHide = ['curve_json', 'frame_index', 'frame_count'];
|
||||
|
||||
// Hide widgets early - before creating UI to prevent layout issues
|
||||
const hideWidgetsEarly = () => {
|
||||
try {
|
||||
const widgets = node.widgets || [];
|
||||
|
||||
widgetsToHide.forEach(widgetName => {
|
||||
const widgetIndex = widgets.findIndex(w => w.name === widgetName);
|
||||
if (widgetIndex !== -1) {
|
||||
const widget = widgets[widgetIndex];
|
||||
|
||||
// ComfyUI/LiteGraph widget hiding - set all size-related properties
|
||||
widget.computeSize = function() { return [0, -4]; };
|
||||
widget.hidden = true;
|
||||
widget.type = "hidden";
|
||||
widget.options = widget.options || {};
|
||||
widget.options.hidden = true;
|
||||
widget.options.height = 0;
|
||||
widget.y = -100; // Push off screen
|
||||
widget.last_y = -100;
|
||||
widget.size = [0, 0];
|
||||
|
||||
// Override draw to do nothing
|
||||
widget.draw = function() { return; };
|
||||
widget.mouse = function() { return false; };
|
||||
|
||||
// Hide DOM element if present
|
||||
if (widget.element) {
|
||||
widget.element.style.display = 'none';
|
||||
widget.element.style.height = '0';
|
||||
widget.element.style.overflow = 'hidden';
|
||||
widget.element.style.visibility = 'hidden';
|
||||
widget.element.style.position = 'absolute';
|
||||
widget.element.style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Force node to recalculate size
|
||||
if (node.setSize) {
|
||||
node.setSize(node.computeSize());
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("[ComfyUI Roto] Could not hide widgets early:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Hide widgets immediately
|
||||
hideWidgetsEarly();
|
||||
|
||||
// Hook into node's onConfigure to hide widgets when node is loaded
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) {
|
||||
originalOnConfigure.call(this, config);
|
||||
}
|
||||
hideWidgetsEarly();
|
||||
};
|
||||
|
||||
// Hook into onDrawForeground to ensure widgets stay hidden
|
||||
const originalOnDrawForeground = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx, graphCanvas) {
|
||||
// Hide widgets before drawing
|
||||
const widgets = this.widgets || [];
|
||||
widgetsToHide.forEach(widgetName => {
|
||||
const widget = widgets.find(w => w.name === widgetName);
|
||||
if (widget) {
|
||||
widget.hidden = true;
|
||||
widget.y = -100;
|
||||
}
|
||||
});
|
||||
if (originalOnDrawForeground) {
|
||||
originalOnDrawForeground.call(this, ctx, graphCanvas);
|
||||
}
|
||||
};
|
||||
|
||||
// Create container - ensure it's a proper DOM element
|
||||
const container = document.createElement("div");
|
||||
if (!container || !(container instanceof HTMLElement)) {
|
||||
console.error("[ComfyUI Roto] Failed to create container element");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set styles before adding to DOM
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
container.style.gap = "8px";
|
||||
container.style.padding = "8px";
|
||||
container.style.marginTop = "4px"; // Add margin to prevent overlap with hidden widgets
|
||||
container.style.backgroundColor = "var(--comfy-menu-bg)";
|
||||
container.style.border = "1px solid var(--border-color)";
|
||||
container.style.borderRadius = "4px";
|
||||
container.style.width = "100%";
|
||||
container.style.minHeight = "540px";
|
||||
container.style.boxSizing = "border-box";
|
||||
container.style.overflow = "hidden"; // Prevent content from poking out
|
||||
container.style.position = "relative"; // Ensure proper positioning
|
||||
|
||||
// Function to update container height based on node size
|
||||
const updateContainerHeight = () => {
|
||||
if (!node || !node.size) return;
|
||||
|
||||
// node.size is [width, height]
|
||||
// Use a generous offset to ensure content stays within node bounds
|
||||
const nodeHeight = node.size[1];
|
||||
const totalOffset = 135; // Header + margins + borders + padding + buffer
|
||||
const availableHeight = nodeHeight - totalOffset;
|
||||
|
||||
if (availableHeight > 500) {
|
||||
container.style.height = `${availableHeight}px`;
|
||||
} else {
|
||||
container.style.height = ''; // Let minHeight take over
|
||||
}
|
||||
};
|
||||
|
||||
// Make container focusable for keyboard shortcuts
|
||||
container.tabIndex = 0;
|
||||
|
||||
// Create canvas
|
||||
const { canvas, ctx } = createCanvas(state);
|
||||
|
||||
// Widget reference (will be set later)
|
||||
let widgetRef = null;
|
||||
|
||||
// Canvas scale update function (will be defined after canvasWrapper)
|
||||
let updateCanvasScale = null;
|
||||
|
||||
// Helper functions
|
||||
function updateCanvasLayout() {
|
||||
container.style.minHeight = `${canvas.height + 56}px`;
|
||||
// Update canvas display scale
|
||||
if (updateCanvasScale) {
|
||||
updateCanvasScale();
|
||||
}
|
||||
// Force node to recalculate size when canvas changes
|
||||
if (node && typeof node.setDirtyCanvas === "function") {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
function resizeCanvasToImageFn(img) {
|
||||
resizeCanvasToImage(canvas, state, img, updateCanvasLayout);
|
||||
}
|
||||
|
||||
// Create callbacks object for components
|
||||
const callbacks = {
|
||||
getActiveShape: () => getActiveShape(state),
|
||||
getCurvesForFrame: (frame) => getCurvesForFrame(state, frame),
|
||||
evaluateFrame: (shape, frameIndex, options) => evaluateFrame(shape, frameIndex, options),
|
||||
promoteToKeyframe: (curveId) => promoteToKeyframe(state, evaluateFrame, deepCopy, curveId),
|
||||
commitFrame: () => commitFrame(state, updateNodeInput),
|
||||
redraw: () => redraw(ctx, canvas, state, callbacks, node, app),
|
||||
updateNodeInput,
|
||||
setMode,
|
||||
removeDocumentListeners: null, // Will be set after document listeners are created
|
||||
attachDocumentListeners: null, // Will be set after document listeners are created
|
||||
drawBackgroundImage: (ctx, canvas, state, node, app) => drawBackgroundImage(ctx, canvas, state, node, app, resizeCanvasToImageFn, callbacks.redraw),
|
||||
getFrameImageForIndex: (index) => getFrameImageForIndex(state, index),
|
||||
resizeCanvasToImage: resizeCanvasToImageFn,
|
||||
addPointToCurveAtEdge: null, // Will be set after toolbar is created
|
||||
updateAddPointButton: null, // Will be set after toolbar is created
|
||||
updateCurveList: null, // Will be set after curve list is created
|
||||
setFrame: null // Will be set after toolbar is created
|
||||
};
|
||||
|
||||
// Create toolbar
|
||||
const toolbarControls = createToolbar(state, callbacks, canvas, node);
|
||||
callbacks.updateAddPointButton = toolbarControls.updateAddPointButton;
|
||||
callbacks.updateToggleSmoothButton = toolbarControls.updateToggleSmoothButton;
|
||||
callbacks.setFrame = toolbarControls.setFrame;
|
||||
|
||||
// Initial button state update
|
||||
if (toolbarControls.updateToggleSmoothButton) {
|
||||
toolbarControls.updateToggleSmoothButton();
|
||||
}
|
||||
|
||||
// Add toggle smooth callback
|
||||
callbacks.togglePointSmoothness = () => {
|
||||
// Require both curve and point selection
|
||||
if (state.selectedCurves.size === 0 || state.selectedPoints.size === 0) return;
|
||||
|
||||
const shape = getActiveShape(state);
|
||||
const frameKey = String(state.currentFrame);
|
||||
|
||||
// Promote affected curves to keyframes
|
||||
const affectedCurveIds = new Set();
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId] = pointKey.split(":");
|
||||
if (state.selectedCurves.has(curveId)) {
|
||||
affectedCurveIds.add(curveId);
|
||||
promoteToKeyframe(state, evaluateFrame, deepCopy, curveId);
|
||||
}
|
||||
});
|
||||
|
||||
// Get keyframe curves
|
||||
if (!shape.frames[frameKey]) shape.frames[frameKey] = [];
|
||||
const curves = shape.frames[frameKey];
|
||||
|
||||
// Determine target state (if all hard, make smooth; otherwise make hard)
|
||||
let allHard = true;
|
||||
let allSmooth = true;
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId, pointIndex] = pointKey.split(":");
|
||||
|
||||
// Only check points in selected curves
|
||||
if (!state.selectedCurves.has(curveId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const curve = curves.find(c => c.id === curveId);
|
||||
if (!curve) {
|
||||
// Curve doesn't exist - skip
|
||||
return;
|
||||
}
|
||||
|
||||
// Get pointTypes with validation
|
||||
let types;
|
||||
if (Array.isArray(curve.pointTypes) && curve.pointTypes.length === curve.points.length) {
|
||||
types = curve.pointTypes;
|
||||
} else {
|
||||
types = curve.points.map(() => "hard");
|
||||
}
|
||||
|
||||
const idx = parseInt(pointIndex);
|
||||
if (idx >= 0 && idx < curve.points.length && idx < types.length) {
|
||||
const type = types[idx];
|
||||
if (type === "smooth") {
|
||||
allHard = false;
|
||||
} else if (type === "hard") {
|
||||
allSmooth = false;
|
||||
}
|
||||
// Invalid type treated as "hard" (defensive)
|
||||
}
|
||||
});
|
||||
|
||||
const targetType = allHard ? "smooth" : "hard";
|
||||
|
||||
// Collect point indices to toggle per curve
|
||||
const curvePointsToToggle = new Map(); // curveId -> Set of point indices
|
||||
state.selectedPoints.forEach(pointKey => {
|
||||
const [curveId, pointIndex] = pointKey.split(":");
|
||||
|
||||
// Only toggle points in selected curves
|
||||
if (!state.selectedCurves.has(curveId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!curvePointsToToggle.has(curveId)) {
|
||||
curvePointsToToggle.set(curveId, new Set());
|
||||
}
|
||||
curvePointsToToggle.set(curveId, curvePointsToToggle.get(curveId).add(parseInt(pointIndex)));
|
||||
});
|
||||
|
||||
// Update pointTypes in ALL keyframes for each affected curve
|
||||
curvePointsToToggle.forEach((pointIndices, curveId) => {
|
||||
// Iterate through all keyframes
|
||||
Object.keys(shape.frames).forEach(fKey => {
|
||||
const frameCurves = shape.frames[fKey] || [];
|
||||
const curve = frameCurves.find(c => c.id === curveId);
|
||||
|
||||
if (!curve) return;
|
||||
|
||||
// Ensure pointTypes array exists and matches points length
|
||||
if (!Array.isArray(curve.pointTypes) || curve.pointTypes.length !== curve.points.length) {
|
||||
curve.pointTypes = curve.points.map(() => "hard");
|
||||
}
|
||||
|
||||
// Toggle each selected point index
|
||||
pointIndices.forEach(idx => {
|
||||
if (idx >= 0 && idx < curve.points.length && idx < curve.pointTypes.length) {
|
||||
curve.pointTypes[idx] = targetType;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
commitFrame(state, updateNodeInput);
|
||||
callbacks.redraw();
|
||||
if (toolbarControls.updateToggleSmoothButton) {
|
||||
toolbarControls.updateToggleSmoothButton();
|
||||
}
|
||||
};
|
||||
|
||||
// Set up addPointToCurveAtEdge callback now that toolbarControls exists
|
||||
callbacks.addPointToCurveAtEdge = (curveId, edgeIndex, position) => {
|
||||
addPointToCurveAtEdge(
|
||||
state,
|
||||
curveId,
|
||||
edgeIndex,
|
||||
position,
|
||||
(state, evaluateFrameFn, deepCopyFn, curveId) => promoteToKeyframe(state, evaluateFrameFn, deepCopyFn, curveId),
|
||||
evaluateFrame,
|
||||
deepCopy,
|
||||
() => commitFrame(state, updateNodeInput),
|
||||
callbacks.redraw,
|
||||
toolbarControls.updateAddPointButton
|
||||
);
|
||||
};
|
||||
|
||||
// Create curve list
|
||||
const curveListControls = createCurveList(state, callbacks);
|
||||
callbacks.updateCurveList = curveListControls.updateCurveList;
|
||||
|
||||
// Create mouse handlers first (returns handleDragMove function)
|
||||
const handleDragMoveFn = createMouseHandlers(canvas, container, state, callbacks);
|
||||
|
||||
// Create document listeners with the handleDragMove function
|
||||
const documentListeners = createDocumentListeners(state, handleDragMoveFn);
|
||||
callbacks.removeDocumentListeners = documentListeners.removeDocumentListeners;
|
||||
callbacks.attachDocumentListeners = documentListeners.attachDocumentListeners;
|
||||
|
||||
// Create keyboard handlers
|
||||
createKeyboardHandlers(container, state, callbacks);
|
||||
|
||||
// Mode management
|
||||
function setMode(mode) {
|
||||
// Clean up dragging if switching modes
|
||||
if (state.dragging) {
|
||||
state.dragging = false;
|
||||
state.dragStart = null;
|
||||
callbacks.removeDocumentListeners();
|
||||
}
|
||||
state.mode = mode;
|
||||
updateModeButtons();
|
||||
callbacks.redraw();
|
||||
}
|
||||
|
||||
function updateModeButtons() {
|
||||
if (state.mode === "draw") {
|
||||
toolbarControls.drawBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
|
||||
toolbarControls.editBtn.style.backgroundColor = "";
|
||||
} else {
|
||||
toolbarControls.editBtn.style.backgroundColor = "var(--comfy-menu-button-bg-selected)";
|
||||
toolbarControls.drawBtn.style.backgroundColor = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Update node input with current state
|
||||
function updateNodeInput() {
|
||||
try {
|
||||
const shape = getActiveShape(state);
|
||||
|
||||
// Extract frame_paths from state.frameImages
|
||||
const frame_paths = state.frameImages.map(frame => ({
|
||||
filename: frame.filename,
|
||||
subfolder: frame.subfolder || ""
|
||||
}));
|
||||
|
||||
// Convert background color from hex to RGB [0-1]
|
||||
const background_color = hexToRgb(state.backgroundColor);
|
||||
|
||||
const meta = {
|
||||
version: 1,
|
||||
width: state.canvasWidth,
|
||||
height: state.canvasHeight,
|
||||
feather_amount: state.featherAmount,
|
||||
background_color: background_color,
|
||||
mask_color: state.maskColor || "#ffffff",
|
||||
mask_opacity: state.maskOpacity || 10,
|
||||
frame_paths: frame_paths
|
||||
};
|
||||
|
||||
const curveData = {
|
||||
meta: meta,
|
||||
shapes: {}
|
||||
};
|
||||
|
||||
// Add all shapes
|
||||
Object.keys(state.shapes).forEach(shapeId => {
|
||||
curveData.shapes[shapeId] = state.shapes[shapeId];
|
||||
});
|
||||
|
||||
const jsonString = JSON.stringify(curveData);
|
||||
|
||||
// Find curve_json widget
|
||||
const widgets = node.widgets || [];
|
||||
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
|
||||
|
||||
if (curveJsonWidget) {
|
||||
curveJsonWidget.value = jsonString;
|
||||
// Trigger widget change event
|
||||
if (curveJsonWidget.callback) {
|
||||
curveJsonWidget.callback(jsonString);
|
||||
}
|
||||
} else {
|
||||
console.warn("[ComfyUI Roto] curve_json widget not found");
|
||||
}
|
||||
|
||||
// Sync current frame from widget if present
|
||||
const frameWidget = widgets.find(w => w.name === "frame_index");
|
||||
if (frameWidget && frameWidget.value !== undefined) {
|
||||
const nextFrame = Number(frameWidget.value) || 1;
|
||||
if (nextFrame !== state.currentFrame) {
|
||||
state.currentFrame = nextFrame;
|
||||
toolbarControls.frameSlider.value = String(nextFrame);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error updating node input:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize feather amount and background color from existing curve_json
|
||||
function initializeFeatherAmount() {
|
||||
try {
|
||||
const widgets = node.widgets || [];
|
||||
const curveJsonWidget = widgets.find(w => w.name === "curve_json");
|
||||
|
||||
if (curveJsonWidget && curveJsonWidget.value) {
|
||||
try {
|
||||
const curveData = JSON.parse(curveJsonWidget.value);
|
||||
const meta = curveData?.meta || {};
|
||||
|
||||
// Initialize feather amount
|
||||
const featherAmount = typeof meta.feather_amount === 'number' && !isNaN(meta.feather_amount)
|
||||
? Math.max(0, Math.min(100, meta.feather_amount))
|
||||
: 0.0;
|
||||
|
||||
state.featherAmount = featherAmount;
|
||||
toolbarControls.featherSlider.value = String(featherAmount);
|
||||
toolbarControls.featherValueLabel.textContent = featherAmount.toFixed(1);
|
||||
|
||||
// Initialize background color
|
||||
if (Array.isArray(meta.background_color) && meta.background_color.length === 3) {
|
||||
const [r, g, b] = meta.background_color;
|
||||
const rInt = Math.round(Math.max(0, Math.min(255, r * 255)));
|
||||
const gInt = Math.round(Math.max(0, Math.min(255, g * 255)));
|
||||
const bInt = Math.round(Math.max(0, Math.min(255, b * 255)));
|
||||
const hexColor = `#${rInt.toString(16).padStart(2, '0')}${gInt.toString(16).padStart(2, '0')}${bInt.toString(16).padStart(2, '0')}`;
|
||||
state.backgroundColor = hexColor;
|
||||
toolbarControls.bgColorInput.value = hexColor;
|
||||
}
|
||||
|
||||
// Initialize mask color
|
||||
if (typeof meta.mask_color === 'string') {
|
||||
state.maskColor = meta.mask_color;
|
||||
toolbarControls.maskColorInput.value = meta.mask_color;
|
||||
} else {
|
||||
state.maskColor = "#ffffff";
|
||||
toolbarControls.maskColorInput.value = "#ffffff";
|
||||
}
|
||||
|
||||
// Initialize mask opacity
|
||||
const maskOpacity = typeof meta.mask_opacity === 'number' && !isNaN(meta.mask_opacity)
|
||||
? Math.max(0, Math.min(100, meta.mask_opacity))
|
||||
: 10;
|
||||
state.maskOpacity = maskOpacity;
|
||||
toolbarControls.maskOpacitySlider.value = String(maskOpacity);
|
||||
toolbarControls.maskOpacityValueLabel.textContent = String(maskOpacity);
|
||||
|
||||
// Initialize frame_paths if present
|
||||
if (Array.isArray(meta.frame_paths) && meta.frame_paths.length > 0) {
|
||||
state.frameImages = meta.frame_paths.map(fp => ({
|
||||
filename: fp.filename || "",
|
||||
subfolder: fp.subfolder || "",
|
||||
url: `/view?filename=${fp.filename}&type=input&subfolder=${fp.subfolder || ""}&t=${Date.now()}`
|
||||
}));
|
||||
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
|
||||
}
|
||||
|
||||
// Initialize frame output controls from widgets
|
||||
const frameIndexWidget = widgets.find(w => w.name === "frame_index");
|
||||
const frameCountWidget = widgets.find(w => w.name === "frame_count");
|
||||
|
||||
const startFrame = (frameIndexWidget && frameIndexWidget.value) ? Number(frameIndexWidget.value) : 1;
|
||||
const frameCount = (frameCountWidget && frameCountWidget.value) ? Number(frameCountWidget.value) : 1;
|
||||
const endFrame = startFrame + frameCount - 1;
|
||||
|
||||
toolbarControls.startFrameInput.value = String(startFrame);
|
||||
toolbarControls.endFrameInput.value = String(endFrame);
|
||||
} catch (parseError) {
|
||||
// Invalid JSON, use default
|
||||
console.warn("[ComfyUI Roto] Failed to parse curve_json for initialization:", parseError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[ComfyUI Roto] Error initializing from curve_json:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create canvas wrapper for flexible sizing
|
||||
const canvasWrapper = document.createElement("div");
|
||||
canvasWrapper.style.flex = "1 1 0"; // Grow to fill available space, start from 0
|
||||
canvasWrapper.style.minHeight = "200px";
|
||||
canvasWrapper.style.display = "flex";
|
||||
canvasWrapper.style.alignItems = "center";
|
||||
canvasWrapper.style.justifyContent = "center";
|
||||
canvasWrapper.style.overflow = "hidden";
|
||||
canvasWrapper.style.position = "relative";
|
||||
canvasWrapper.style.width = "100%"; // Ensure full width
|
||||
canvasWrapper.appendChild(canvas);
|
||||
|
||||
// Make canvas scale to fit wrapper while maintaining aspect ratio
|
||||
updateCanvasScale = () => {
|
||||
if (!canvasWrapper.offsetWidth || !canvasWrapper.offsetHeight) return;
|
||||
|
||||
const wrapperWidth = canvasWrapper.offsetWidth;
|
||||
const wrapperHeight = canvasWrapper.offsetHeight;
|
||||
const canvasAspect = canvas.width / canvas.height;
|
||||
const wrapperAspect = wrapperWidth / wrapperHeight;
|
||||
|
||||
let displayWidth, displayHeight;
|
||||
if (canvasAspect > wrapperAspect) {
|
||||
// Canvas is wider - fit to width
|
||||
displayWidth = wrapperWidth;
|
||||
displayHeight = wrapperWidth / canvasAspect;
|
||||
} else {
|
||||
// Canvas is taller - fit to height
|
||||
displayHeight = wrapperHeight;
|
||||
displayWidth = wrapperHeight * canvasAspect;
|
||||
}
|
||||
|
||||
canvas.style.width = `${displayWidth}px`;
|
||||
canvas.style.height = `${displayHeight}px`;
|
||||
};
|
||||
|
||||
// Use ResizeObserver to detect wrapper size changes
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasScale();
|
||||
});
|
||||
resizeObserver.observe(canvasWrapper);
|
||||
|
||||
// Also observe container size changes to update canvas when node is resized
|
||||
const containerResizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasScale();
|
||||
});
|
||||
containerResizeObserver.observe(container);
|
||||
}
|
||||
|
||||
// Assemble UI in new layout order
|
||||
container.appendChild(toolbarControls.loadFramesSection);
|
||||
container.appendChild(toolbarControls.toolButtonsSection);
|
||||
container.appendChild(canvasWrapper);
|
||||
container.appendChild(toolbarControls.timelineSection);
|
||||
container.appendChild(curveListControls.curveListContainer);
|
||||
container.appendChild(toolbarControls.settingsSection);
|
||||
|
||||
// Verify container is still valid before adding widget
|
||||
if (!container || !(container instanceof HTMLElement)) {
|
||||
console.error("[ComfyUI Roto] Container is not a valid HTMLElement");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add widget to node
|
||||
try {
|
||||
if (typeof node.addDOMWidget !== "function") {
|
||||
console.error("[ComfyUI Roto] node.addDOMWidget is not a function");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate MINIMUM widget size (fixed values to prevent feedback loop)
|
||||
const calculateWidgetSize = () => {
|
||||
const padding = 16; // 8px padding on each side
|
||||
const gaps = 8 * 6; // 6 gaps between sections (8px each)
|
||||
|
||||
// Use fixed estimates for UI sections to prevent feedback loop
|
||||
const loadFramesHeight = 40;
|
||||
const toolButtonsHeight = 32;
|
||||
const timelineHeight = 40;
|
||||
const curveListHeight = 120;
|
||||
const settingsHeight = 80;
|
||||
|
||||
// Use canvas native height as minimum (not wrapper height!)
|
||||
const canvasHeight = Math.max(canvas.height, 300);
|
||||
|
||||
const totalHeight = loadFramesHeight + toolButtonsHeight + canvasHeight +
|
||||
timelineHeight + curveListHeight + settingsHeight + gaps + padding;
|
||||
const totalWidth = Math.max(canvas.width + padding, 1200); // Minimum width (3x wider)
|
||||
|
||||
return [totalWidth, totalHeight];
|
||||
};
|
||||
|
||||
const widget = node.addDOMWidget("roto_editor", "custom", null, { element: container });
|
||||
widgetRef = widget; // Store reference for use in other functions
|
||||
if (widget) {
|
||||
widget.element = container;
|
||||
widget.computeSize = () => {
|
||||
// Use actual measured heights if available, otherwise estimate
|
||||
return calculateWidgetSize();
|
||||
};
|
||||
}
|
||||
|
||||
// Hide widgets - multiple approaches
|
||||
const hideWidgetByName = (widgetName) => {
|
||||
try {
|
||||
const widget = (node.widgets || []).find(w => w.name === widgetName);
|
||||
if (widget) {
|
||||
// ComfyUI/LiteGraph widget hiding - set all size-related properties
|
||||
widget.computeSize = function() { return [0, -4]; };
|
||||
widget.hidden = true;
|
||||
widget.type = "hidden";
|
||||
widget.options = widget.options || {};
|
||||
widget.options.hidden = true;
|
||||
widget.options.height = 0;
|
||||
widget.y = -100; // Push off screen
|
||||
widget.last_y = -100;
|
||||
widget.size = [0, 0];
|
||||
|
||||
// Override draw to do nothing
|
||||
widget.draw = function() { return; };
|
||||
widget.mouse = function() { return false; };
|
||||
|
||||
// Hide DOM element if present
|
||||
if (widget.element) {
|
||||
widget.element.style.display = 'none';
|
||||
widget.element.style.height = '0';
|
||||
widget.element.style.overflow = 'hidden';
|
||||
widget.element.style.visibility = 'hidden';
|
||||
widget.element.style.position = 'absolute';
|
||||
widget.element.style.pointerEvents = 'none';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug(`[ComfyUI Roto] Could not hide ${widgetName} widget:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// Hide all internal widgets
|
||||
const hideAllWidgets = () => {
|
||||
hideWidgetByName('curve_json');
|
||||
hideWidgetByName('frame_index');
|
||||
hideWidgetByName('frame_count');
|
||||
};
|
||||
|
||||
hideAllWidgets();
|
||||
|
||||
// Hide again after short delays to catch widgets rendered later
|
||||
[100, 500, 1000].forEach(delay => {
|
||||
setTimeout(hideAllWidgets, delay);
|
||||
});
|
||||
|
||||
// Hook frame_index widget to update current frame
|
||||
const frameWidget = (node.widgets || []).find(w => w.name === "frame_index");
|
||||
if (frameWidget && typeof frameWidget.callback === "function") {
|
||||
const originalCallback = frameWidget.callback;
|
||||
frameWidget.callback = function (v) {
|
||||
if (state.updatingFrame) {
|
||||
return originalCallback.call(frameWidget, v, null, node);
|
||||
}
|
||||
state.updatingFrame = true;
|
||||
state.currentFrame = Number(v) || 1;
|
||||
toolbarControls.frameSlider.value = String(state.currentFrame);
|
||||
callbacks.redraw();
|
||||
const result = originalCallback.call(frameWidget, v, null, node);
|
||||
state.updatingFrame = false;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Initial setup after widget is attached
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
initializeFeatherAmount();
|
||||
toolbarControls.frameSlider.max = String(Math.max(1, state.frameImages.length));
|
||||
toolbarControls.frameSlider.value = String(state.currentFrame);
|
||||
toolbarControls.updateFrameLabel();
|
||||
callbacks.redraw();
|
||||
updateNodeInput();
|
||||
|
||||
// Initial canvas scale update
|
||||
if (updateCanvasScale) {
|
||||
updateCanvasScale();
|
||||
}
|
||||
|
||||
// Hook into node's onResize to update container height
|
||||
const originalOnResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (originalOnResize) {
|
||||
originalOnResize.call(this, size);
|
||||
}
|
||||
updateContainerHeight();
|
||||
if (updateCanvasScale) {
|
||||
updateCanvasScale();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial height update
|
||||
updateContainerHeight();
|
||||
|
||||
// Force minimum size after everything is initialized (multiple attempts)
|
||||
const enforceMinSize = () => {
|
||||
if (node.size && node.size[0] < defaultWidth) {
|
||||
const newSize = [defaultWidth, Math.max(node.size[1], defaultHeight)];
|
||||
node.size = newSize;
|
||||
if (node.setSize) node.setSize(newSize);
|
||||
if (node.graph && node.graph.setDirtyCanvas) node.graph.setDirtyCanvas(true, true);
|
||||
updateContainerHeight();
|
||||
if (updateCanvasScale) updateCanvasScale();
|
||||
}
|
||||
};
|
||||
setTimeout(enforceMinSize, 50);
|
||||
setTimeout(enforceMinSize, 200);
|
||||
setTimeout(enforceMinSize, 500);
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error in initial setup:", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ComfyUI Roto] Error adding DOM widget:", error);
|
||||
console.error("[ComfyUI Roto] Error details:", error.stack);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Canvas utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get mouse position relative to canvas, accounting for scaling
|
||||
* @param {MouseEvent} e - Mouse event
|
||||
* @param {HTMLCanvasElement} canvas - Canvas element
|
||||
* @returns {{x: number, y: number}} Mouse position clamped to canvas bounds
|
||||
*/
|
||||
export function getMousePos(e, canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
// Calculate position even if mouse is outside canvas bounds
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
// Clamp to canvas bounds
|
||||
return {
|
||||
x: Math.max(0, Math.min(canvas.width, x)),
|
||||
y: Math.max(0, Math.min(canvas.height, y))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* File upload and image loading utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upload an image file to ComfyUI server
|
||||
* @param {File} file - Image file to upload
|
||||
* @returns {Promise<Object>} Upload response with filename and subfolder
|
||||
*/
|
||||
export async function uploadImageFile(file) {
|
||||
const body = new FormData();
|
||||
body.append("image", file);
|
||||
body.append("overwrite", "true");
|
||||
const resp = await fetch("/upload/image", { method: "POST", body });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to upload image: ${resp.statusText}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image asynchronously from a URL
|
||||
* @param {string} imageURL - URL of the image to load
|
||||
* @returns {Promise<HTMLImageElement>} Loaded image element
|
||||
*/
|
||||
export async function loadImageAsync(imageURL) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.setAttribute("crossorigin", "anonymous");
|
||||
img.addEventListener("load", () => resolve(img));
|
||||
img.src = imageURL;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* General helper utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deep copy an object using JSON serialization
|
||||
* @param {any} obj - Object to deep copy
|
||||
* @returns {any} Deep copy of the object
|
||||
*/
|
||||
export function deepCopy(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color string to RGB array [0-1]
|
||||
* @param {string} hex - Hex color string (e.g., "#ff0000" or "ff0000")
|
||||
* @returns {number[]} RGB array with values in range [0, 1]
|
||||
*/
|
||||
export function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return [0.0, 0.0, 0.0];
|
||||
return [
|
||||
parseInt(result[1], 16) / 255.0,
|
||||
parseInt(result[2], 16) / 255.0,
|
||||
parseInt(result[3], 16) / 255.0
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* UUID generation utility
|
||||
*/
|
||||
|
||||
export function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user