From 4cd66803ef8adf03a09fc6f6fea8180badd3110f Mon Sep 17 00:00:00 2001 From: TechnicalTod Date: Wed, 28 Jan 2026 19:33:35 +1300 Subject: [PATCH] Initial commit --- .gitignore | 33 ++ LICENSE | 21 + README.md | 147 ++++++ __init__.py | 17 + core/__init__.py | 3 + core/curve_validator.py | 112 +++++ core/frame_evaluator.py | 223 +++++++++ image/__init__.py | 3 + image/loader.py | 58 +++ image/processor.py | 66 +++ image/utils.py | 24 + mask/__init__.py | 3 + mask/antialiasing.py | 36 ++ mask/generator.py | 54 +++ mask/rasterizer.py | 178 ++++++++ requirements.txt | 5 + roto_node.py | 187 ++++++++ web/components/Canvas.js | 47 ++ web/components/CurveList.js | 227 ++++++++++ web/components/Toolbar.js | 777 ++++++++++++++++++++++++++++++++ web/core/curveManager.js | 238 ++++++++++ web/core/frameEvaluator.js | 225 +++++++++ web/core/state.js | 41 ++ web/events/documentListeners.js | 53 +++ web/events/keyboardHandlers.js | 84 ++++ web/events/mouseHandlers.js | 291 ++++++++++++ web/renderer/drawBackground.js | 132 ++++++ web/renderer/drawCurve.js | 224 +++++++++ web/renderer/redraw.js | 97 ++++ web/roto_editor.js | 755 +++++++++++++++++++++++++++++++ web/utils/canvas.js | 23 + web/utils/fileUpload.js | 33 ++ web/utils/helpers.js | 27 ++ web/utils/uuid.js | 11 + 34 files changed, 4455 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 core/__init__.py create mode 100644 core/curve_validator.py create mode 100644 core/frame_evaluator.py create mode 100644 image/__init__.py create mode 100644 image/loader.py create mode 100644 image/processor.py create mode 100644 image/utils.py create mode 100644 mask/__init__.py create mode 100644 mask/antialiasing.py create mode 100644 mask/generator.py create mode 100644 mask/rasterizer.py create mode 100644 requirements.txt create mode 100644 roto_node.py create mode 100644 web/components/Canvas.js create mode 100644 web/components/CurveList.js create mode 100644 web/components/Toolbar.js create mode 100644 web/core/curveManager.js create mode 100644 web/core/frameEvaluator.js create mode 100644 web/core/state.js create mode 100644 web/events/documentListeners.js create mode 100644 web/events/keyboardHandlers.js create mode 100644 web/events/mouseHandlers.js create mode 100644 web/renderer/drawBackground.js create mode 100644 web/renderer/drawCurve.js create mode 100644 web/renderer/redraw.js create mode 100644 web/roto_editor.js create mode 100644 web/utils/canvas.js create mode 100644 web/utils/fileUpload.js create mode 100644 web/utils/helpers.js create mode 100644 web/utils/uuid.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94220e7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..56d5142 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ea988c --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..8a0be70 --- /dev/null +++ b/__init__.py @@ -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" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e53f1da --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,3 @@ +""" +Core modules for roto node +""" diff --git a/core/curve_validator.py b/core/curve_validator.py new file mode 100644 index 0000000..36b17c7 --- /dev/null +++ b/core/curve_validator.py @@ -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 diff --git a/core/frame_evaluator.py b/core/frame_evaluator.py new file mode 100644 index 0000000..9136436 --- /dev/null +++ b/core/frame_evaluator.py @@ -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 diff --git a/image/__init__.py b/image/__init__.py new file mode 100644 index 0000000..2dcffa4 --- /dev/null +++ b/image/__init__.py @@ -0,0 +1,3 @@ +""" +Image processing modules +""" diff --git a/image/loader.py b/image/loader.py new file mode 100644 index 0000000..f0a5273 --- /dev/null +++ b/image/loader.py @@ -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 diff --git a/image/processor.py b/image/processor.py new file mode 100644 index 0000000..807884a --- /dev/null +++ b/image/processor.py @@ -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 diff --git a/image/utils.py b/image/utils.py new file mode 100644 index 0000000..d0453ee --- /dev/null +++ b/image/utils.py @@ -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) diff --git a/mask/__init__.py b/mask/__init__.py new file mode 100644 index 0000000..7c7c74e --- /dev/null +++ b/mask/__init__.py @@ -0,0 +1,3 @@ +""" +Mask generation modules +""" diff --git a/mask/antialiasing.py b/mask/antialiasing.py new file mode 100644 index 0000000..926aa61 --- /dev/null +++ b/mask/antialiasing.py @@ -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) diff --git a/mask/generator.py b/mask/generator.py new file mode 100644 index 0000000..13d1172 --- /dev/null +++ b/mask/generator.py @@ -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 diff --git a/mask/rasterizer.py b/mask/rasterizer.py new file mode 100644 index 0000000..d24b4eb --- /dev/null +++ b/mask/rasterizer.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..865399f --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/roto_node.py b/roto_node.py new file mode 100644 index 0000000..223e0ad --- /dev/null +++ b/roto_node.py @@ -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) diff --git a/web/components/Canvas.js b/web/components/Canvas.js new file mode 100644 index 0000000..766a1d0 --- /dev/null +++ b/web/components/Canvas.js @@ -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(); + } + } +} diff --git a/web/components/CurveList.js b/web/components/CurveList.js new file mode 100644 index 0000000..615755b --- /dev/null +++ b/web/components/CurveList.js @@ -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 + }; +} diff --git a/web/components/Toolbar.js b/web/components/Toolbar.js new file mode 100644 index 0000000..363dd26 --- /dev/null +++ b/web/components/Toolbar.js @@ -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 + }; +} diff --git a/web/core/curveManager.js b/web/core/curveManager.js new file mode 100644 index 0000000..3cd583b --- /dev/null +++ b/web/core/curveManager.js @@ -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(); + } +} diff --git a/web/core/frameEvaluator.js b/web/core/frameEvaluator.js new file mode 100644 index 0000000..7c863c4 --- /dev/null +++ b/web/core/frameEvaluator.js @@ -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} 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; +} diff --git a/web/core/state.js b/web/core/state.js new file mode 100644 index 0000000..7c3eb7c --- /dev/null +++ b/web/core/state.js @@ -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 + }; +} diff --git a/web/events/documentListeners.js b/web/events/documentListeners.js new file mode 100644 index 0000000..c033add --- /dev/null +++ b/web/events/documentListeners.js @@ -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 + }; +} + diff --git a/web/events/keyboardHandlers.js b/web/events/keyboardHandlers.js new file mode 100644 index 0000000..9295d24 --- /dev/null +++ b/web/events/keyboardHandlers.js @@ -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); +} diff --git a/web/events/mouseHandlers.js b/web/events/mouseHandlers.js new file mode 100644 index 0000000..af0ac20 --- /dev/null +++ b/web/events/mouseHandlers.js @@ -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; +} + diff --git a/web/renderer/drawBackground.js b/web/renderer/drawBackground.js new file mode 100644 index 0000000..720b57c --- /dev/null +++ b/web/renderer/drawBackground.js @@ -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} 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); + } +} diff --git a/web/renderer/drawCurve.js b/web/renderer/drawCurve.js new file mode 100644 index 0000000..2201721 --- /dev/null +++ b/web/renderer/drawCurve.js @@ -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} lineStart - Line start point [x, y] + * @param {Array} 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(); +} diff --git a/web/renderer/redraw.js b/web/renderer/redraw.js new file mode 100644 index 0000000..4e2a34e --- /dev/null +++ b/web/renderer/redraw.js @@ -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); + } +} diff --git a/web/roto_editor.js b/web/roto_editor.js new file mode 100644 index 0000000..748f7ae --- /dev/null +++ b/web/roto_editor.js @@ -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); + } + } +}); diff --git a/web/utils/canvas.js b/web/utils/canvas.js new file mode 100644 index 0000000..b520d0b --- /dev/null +++ b/web/utils/canvas.js @@ -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)) + }; +} diff --git a/web/utils/fileUpload.js b/web/utils/fileUpload.js new file mode 100644 index 0000000..4e7492a --- /dev/null +++ b/web/utils/fileUpload.js @@ -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} 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} 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; + }); +} diff --git a/web/utils/helpers.js b/web/utils/helpers.js new file mode 100644 index 0000000..d0c3a16 --- /dev/null +++ b/web/utils/helpers.js @@ -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 + ]; +} diff --git a/web/utils/uuid.js b/web/utils/uuid.js new file mode 100644 index 0000000..1d5a82b --- /dev/null +++ b/web/utils/uuid.js @@ -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); + }); +}