commit 2dcd04440724f061f30625dae2e5dd5d85d4db76 Author: filliptm Date: Sun Nov 2 20:28:41 2025 -0500 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b06f8dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d36588 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# FL Path Animator + +A standalone ComfyUI custom node for creating animated shapes that follow user-drawn paths. + +## Features + +- **Interactive Path Editor** - Visual modal interface for drawing motion paths and static anchor points +- **Two Path Types**: + - **Motion Paths** - Draw continuous paths for shapes to follow over time + - **Static Anchors** - Single-point paths for stationary shapes +- **Background Image Support** - Load or paste reference images to draw paths on +- **WAN ATI Compatible** - Outputs 121-point resampled coordinates for stable AI video generation +- **Visual Effects** - Blur, trails, rotation, borders, and custom colors +- **Multiple Shapes** - Circle, square, triangle, hexagon, and star + +## Installation + +1. Clone or download this repository into your ComfyUI custom_nodes folder: +```bash +cd ComfyUI/custom_nodes/ +git clone https://github.com/yourusername/ComfyUI_FL-Path-Animator.git +``` + +2. Restart ComfyUI + +## Usage + +### Basic Workflow + +1. Add the "FL Path Animator V2" node to your workflow +2. Click the **"Edit Paths"** button to open the path editor +3. Use the toolbar to: + - **✏️ Pencil** - Draw motion paths (hold SHIFT for straight lines) + - **📍 Point** - Add static anchor points + - **🗑️ Eraser** - Delete paths by clicking + - **↖️ Select** - Select and inspect paths + - **🔒 Lock Perimeter** - Auto-generate static points around border +4. Optionally load a background image with **🖼️** or paste with **Ctrl+V** +5. Press **ESC** to save and close +6. Configure shape properties in the node +7. Connect outputs to your workflow + +### Keyboard Shortcuts + +- **ESC** - Save paths and close editor +- **SHIFT (hold)** - Draw straight lines (horizontal/vertical/45° diagonal) +- **Ctrl+V** - Paste background image from clipboard + +### Node Parameters + +#### Required +- `frame_width` / `frame_height` - Output frame dimensions +- `frame_count` - Number of frames to generate +- `shape` - Shape type (circle, square, triangle, hexagon, star) +- `shape_size` - Size in pixels +- `shape_color` - Color as hex (#FFFFFF) or RGB (255,255,255) +- `bg_color` - Background color + +#### Optional +- `blur_radius` - Gaussian blur strength +- `trail_length` - Motion trail effect (0.0-1.0) +- `rotation_speed` - Shape rotation over time +- `border_width` - Border thickness +- `border_color` - Border color +- `paths_data` - JSON data from path editor (auto-managed) + +### Outputs + +1. **IMAGE** - Batch of rendered frames (shape following paths) +2. **MASK** - Alpha masks extracted from red channel +3. **STRING** - WAN ATI-compatible coordinate data (121 points per path) + +## WAN ATI Integration + +The coordinate output is specifically formatted for WAN (Warp and Noise) ATI video generation: + +- Each path is resampled to exactly 121 points +- Arc-length parameterization ensures smooth motion +- Static points are repeated 121 times for stable anchors +- Output format: `[[{x, y}, ...], [{x, y}, ...]]` (array of tracks) + +This prevents jitter and warping in AI-generated video by providing consistent tracking data. + +## Path Editor Tools + +### ✏️ Pencil Tool +Draw continuous motion paths by clicking and dragging. Shapes will smoothly follow these paths over the animation duration. + +- Minimum 3px spacing between points for smoothing +- Hold SHIFT to constrain to straight lines + +### 📍 Point Tool +Click once to create a static anchor point. Shapes at these positions won't move, useful for border stability in video generation. + +### 🗑️ Eraser Tool +Click on any path to delete it. + +### ↖️ Select Tool +Click paths to inspect details: +- Neon green highlight +- Point count and numbering +- Path type indicator + +### 🔒 Lock Perimeter +Automatically distributes N static anchor points evenly around the canvas border. Useful for WAN video generation to stabilize frame edges. + +## Technical Details + +- **Path Resampling**: Arc-length parameterization for constant-speed motion +- **Canvas Scaling**: Paths automatically scale from editor coordinates to output frame size +- **Background Cache**: Uploaded/pasted images persist across editor sessions +- **Animated Preview**: Directional flow indicators show path direction in real-time + +## Requirements + +- Python 3.8+ +- PIL (Pillow) +- NumPy +- PyTorch +- ComfyUI + +## License + +MIT License - See LICENSE file for details + +## Credits + +Created by Machine Delusions for the Fill-Nodes pack. +Extracted as standalone node for easier distribution. + +## Support + +For issues, feature requests, or questions: +- GitHub Issues: [Your repo issues page] +- Patreon: https://www.patreon.com/c/Machinedelusions diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..54e3739 --- /dev/null +++ b/__init__.py @@ -0,0 +1,22 @@ +""" +FL Path Animator V2 - Standalone Node Pack +Creates animated shapes that follow user-drawn paths with visual editor. +""" + +from .nodes.FL_PathAnimatorV2 import FL_PathAnimatorV2 + +NODE_CLASS_MAPPINGS = { + "FL_PathAnimatorV2": FL_PathAnimatorV2, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "FL_PathAnimatorV2": "FL Path Animator V2", +} + +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + +print("\n" + "="*60) +print("FL Path Animator V2 - Standalone Node Pack Loaded") +print("="*60 + "\n") diff --git a/nodes/FL_PathAnimatorV2.py b/nodes/FL_PathAnimatorV2.py new file mode 100644 index 0000000..7954509 --- /dev/null +++ b/nodes/FL_PathAnimatorV2.py @@ -0,0 +1,374 @@ +import torch +import numpy as np +from PIL import Image, ImageDraw, ImageFilter +import math +import json + +def pil2tensor(image): + """Convert PIL Image to tensor""" + return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0) + +def tensor2pil(tensor): + """Convert tensor to PIL Image""" + return Image.fromarray(np.clip(255. * tensor.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)) + +def parse_color(color): + """Parse color string to RGB tuple""" + if isinstance(color, str): + if ',' in color: + return tuple(int(c.strip()) for c in color.split(',')) + else: + from PIL import ImageColor + try: + return ImageColor.getrgb(color) + except: + return (255, 255, 255) + return color + +class FL_PathAnimatorV2: + + RETURN_TYPES = ("IMAGE", "MASK", "STRING",) + RETURN_NAMES = ("image", "mask", "coordinates",) + FUNCTION = "animate_paths" + CATEGORY = "🎨 FL Path Animator V2" + DESCRIPTION = """ +Creates animated shapes that follow user-drawn paths. +Open the path editor to draw trajectories on a reference image, then shapes will follow these paths over time. +Outputs WAN ATI-compatible coordinate strings with proper 121-point resampling for stable video generation. +""" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "frame_width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 1}), + "frame_height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 1}), + "frame_count": ("INT", {"default": 30, "min": 1, "max": 500, "step": 1}), + "shape": ([ + 'circle', + 'square', + 'triangle', + 'hexagon', + 'star', + ], {"default": 'circle'}), + "shape_size": ("INT", {"default": 20, "min": 2, "max": 500, "step": 1}), + "shape_color": ("STRING", {"default": 'white'}), + "bg_color": ("STRING", {"default": 'black'}), + }, + "optional": { + "blur_radius": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 50.0, "step": 0.1}), + "trail_length": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "rotation_speed": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 1.0}), + "border_width": ("INT", {"default": 0, "min": 0, "max": 20, "step": 1}), + "border_color": ("STRING", {"default": 'white'}), + "paths_data": ("STRING", {"default": '{"paths": [], "canvas_size": {"width": 512, "height": 512}}', "multiline": True}), + } + } + + def draw_shape(self, draw, shape, center_x, center_y, size, rotation, fill_color, border_width=0, border_color='white'): + """Draw a shape at the specified location""" + half_size = size / 2 + + if shape == 'circle': + bbox = [center_x - half_size, center_y - half_size, + center_x + half_size, center_y + half_size] + if border_width > 0: + draw.ellipse(bbox, fill=fill_color, outline=border_color, width=border_width) + else: + draw.ellipse(bbox, fill=fill_color) + + elif shape == 'square': + bbox = [center_x - half_size, center_y - half_size, + center_x + half_size, center_y + half_size] + if border_width > 0: + draw.rectangle(bbox, fill=fill_color, outline=border_color, width=border_width) + else: + draw.rectangle(bbox, fill=fill_color) + + elif shape == 'triangle': + points = [ + (center_x, center_y - half_size), + (center_x - half_size, center_y + half_size), + (center_x + half_size, center_y + half_size), + ] + if rotation != 0: + points = self.rotate_points(points, center_x, center_y, rotation) + if border_width > 0: + draw.polygon(points, fill=fill_color, outline=border_color, width=border_width) + else: + draw.polygon(points, fill=fill_color) + + elif shape == 'hexagon': + points = [] + for i in range(6): + angle = math.radians(60 * i + rotation) + x = center_x + half_size * math.cos(angle) + y = center_y + half_size * math.sin(angle) + points.append((x, y)) + if border_width > 0: + draw.polygon(points, fill=fill_color, outline=border_color, width=border_width) + else: + draw.polygon(points, fill=fill_color) + + elif shape == 'star': + points = [] + for i in range(10): + angle = math.radians(36 * i + rotation) + r = half_size if i % 2 == 0 else half_size * 0.4 + x = center_x + r * math.cos(angle - math.pi / 2) + y = center_y + r * math.sin(angle - math.pi / 2) + points.append((x, y)) + if border_width > 0: + draw.polygon(points, fill=fill_color, outline=border_color, width=border_width) + else: + draw.polygon(points, fill=fill_color) + + def rotate_points(self, points, cx, cy, angle): + """Rotate points around a center""" + rad = math.radians(angle) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + rotated = [] + for x, y in points: + x -= cx + y -= cy + new_x = x * cos_a - y * sin_a + cx + new_y = x * sin_a + y * cos_a + cy + rotated.append((new_x, new_y)) + return rotated + + def resample_path_uniform(self, points, num_samples=121): + """ + Resample path to exactly num_samples points with even arc-length spacing. + This matches KJNodes "path" sampling method and is CRITICAL for WAN ATI stability. + + Args: + points: List of {x, y} dicts representing the path + num_samples: Number of points to resample to (default 121 for WAN ATI) + + Returns: + List of {x, y} dicts with exactly num_samples points evenly distributed along the arc + """ + if len(points) == 0: + return [] + + # SOLUTION 1: Support static single points + if len(points) == 1: + # Single point - repeat for all samples (creates static anchor) + return [{'x': points[0]['x'], 'y': points[0]['y']} for _ in range(num_samples)] + + # Calculate cumulative arc lengths along the path + cumulative_lengths = [0.0] + for i in range(len(points) - 1): + dx = points[i + 1]['x'] - points[i]['x'] + dy = points[i + 1]['y'] - points[i]['y'] + length = math.sqrt(dx * dx + dy * dy) + cumulative_lengths.append(cumulative_lengths[-1] + length) + + total_length = cumulative_lengths[-1] + + # Handle zero-length path (all points are the same) + if total_length == 0: + return [{'x': points[0]['x'], 'y': points[0]['y']} for _ in range(num_samples)] + + # Resample at even intervals along the arc + resampled = [] + for i in range(num_samples): + # Calculate target distance along path + if num_samples == 1: + target_length = 0 + else: + target_length = (i / (num_samples - 1)) * total_length + + # Find segment containing target length + for j in range(len(cumulative_lengths) - 1): + if cumulative_lengths[j] <= target_length <= cumulative_lengths[j + 1]: + # Interpolate within this segment + seg_length = cumulative_lengths[j + 1] - cumulative_lengths[j] + if seg_length > 0: + t = (target_length - cumulative_lengths[j]) / seg_length + else: + t = 0 + + x = points[j]['x'] + t * (points[j + 1]['x'] - points[j]['x']) + y = points[j]['y'] + t * (points[j + 1]['y'] - points[j]['y']) + resampled.append({'x': x, 'y': y}) + break + else: + # Fallback to last point (shouldn't happen with correct logic) + resampled.append({'x': points[-1]['x'], 'y': points[-1]['y']}) + + return resampled + + def interpolate_path(self, points, t): + """ + Interpolate position along a path at time t (0.0 to 1.0) + Returns (x, y) coordinates + + NOTE: This is used for visualization/animation only. + For WAN ATI output, use resample_path_uniform() instead. + """ + if len(points) == 0: + return (0, 0) + + # Support static single points + if len(points) == 1: + return (points[0]['x'], points[0]['y']) + + # Calculate total path length + total_length = 0 + segment_lengths = [] + for i in range(len(points) - 1): + dx = points[i + 1]['x'] - points[i]['x'] + dy = points[i + 1]['y'] - points[i]['y'] + length = math.sqrt(dx * dx + dy * dy) + segment_lengths.append(length) + total_length += length + + if total_length == 0: + return (points[0]['x'], points[0]['y']) + + # Find target distance along path + target_distance = t * total_length + + # Find which segment contains target distance + current_distance = 0 + for i, seg_length in enumerate(segment_lengths): + if current_distance + seg_length >= target_distance: + # Interpolate within this segment + segment_t = (target_distance - current_distance) / seg_length if seg_length > 0 else 0 + x = points[i]['x'] + (points[i + 1]['x'] - points[i]['x']) * segment_t + y = points[i]['y'] + (points[i + 1]['y'] - points[i]['y']) * segment_t + return (x, y) + current_distance += seg_length + + # Return last point if we've gone past the end + return (points[-1]['x'], points[-1]['y']) + + def animate_paths(self, frame_width, frame_height, frame_count, shape, shape_size, + shape_color, bg_color, blur_radius=0.0, trail_length=0.0, + rotation_speed=0.0, border_width=0, border_color='white', + paths_data='{"paths": [], "canvas_size": {"width": 512, "height": 512}}'): + + # Parse colors + shape_color = parse_color(shape_color) + bg_color = parse_color(bg_color) + border_color = parse_color(border_color) + + # Parse paths data + try: + paths_obj = json.loads(paths_data) + paths = paths_obj.get('paths', []) + canvas_size = paths_obj.get('canvas_size', {'width': frame_width, 'height': frame_height}) + except json.JSONDecodeError: + print("FL_PathAnimatorV2: Invalid JSON in paths_data, using empty paths") + paths = [] + canvas_size = {'width': frame_width, 'height': frame_height} + + # Calculate scaling factors to transform from canvas coordinates to frame coordinates + canvas_width = canvas_size.get('width', frame_width) + canvas_height = canvas_size.get('height', frame_height) + scale_x = frame_width / canvas_width if canvas_width > 0 else 1.0 + scale_y = frame_height / canvas_height if canvas_height > 0 else 1.0 + + # Scale all path coordinates + scaled_paths = [] + for path in paths: + scaled_path = path.copy() + scaled_points = [] + for point in path.get('points', []): + scaled_points.append({ + 'x': point['x'] * scale_x, + 'y': point['y'] * scale_y + }) + scaled_path['points'] = scaled_points + + # Preserve isSinglePoint flag if it exists + if 'isSinglePoint' in path: + scaled_path['isSinglePoint'] = path['isSinglePoint'] + + scaled_paths.append(scaled_path) + + images_list = [] + masks_list = [] + previous_output = None + + for frame in range(frame_count): + # Create blank image with bg_color + image = Image.new("RGB", (frame_width, frame_height), bg_color) + draw = ImageDraw.Draw(image) + + # Calculate time along path (0.0 to 1.0) + t = frame / max(frame_count - 1, 1) + + # Draw each path's shape + for path_idx, path in enumerate(scaled_paths): + points = path.get('points', []) + if len(points) == 0: + continue + + # Get position along this path + x, y = self.interpolate_path(points, t) + + # Calculate rotation + current_rotation = rotation_speed * t * 360.0 + + # Draw the shape + self.draw_shape(draw, shape, x, y, shape_size, current_rotation, + shape_color, border_width, border_color) + + # Apply blur + if blur_radius > 0: + image = image.filter(ImageFilter.GaussianBlur(blur_radius)) + + # Convert to tensor + image_tensor = pil2tensor(image) + + # Apply trailing effect + if trail_length > 0 and previous_output is not None: + image_tensor = image_tensor + trail_length * previous_output + image_tensor = image_tensor / image_tensor.max() + + previous_output = image_tensor.clone() + + # Clamp values + image_tensor = torch.clamp(image_tensor, 0.0, 1.0) + + # Extract mask from red channel + mask = image_tensor[:, :, :, 0] + + images_list.append(image_tensor) + masks_list.append(mask) + + # Concatenate all frames + out_images = torch.cat(images_list, dim=0) + out_masks = torch.cat(masks_list, dim=0) + + # SOLUTION 2 & 3: Generate WAN ATI-compatible coordinate string + # Resample each path to exactly 121 points with visibility flags + coord_tracks = [] + for path in scaled_paths: + points = path.get('points', []) + + # Check if this is a single-point path (static anchor) + is_single_point = path.get('isSinglePoint', False) or len(points) == 1 + + # Resample to exactly 121 points for WAN ATI compatibility + resampled_points = self.resample_path_uniform(points, num_samples=121) + + # Add visibility flag (1.0 = visible, required by WAN ATI) + # Format: [{"x": x, "y": y}, {"x": x, "y": y}, ...] + # The visibility will be added as a third coordinate when processed by ATI + track_coords = [ + {"x": int(round(p["x"])), "y": int(round(p["y"]))} + for p in resampled_points + ] + + coord_tracks.append(track_coords) + + # Output as list of tracks (each track is a list of 121 {x, y} points) + coord_string = json.dumps(coord_tracks) + + print(f"FL_PathAnimatorV2: Generated {len(coord_tracks)} tracks with 121 points each for WAN ATI") + + return (out_images, out_masks, coord_string) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..33e15e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Pillow>=9.0.0 +numpy>=1.20.0 diff --git a/web/nodes/FL_PathAnimatorV2.js b/web/nodes/FL_PathAnimatorV2.js new file mode 100644 index 0000000..08d0cd2 --- /dev/null +++ b/web/nodes/FL_PathAnimatorV2.js @@ -0,0 +1,1464 @@ +/** + * File: FL_PathAnimatorV2.js + * Project: ComfyUI_FL-Path-Animator + * + * Interactive path animator with modal drawing editor + */ + +import { app } from "../../../../../scripts/app.js"; +import { api } from "../../../../../scripts/api.js"; + +function moveWidgetToTop(node, widget) { + if (!widget) return; + + // Find the widget's current index + const widgetIndex = node.widgets.indexOf(widget); + if (widgetIndex > 0) { + // Remove from current position + node.widgets.splice(widgetIndex, 1); + // Insert at the beginning + node.widgets.unshift(widget); + } +} + +app.registerExtension({ + name: "FillNodes.PathAnimatorV2", + async nodeCreated(node) { + if (node.comfyClass === "FL_PathAnimatorV2") { + // Find the paths_data widget immediately + const pathsDataWidget = node.widgets.find(w => w.name === "paths_data"); + if (!pathsDataWidget) { + console.error("FL_PathAnimatorV2: 'paths_data' widget not found!"); + return; + } + + // Move it to the top of the widget list so it renders first + moveWidgetToTop(node, pathsDataWidget); + + // Initialize cached background image storage on the widget + pathsDataWidget._cachedBackgroundImage = null; + + // Add "Edit Paths" button + const editButton = node.addWidget("button", "Edit Paths", null, () => { + openPathEditor(node, pathsDataWidget); + }); + + // Add path count display (readonly) + const pathCountWidget = node.addWidget("text", "Path Count", "0 paths", null); + pathCountWidget.disabled = true; + + // Update path count when paths change + function updatePathCount() { + try { + const data = JSON.parse(pathsDataWidget.value); + const count = data.paths ? data.paths.length : 0; + const staticCount = data.paths ? data.paths.filter(p => p.isSinglePoint || p.points.length === 1).length : 0; + const motionCount = count - staticCount; + pathCountWidget.value = `${count} path${count !== 1 ? 's' : ''} (${staticCount} static, ${motionCount} motion)`; + } catch (e) { + pathCountWidget.value = "0 paths"; + } + } + + // Initial update + updatePathCount(); + + // Store update function for later use + node._updatePathCount = updatePathCount; + } + } +}); + +function openPathEditor(node, pathsDataWidget) { + // Get frame dimensions from node widgets + const frameWidthWidget = node.widgets.find(w => w.name === "frame_width"); + const frameHeightWidget = node.widgets.find(w => w.name === "frame_height"); + + const frameWidth = frameWidthWidget ? frameWidthWidget.value : 512; + const frameHeight = frameHeightWidget ? frameHeightWidget.value : 512; + + // Create and show modal + const modal = new PathEditorModal(node, pathsDataWidget, frameWidth, frameHeight); + modal.show(); +} + +class PathEditorModal { + constructor(node, pathsDataWidget, frameWidth, frameHeight) { + this.node = node; + this.pathsDataWidget = pathsDataWidget; + this.frameWidth = frameWidth; + this.frameHeight = frameHeight; + this.paths = []; + this.currentPath = null; + this.selectedPathIndex = -1; + this.isDrawing = false; + this.tool = 'pencil'; // pencil, eraser, select, point + this.currentColor = this.getRandomColor(); + this.backgroundImage = null; + this.canvasScale = 1.0; + this.canvasOffsetX = 0; + this.canvasOffsetY = 0; + this.pathThickness = 3; // Visual thickness for displaying paths + this.shiftPressed = false; // Track shift key state + this.backgroundOpacity = 1.0; // Background image opacity (0.5 to 1.0) + this.animationOffset = 0; // For animated directional indicators + this.animationFrame = null; // Animation frame ID + + // Load existing paths + this.loadPaths(); + + // Load cached background image if it exists + this.loadCachedBackgroundImage(); + + // Create modal elements + this.createModal(); + + // Setup keyboard handlers + this.setupKeyboardHandlers(); + + // Start animation loop for directional indicators + this.startAnimation(); + } + + startAnimation() { + const animate = () => { + // Increment animation offset for directional flow + this.animationOffset += 0.5; + if (this.animationOffset > 20) { + this.animationOffset = 0; + } + this.render(); + this.animationFrame = requestAnimationFrame(animate); + }; + this.animationFrame = requestAnimationFrame(animate); + } + + stopAnimation() { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + setupKeyboardHandlers() { + this.keydownHandler = (e) => { + // Track shift key + if (e.key === 'Shift') { + this.shiftPressed = true; + } + + // Escape to save and close + if (e.key === 'Escape') { + this.savePaths(); + this.close(); + } + + // Ctrl+V to paste image from clipboard + if ((e.ctrlKey || e.metaKey) && e.key === 'v') { + console.log('FL_PathAnimatorV2: Ctrl+V detected'); + e.preventDefault(); + e.stopPropagation(); + // Try to read from clipboard using modern API + this.pasteFromClipboard(); + } + }; + + this.keyupHandler = (e) => { + if (e.key === 'Shift') { + this.shiftPressed = false; + } + }; + + this.pasteHandler = (e) => { + console.log('FL_PathAnimatorV2: Paste handler called'); + e.preventDefault(); + e.stopPropagation(); + this.handlePaste(e); + }; + + document.addEventListener('keydown', this.keydownHandler); + document.addEventListener('keyup', this.keyupHandler); + + console.log('FL_PathAnimatorV2: Keyboard handlers registered'); + } + + attachPasteListener() { + // Attach paste listener to container after it's created + if (this.container) { + this.container.addEventListener('paste', this.pasteHandler); + document.addEventListener('paste', this.pasteHandler); + console.log('FL_PathAnimatorV2: Paste handlers attached to container and document'); + } + } + + loadCachedBackgroundImage() { + // Check if there's a cached background image + if (this.pathsDataWidget._cachedBackgroundImage) { + const img = new Image(); + img.onload = () => { + this.backgroundImage = img; + if (this.canvas) { + this.canvas.width = img.width; + this.canvas.height = img.height; + this.render(); + } + }; + img.src = this.pathsDataWidget._cachedBackgroundImage; + } + } + + async pasteFromClipboard() { + console.log('FL_PathAnimatorV2: Attempting to read from clipboard using Clipboard API'); + + try { + // Check if Clipboard API is available + if (!navigator.clipboard || !navigator.clipboard.read) { + console.log('FL_PathAnimatorV2: Clipboard API not available, falling back to paste event'); + return; + } + + const clipboardItems = await navigator.clipboard.read(); + console.log('FL_PathAnimatorV2: Read', clipboardItems.length, 'items from clipboard'); + + for (const clipboardItem of clipboardItems) { + console.log('FL_PathAnimatorV2: Clipboard item types:', clipboardItem.types); + + for (const type of clipboardItem.types) { + if (type.startsWith('image/')) { + console.log('FL_PathAnimatorV2: Found image type:', type); + const blob = await clipboardItem.getType(type); + this.loadImageFromBlob(blob); + return; // Only load first image + } + } + } + + console.log('FL_PathAnimatorV2: No image found in clipboard'); + } catch (err) { + console.error('FL_PathAnimatorV2: Error reading from clipboard:', err); + console.log('FL_PathAnimatorV2: You may need to grant clipboard permission'); + } + } + + loadImageFromBlob(blob) { + console.log('FL_PathAnimatorV2: Loading image from blob, size:', blob.size); + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + this.backgroundImage = img; + // Update canvas size to match image + this.canvas.width = img.width; + this.canvas.height = img.height; + + // Cache the image data URL for later use + this.pathsDataWidget._cachedBackgroundImage = event.target.result; + + this.render(); + console.log('FL_PathAnimatorV2: Image pasted successfully -', img.width, 'x', img.height); + }; + img.onerror = () => { + console.error('FL_PathAnimatorV2: Failed to load pasted image'); + }; + img.src = event.target.result; + }; + reader.onerror = () => { + console.error('FL_PathAnimatorV2: Failed to read image blob'); + }; + reader.readAsDataURL(blob); + } + + handlePaste(e) { + console.log('FL_PathAnimatorV2: Paste event triggered'); + + // Get clipboard items + const items = e.clipboardData?.items; + if (!items) { + console.log('FL_PathAnimatorV2: No clipboard items found'); + return; + } + + console.log('FL_PathAnimatorV2: Clipboard has', items.length, 'items'); + + // Look for image items + for (let i = 0; i < items.length; i++) { + const item = items[i]; + console.log('FL_PathAnimatorV2: Item', i, 'type:', item.type); + + // Check if item is an image + if (item.type.indexOf('image') !== -1) { + console.log('FL_PathAnimatorV2: Found image in clipboard via paste event'); + const blob = item.getAsFile(); + if (!blob) { + console.log('FL_PathAnimatorV2: Failed to get blob from clipboard item'); + continue; + } + + // Use the shared loadImageFromBlob method + this.loadImageFromBlob(blob); + break; // Only paste the first image found + } + } + } + + loadPaths() { + try { + const data = JSON.parse(this.pathsDataWidget.value); + this.paths = data.paths || []; + } catch (e) { + console.error("Error loading paths:", e); + this.paths = []; + } + } + + savePaths() { + const data = { + paths: this.paths, + canvas_size: { + width: this.canvas.width, + height: this.canvas.height + } + }; + this.pathsDataWidget.value = JSON.stringify(data); + + // Update path count display + if (this.node._updatePathCount) { + this.node._updatePathCount(); + } + } + + getRandomColor() { + const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2']; + return colors[Math.floor(Math.random() * colors.length)]; + } + + createModal() { + // Create modal overlay + this.overlay = document.createElement('div'); + this.overlay.className = 'fl-path-editor-overlay'; + this.overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.2s ease-out; + `; + + // Create modal container + this.container = document.createElement('div'); + this.container.className = 'fl-path-editor-container'; + this.container.tabIndex = 0; // Make container focusable for paste events + this.container.style.cssText = ` + background: linear-gradient(145deg, #2d2d2d, #252525); + border-radius: 12px; + border: 1px solid #3a3a3a; + width: 95%; + height: 95%; + max-width: 2000px; + max-height: 1400px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05); + animation: slideIn 0.3s ease-out; + outline: none; + `; + + // Create header + this.createHeader(); + + // Create main content area + this.createMainContent(); + + // Create footer + this.createFooter(); + + this.overlay.appendChild(this.container); + + // Close on overlay click + this.overlay.addEventListener('click', (e) => { + if (e.target === this.overlay) { + this.close(); + } + }); + } + + createHeader() { + const header = document.createElement('div'); + header.style.cssText = ` + padding: 20px 24px 16px 24px; + border-bottom: 1px solid #404040; + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%); + display: flex; + flex-direction: column; + gap: 16px; + border-radius: 12px 12px 0 0; + `; + + // Top row with title and close button + const topRow = document.createElement('div'); + topRow.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + `; + + const titleContainer = document.createElement('div'); + titleContainer.style.cssText = ` + display: flex; + flex-direction: column; + gap: 4px; + `; + + const title = document.createElement('h2'); + title.textContent = '✏️ Path Animator Editor'; + title.style.cssText = ` + margin: 0; + color: #fff; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.5px; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + `; + + const subtitle = document.createElement('div'); + subtitle.textContent = 'Press ESC to save & close | Hold SHIFT for straight lines | CTRL+V to paste image'; + subtitle.style.cssText = ` + color: #888; + font-size: 12px; + font-weight: 400; + `; + + titleContainer.appendChild(title); + titleContainer.appendChild(subtitle); + + const closeBtn = document.createElement('button'); + closeBtn.textContent = '✕'; + closeBtn.style.cssText = ` + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: #fff; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + `; + closeBtn.onmouseover = () => { + closeBtn.style.background = 'rgba(255, 77, 77, 0.8)'; + closeBtn.style.borderColor = 'rgba(255, 77, 77, 1)'; + closeBtn.style.transform = 'scale(1.05)'; + }; + closeBtn.onmouseout = () => { + closeBtn.style.background = 'rgba(255, 255, 255, 0.05)'; + closeBtn.style.borderColor = 'rgba(255, 255, 255, 0.1)'; + closeBtn.style.transform = 'scale(1)'; + }; + closeBtn.onclick = () => this.close(); + + topRow.appendChild(titleContainer); + topRow.appendChild(closeBtn); + + // Controls row with sliders + const controlsRow = document.createElement('div'); + controlsRow.style.cssText = ` + display: flex; + gap: 32px; + align-items: center; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); + `; + + // Path Width slider + const widthControl = this.createSliderControl( + 'Path Width', + 1, 10, this.pathThickness, + (value) => { + this.pathThickness = value; + } + ); + + // Background Opacity slider + const opacityControl = this.createSliderControl( + 'Background Opacity', + 50, 100, this.backgroundOpacity * 100, + (value) => { + this.backgroundOpacity = value / 100; + }, + '%' + ); + + controlsRow.appendChild(widthControl); + controlsRow.appendChild(opacityControl); + + header.appendChild(topRow); + header.appendChild(controlsRow); + this.container.appendChild(header); + } + + createSliderControl(label, min, max, defaultValue, onChange, suffix = '') { + const container = document.createElement('div'); + container.style.cssText = ` + flex: 1; + display: flex; + align-items: center; + gap: 12px; + `; + + const labelEl = document.createElement('label'); + labelEl.textContent = label; + labelEl.style.cssText = ` + color: #fff; + font-size: 13px; + font-weight: 500; + min-width: 120px; + opacity: 0.9; + `; + + const sliderContainer = document.createElement('div'); + sliderContainer.style.cssText = ` + flex: 1; + display: flex; + align-items: center; + gap: 12px; + `; + + const slider = document.createElement('input'); + slider.type = 'range'; + slider.min = min; + slider.max = max; + slider.value = defaultValue; + slider.style.cssText = ` + flex: 1; + cursor: pointer; + accent-color: #4ECDC4; + height: 6px; + `; + + const valueDisplay = document.createElement('div'); + valueDisplay.textContent = defaultValue + suffix; + valueDisplay.style.cssText = ` + color: #4ECDC4; + font-size: 14px; + font-weight: bold; + min-width: 50px; + text-align: right; + `; + + slider.oninput = (e) => { + const value = parseInt(e.target.value); + valueDisplay.textContent = value + suffix; + onChange(value); + this.render(); + }; + + sliderContainer.appendChild(slider); + sliderContainer.appendChild(valueDisplay); + + container.appendChild(labelEl); + container.appendChild(sliderContainer); + + return container; + } + + createMainContent() { + const content = document.createElement('div'); + content.style.cssText = ` + flex: 1; + display: flex; + overflow: hidden; + `; + + // Create toolbar + this.createToolbar(content); + + // Create canvas area + this.createCanvasArea(content); + + // Create sidebar + this.createSidebar(content); + + this.container.appendChild(content); + } + + createToolbarButton(icon, title, isActive = false) { + const btn = document.createElement('button'); + btn.textContent = icon; + btn.title = title; + btn.style.cssText = ` + width: 50px; + height: 50px; + border: 2px solid ${isActive ? '#4ECDC4' : 'rgba(255, 255, 255, 0.15)'}; + background: ${isActive ? 'rgba(78, 205, 196, 0.2)' : 'rgba(255, 255, 255, 0.05)'}; + color: #fff; + cursor: pointer; + border-radius: 8px; + font-size: 20px; + transition: all 0.2s ease; + box-shadow: ${isActive ? '0 0 12px rgba(78, 205, 196, 0.3)' : 'none'}; + `; + btn.onmouseover = () => { + if (!isActive) { + btn.style.background = 'rgba(255, 255, 255, 0.1)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.25)'; + btn.style.transform = 'scale(1.05)'; + } + }; + btn.onmouseout = () => { + if (!isActive) { + btn.style.background = 'rgba(255, 255, 255, 0.05)'; + btn.style.borderColor = 'rgba(255, 255, 255, 0.15)'; + btn.style.transform = 'scale(1)'; + } + }; + return btn; + } + + createToolbar(parent) { + const toolbar = document.createElement('div'); + toolbar.style.cssText = ` + width: 70px; + background: linear-gradient(180deg, #1e1e1e 0%, #181818 100%); + border-right: 1px solid #3a3a3a; + padding: 12px 10px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3); + `; + + // Add image upload button + const uploadBtn = this.createToolbarButton('🖼️', 'Load Background Image'); + uploadBtn.onclick = () => this.loadImage(); + toolbar.appendChild(uploadBtn); + + // Add clear image button + const clearImgBtn = this.createToolbarButton('🚫', 'Clear Background Image'); + clearImgBtn.onclick = () => this.clearImage(); + toolbar.appendChild(clearImgBtn); + + // Add separator + const separator = document.createElement('div'); + separator.style.cssText = ` + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + margin: 8px 0; + `; + toolbar.appendChild(separator); + + const tools = [ + { name: 'pencil', icon: '✏️', title: 'Draw Path (Motion)' }, + { name: 'point', icon: '📍', title: 'Add Static Point (Anchor)' }, + { name: 'eraser', icon: '🗑️', title: 'Erase Path' }, + { name: 'select', icon: '↖️', title: 'Select Path' }, + ]; + + const toolButtons = []; + tools.forEach(tool => { + const btn = this.createToolbarButton(tool.icon, tool.title, this.tool === tool.name); + btn.dataset.tool = tool.name; + btn.onclick = () => { + this.tool = tool.name; + // Update canvas cursor + if (this.tool === 'point') { + this.canvas.style.cursor = 'crosshair'; + } else if (this.tool === 'pencil') { + this.canvas.style.cursor = 'crosshair'; + } else if (this.tool === 'eraser') { + this.canvas.style.cursor = 'not-allowed'; + } else if (this.tool === 'select') { + this.canvas.style.cursor = 'pointer'; + } + // Update all tool button states + toolButtons.forEach(tb => { + const isActive = tb.dataset.tool === tool.name; + tb.style.border = `2px solid ${isActive ? '#4ECDC4' : 'rgba(255, 255, 255, 0.15)'}`; + tb.style.background = isActive ? 'rgba(78, 205, 196, 0.2)' : 'rgba(255, 255, 255, 0.05)'; + tb.style.boxShadow = isActive ? '0 0 12px rgba(78, 205, 196, 0.3)' : 'none'; + }); + }; + toolButtons.push(btn); + toolbar.appendChild(btn); + }); + + // Add another separator + const separator2 = document.createElement('div'); + separator2.style.cssText = separator.style.cssText; + toolbar.appendChild(separator2); + + // Add Lock Perimeter button + const lockPerimeterBtn = this.createToolbarButton('🔒', 'Lock Perimeter - Add static shapes around border'); + lockPerimeterBtn.onclick = () => this.lockPerimeter(); + toolbar.appendChild(lockPerimeterBtn); + + // Add another separator + const separator3 = document.createElement('div'); + separator3.style.cssText = separator.style.cssText; + toolbar.appendChild(separator3); + + // Add clear all button + const clearBtn = this.createToolbarButton('🗑️', 'Clear All Paths'); + clearBtn.style.marginTop = 'auto'; + clearBtn.onclick = () => { + if (confirm('Clear all paths?')) { + this.paths = []; + this.selectedPathIndex = -1; + this.updateSidebar(); + this.render(); + } + }; + toolbar.appendChild(clearBtn); + + parent.appendChild(toolbar); + } + + loadImage() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + this.backgroundImage = img; + // Update canvas size to match image + this.canvas.width = img.width; + this.canvas.height = img.height; + + // Cache the image data URL for later use + this.pathsDataWidget._cachedBackgroundImage = event.target.result; + + this.render(); + }; + img.src = event.target.result; + }; + reader.readAsDataURL(file); + } + }; + input.click(); + } + + clearImage() { + if (confirm('Clear background image?')) { + this.backgroundImage = null; + this.pathsDataWidget._cachedBackgroundImage = null; + // Reset canvas to frame dimensions + this.canvas.width = this.frameWidth; + this.canvas.height = this.frameHeight; + this.render(); + } + } + + lockPerimeter() { + // Prompt user for number of points + const numPoints = prompt('How many shapes around the perimeter?', '12'); + if (!numPoints || isNaN(numPoints) || numPoints < 1) return; + + const count = parseInt(numPoints); + const w = this.canvas.width; + const h = this.canvas.height; + const perimeter = 2 * (w + h); + const spacing = perimeter / count; + + // Create paths around the perimeter + for (let i = 0; i < count; i++) { + const d = i * spacing; + let x, y; + + if (d < w) { + // Top edge + x = d; + y = 0; + } else if (d < w + h) { + // Right edge + x = w; + y = d - w; + } else if (d < 2 * w + h) { + // Bottom edge + x = w - (d - w - h); + y = h; + } else { + // Left edge + x = 0; + y = h - (d - 2 * w - h); + } + + // Create a static path (single point) at this position + const path = { + id: 'path_' + Date.now() + '_' + i, + name: 'Perimeter ' + (i + 1), + points: [{ x: Math.round(x), y: Math.round(y) }], + color: this.getRandomColor(), + isSinglePoint: true + }; + this.paths.push(path); + } + + this.updateSidebar(); + this.render(); + } + + createCanvasArea(parent) { + const canvasContainer = document.createElement('div'); + canvasContainer.style.cssText = ` + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at center, #1e1e1e 0%, #0a0a0a 100%); + position: relative; + overflow: hidden; + padding: 20px; + `; + + this.canvas = document.createElement('canvas'); + this.canvas.width = this.frameWidth; + this.canvas.height = this.frameHeight; + this.canvas.style.cssText = ` + border: 1px solid #4a4a4a; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); + cursor: crosshair; + max-width: 100%; + max-height: 100%; + border-radius: 4px; + `; + + this.ctx = this.canvas.getContext('2d'); + + // Setup canvas event listeners + this.setupCanvasEvents(); + + canvasContainer.appendChild(this.canvas); + parent.appendChild(canvasContainer); + + // Initial render + this.render(); + } + + setupCanvasEvents() { + this.canvas.addEventListener('mousedown', (e) => this.onMouseDown(e)); + this.canvas.addEventListener('mousemove', (e) => this.onMouseMove(e)); + this.canvas.addEventListener('mouseup', (e) => this.onMouseUp(e)); + this.canvas.addEventListener('mouseleave', (e) => this.onMouseUp(e)); + } + + getCanvasCoords(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY + }; + } + + onMouseDown(e) { + const pos = this.getCanvasCoords(e); + + if (this.tool === 'pencil') { + this.isDrawing = true; + this.currentPath = { + id: 'path_' + Date.now(), + name: 'Path ' + (this.paths.length + 1), + points: [pos], + color: this.currentColor, + closed: false, + isSinglePoint: false + }; + } else if (this.tool === 'point') { + // Add single static point + const path = { + id: 'path_' + Date.now(), + name: 'Static ' + (this.paths.filter(p => p.isSinglePoint).length + 1), + points: [pos], + color: this.currentColor, + isSinglePoint: true + }; + this.paths.push(path); + this.selectedPathIndex = this.paths.length - 1; + this.currentColor = this.getRandomColor(); + this.updateSidebar(); + this.render(); + } else if (this.tool === 'select') { + // Find clicked path (returns -1 if no path found) + this.selectedPathIndex = this.findPathAtPoint(pos); + // selectedPathIndex will be -1 if clicking empty space, which deselects + this.updateSidebar(); + this.render(); + } else if (this.tool === 'eraser') { + // Erase path at this point + const pathIndex = this.findPathAtPoint(pos); + if (pathIndex !== -1) { + this.paths.splice(pathIndex, 1); + this.selectedPathIndex = -1; + this.updateSidebar(); + this.render(); + } + } + } + + onMouseMove(e) { + if (this.isDrawing && this.tool === 'pencil') { + const pos = this.getCanvasCoords(e); + + // If shift is pressed, draw straight line from last point + if (this.shiftPressed && this.currentPath.points.length > 0) { + // Replace the preview point if it exists, or add it + const lastPoint = this.currentPath.points[this.currentPath.points.length - 1]; + + // Determine if line should be horizontal, vertical, or diagonal + const dx = Math.abs(pos.x - lastPoint.x); + const dy = Math.abs(pos.y - lastPoint.y); + + let constrainedPos; + if (dx > dy * 2) { + // Horizontal + constrainedPos = { x: pos.x, y: lastPoint.y }; + } else if (dy > dx * 2) { + // Vertical + constrainedPos = { x: lastPoint.x, y: pos.y }; + } else { + // Diagonal 45 degrees + const dist = Math.min(dx, dy); + constrainedPos = { + x: lastPoint.x + (pos.x > lastPoint.x ? dist : -dist), + y: lastPoint.y + (pos.y > lastPoint.y ? dist : -dist) + }; + } + + // Store original last point count to know if we're previewing + if (!this.shiftPreviewPoint) { + this.shiftPreviewPoint = true; + this.currentPath.points.push(constrainedPos); + } else { + this.currentPath.points[this.currentPath.points.length - 1] = constrainedPos; + } + this.render(); + } else { + // Normal drawing - clear shift preview flag + this.shiftPreviewPoint = false; + + // Only add point if it's far enough from the last point (smoothing) + const lastPoint = this.currentPath.points[this.currentPath.points.length - 1]; + const dist = Math.sqrt(Math.pow(pos.x - lastPoint.x, 2) + Math.pow(pos.y - lastPoint.y, 2)); + + if (dist > 3) { // Minimum distance between points + this.currentPath.points.push(pos); + this.render(); + } + } + } + } + + onMouseUp(e) { + if (this.isDrawing && this.currentPath) { + // Clear shift preview flag + this.shiftPreviewPoint = false; + + if (this.currentPath.points.length > 1) { + this.paths.push(this.currentPath); + this.selectedPathIndex = this.paths.length - 1; + this.currentColor = this.getRandomColor(); + this.updateSidebar(); + } else if (this.currentPath.points.length === 1) { + // Single click became a static point + this.currentPath.isSinglePoint = true; + this.currentPath.name = 'Static ' + (this.paths.filter(p => p.isSinglePoint).length + 1); + this.paths.push(this.currentPath); + this.selectedPathIndex = this.paths.length - 1; + this.currentColor = this.getRandomColor(); + this.updateSidebar(); + } + this.currentPath = null; + this.isDrawing = false; + this.render(); + } + } + + findPathAtPoint(point, baseThreshold = 10) { + // Scale threshold based on canvas resolution + const scale = this.getRenderScale(); + const threshold = baseThreshold * scale; + + // Check single points first (easier to select) + for (let i = this.paths.length - 1; i >= 0; i--) { + const path = this.paths[i]; + if (path.isSinglePoint || path.points.length === 1) { + const p = path.points[0]; + const dist = Math.sqrt(Math.pow(point.x - p.x, 2) + Math.pow(point.y - p.y, 2)); + if (dist < threshold) { + return i; + } + } + } + + // Then check multi-point paths + for (let i = this.paths.length - 1; i >= 0; i--) { + const path = this.paths[i]; + if (!path.isSinglePoint && path.points.length > 1) { + for (let j = 0; j < path.points.length - 1; j++) { + const p1 = path.points[j]; + const p2 = path.points[j + 1]; + + // Check if point is near line segment + const dist = this.distanceToSegment(point, p1, p2); + if (dist < threshold) { + return i; + } + } + } + } + return -1; + } + + distanceToSegment(point, p1, p2) { + const A = point.x - p1.x; + const B = point.y - p1.y; + const C = p2.x - p1.x; + const D = p2.y - p1.y; + + 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 = p1.x; + yy = p1.y; + } else if (param > 1) { + xx = p2.x; + yy = p2.y; + } else { + xx = p1.x + param * C; + yy = p1.y + param * D; + } + + const dx = point.x - xx; + const dy = point.y - yy; + return Math.sqrt(dx * dx + dy * dy); + } + + getRenderScale() { + // Calculate scale factor based on canvas dimensions + // Use 512 as the base resolution for consistent rendering + const baseResolution = 512; + const minDimension = Math.min(this.canvas.width, this.canvas.height); + return minDimension / baseResolution; + } + + render() { + if (!this.ctx) return; + + // Clear canvas + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // Draw background image with opacity + if (this.backgroundImage && this.backgroundImage.complete) { + this.ctx.save(); + this.ctx.globalAlpha = this.backgroundOpacity; + this.ctx.drawImage(this.backgroundImage, 0, 0, this.canvas.width, this.canvas.height); + this.ctx.restore(); + } else { + this.ctx.fillStyle = '#333'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + // Draw saved paths + this.paths.forEach((path, index) => { + this.drawPath(path, index === this.selectedPathIndex); + }); + + // Draw current path being drawn + if (this.currentPath) { + this.drawPath(this.currentPath, true); + } + } + + drawPath(path, isSelected = false) { + const isSinglePoint = path.isSinglePoint || path.points.length === 1; + const scale = this.getRenderScale(); + const neonGreen = '#00FF41'; // Neon green for selection + + if (isSinglePoint) { + // Draw single point as a square (static anchor) + const point = path.points[0]; + const baseSize = isSelected ? 14 : 8; + const size = baseSize * scale; + + this.ctx.fillStyle = isSelected ? neonGreen : path.color; + this.ctx.fillRect(point.x - size / 2, point.y - size / 2, size, size); + + // Draw border + this.ctx.strokeStyle = isSelected ? neonGreen : '#fff'; + this.ctx.lineWidth = (isSelected ? 4 : 2) * scale; + this.ctx.strokeRect(point.x - size / 2, point.y - size / 2, size, size); + + // Draw label for static points + if (isSelected) { + this.ctx.fillStyle = neonGreen; + this.ctx.font = `bold ${12 * scale}px sans-serif`; + this.ctx.fillText('📍 Static', point.x + 10 * scale, point.y - 10 * scale); + } + } else if (path.points.length >= 2) { + // Draw multi-point path (motion path) + this.ctx.beginPath(); + this.ctx.moveTo(path.points[0].x, path.points[0].y); + + for (let i = 1; i < path.points.length; i++) { + this.ctx.lineTo(path.points[i].x, path.points[i].y); + } + + this.ctx.strokeStyle = isSelected ? neonGreen : path.color; + this.ctx.lineWidth = (isSelected ? this.pathThickness + 3 : this.pathThickness) * scale; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + this.ctx.stroke(); + + // Draw animated directional flow indicators (dashed overlay) + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.moveTo(path.points[0].x, path.points[0].y); + + for (let i = 1; i < path.points.length; i++) { + this.ctx.lineTo(path.points[i].x, path.points[i].y); + } + + // Animated dashed line showing direction + const dashLength = 10 * scale; + const gapLength = 10 * scale; + this.ctx.setLineDash([dashLength, gapLength]); + this.ctx.lineDashOffset = -this.animationOffset * scale; // Negative for forward motion + this.ctx.strokeStyle = isSelected ? 'rgba(0, 255, 65, 0.8)' : 'rgba(255, 255, 255, 0.6)'; + this.ctx.lineWidth = Math.max(1, this.pathThickness * 0.5) * scale; + this.ctx.stroke(); + this.ctx.restore(); + + // Draw points + if (isSelected) { + path.points.forEach((point, idx) => { + // Draw outer ring + this.ctx.beginPath(); + this.ctx.arc(point.x, point.y, Math.max(6, this.pathThickness + 2) * scale, 0, Math.PI * 2); + this.ctx.fillStyle = neonGreen; + this.ctx.fill(); + + // Draw inner point + this.ctx.beginPath(); + this.ctx.arc(point.x, point.y, Math.max(3, this.pathThickness * 0.6) * scale, 0, Math.PI * 2); + this.ctx.fillStyle = '#000'; + this.ctx.fill(); + + // Draw point numbers + if (path.points.length < 20) { // Only if not too many points + this.ctx.fillStyle = neonGreen; + this.ctx.font = `bold ${10 * scale}px sans-serif`; + this.ctx.fillText(idx, point.x + 8 * scale, point.y - 8 * scale); + } + }); + + // Draw label for motion paths + const midPoint = path.points[Math.floor(path.points.length / 2)]; + this.ctx.fillStyle = neonGreen; + this.ctx.font = `bold ${12 * scale}px sans-serif`; + this.ctx.fillText(`↗️ Motion (${path.points.length} pts)`, midPoint.x + 10 * scale, midPoint.y - 10 * scale); + } + } + } + + createSidebar(parent) { + this.sidebar = document.createElement('div'); + this.sidebar.style.cssText = ` + width: 280px; + background: #1e1e1e; + border-left: 1px solid #444; + padding: 15px; + overflow-y: auto; + `; + + const title = document.createElement('h3'); + title.textContent = 'Paths'; + title.style.cssText = ` + margin: 0 0 15px 0; + color: #fff; + font-size: 14px; + font-weight: 500; + `; + this.sidebar.appendChild(title); + + this.pathList = document.createElement('div'); + this.pathList.style.cssText = ` + display: flex; + flex-direction: column; + gap: 8px; + `; + this.sidebar.appendChild(this.pathList); + + parent.appendChild(this.sidebar); + + this.updateSidebar(); + } + + updateSidebar() { + if (!this.pathList) return; + + this.pathList.innerHTML = ''; + + this.paths.forEach((path, index) => { + const isSinglePoint = path.isSinglePoint || path.points.length === 1; + const neonGreen = '#00FF41'; + const isSelected = index === this.selectedPathIndex; + + const item = document.createElement('div'); + item.style.cssText = ` + padding: 10px; + background: ${isSelected ? 'rgba(0, 255, 65, 0.15)' : '#2b2b2b'}; + border: 2px solid ${isSelected ? neonGreen : '#444'}; + border-radius: 4px; + cursor: pointer; + color: #fff; + font-size: 12px; + display: flex; + flex-direction: column; + gap: 6px; + transition: all 0.2s ease; + `; + + const topRow = document.createElement('div'); + topRow.style.cssText = ` + display: flex; + justify-content: space-between; + align-items: center; + `; + + const info = document.createElement('div'); + info.style.cssText = ` + display: flex; + align-items: center; + gap: 8px; + flex: 1; + `; + + const colorBox = document.createElement('div'); + colorBox.style.cssText = ` + width: 16px; + height: 16px; + background: ${isSelected ? neonGreen : path.color}; + border-radius: ${isSinglePoint ? '2px' : '50%'}; + border: 2px solid ${isSelected ? neonGreen : (isSinglePoint ? '#fff' : 'transparent')}; + `; + + const nameContainer = document.createElement('div'); + nameContainer.style.cssText = ` + display: flex; + flex-direction: column; + gap: 2px; + `; + + const name = document.createElement('span'); + name.textContent = path.name || `Path ${index + 1}`; + name.style.cssText = ` + font-weight: 500; + color: ${isSelected ? neonGreen : '#fff'}; + `; + + const typeLabel = document.createElement('span'); + typeLabel.textContent = isSinglePoint + ? '📍 Static (1 pt)' + : `↗️ Motion (${path.points.length} pts)`; + typeLabel.style.cssText = ` + font-size: 10px; + color: ${isSelected ? neonGreen : (isSinglePoint ? '#F7DC6F' : '#4ECDC4')}; + `; + + nameContainer.appendChild(name); + nameContainer.appendChild(typeLabel); + + info.appendChild(colorBox); + info.appendChild(nameContainer); + + const deleteBtn = document.createElement('button'); + deleteBtn.textContent = '✕'; + deleteBtn.style.cssText = ` + background: rgba(255, 77, 77, 0.2); + border: 1px solid rgba(255, 77, 77, 0.4); + border-radius: 4px; + color: #ff4d4d; + cursor: pointer; + font-size: 14px; + padding: 4px 8px; + transition: all 0.2s ease; + `; + deleteBtn.onmouseover = () => { + deleteBtn.style.background = 'rgba(255, 77, 77, 0.4)'; + deleteBtn.style.borderColor = 'rgba(255, 77, 77, 0.8)'; + }; + deleteBtn.onmouseout = () => { + deleteBtn.style.background = 'rgba(255, 77, 77, 0.2)'; + deleteBtn.style.borderColor = 'rgba(255, 77, 77, 0.4)'; + }; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + this.paths.splice(index, 1); + this.selectedPathIndex = -1; + this.updateSidebar(); + this.render(); + }; + + topRow.appendChild(info); + topRow.appendChild(deleteBtn); + item.appendChild(topRow); + + item.onclick = () => { + this.selectedPathIndex = index; + this.updateSidebar(); + this.render(); + }; + + this.pathList.appendChild(item); + }); + } + + createFooter() { + const footer = document.createElement('div'); + footer.style.cssText = ` + padding: 15px 20px; + border-top: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + `; + + const statsContainer = document.createElement('div'); + statsContainer.style.cssText = ` + color: #888; + font-size: 12px; + `; + const staticCount = this.paths.filter(p => p.isSinglePoint || p.points.length === 1).length; + const motionCount = this.paths.length - staticCount; + statsContainer.textContent = `Total: ${this.paths.length} paths (${staticCount} static, ${motionCount} motion)`; + + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + gap: 10px; + `; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = 'Cancel'; + cancelBtn.style.cssText = ` + padding: 8px 20px; + background: #444; + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 14px; + `; + cancelBtn.onclick = () => this.close(); + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save Paths'; + saveBtn.style.cssText = ` + padding: 8px 20px; + background: #4ECDC4; + border: none; + border-radius: 4px; + color: #fff; + cursor: pointer; + font-size: 14px; + font-weight: 500; + `; + saveBtn.onclick = () => { + this.savePaths(); + this.close(); + }; + + buttonContainer.appendChild(cancelBtn); + buttonContainer.appendChild(saveBtn); + + footer.appendChild(statsContainer); + footer.appendChild(buttonContainer); + this.container.appendChild(footer); + } + + show() { + // Add CSS animations if not already added + if (!document.getElementById('fl-path-animator-styles')) { + const style = document.createElement('style'); + style.id = 'fl-path-animator-styles'; + style.textContent = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } + } + `; + document.head.appendChild(style); + } + document.body.appendChild(this.overlay); + + // Attach paste listener after modal is shown + this.attachPasteListener(); + + // Focus the container so it can receive paste events + setTimeout(() => { + this.container.focus(); + console.log('FL_PathAnimatorV2: Container focused'); + }, 100); + } + + close() { + // Stop animation loop + this.stopAnimation(); + + // Remove keyboard handlers + document.removeEventListener('keydown', this.keydownHandler); + document.removeEventListener('keyup', this.keyupHandler); + + // Remove paste handlers from both container and document + if (this.container) { + this.container.removeEventListener('paste', this.pasteHandler); + } + document.removeEventListener('paste', this.pasteHandler); + + console.log('FL_PathAnimatorV2: All event listeners removed'); + + // Fade out animation + this.overlay.style.animation = 'fadeIn 0.15s ease-in reverse'; + this.container.style.animation = 'slideIn 0.15s ease-in reverse'; + setTimeout(() => { + if (this.overlay.parentNode) { + document.body.removeChild(this.overlay); + } + }, 150); + } +}