diff --git a/nodes/FL_PathAnimator.py b/nodes/FL_PathAnimator.py index 40a886a..6dc5d0b 100644 --- a/nodes/FL_PathAnimator.py +++ b/nodes/FL_PathAnimator.py @@ -25,6 +25,24 @@ def parse_color(color): return (255, 255, 255) return color +def apply_interpolation(t, interpolation_type='linear'): + """Apply interpolation easing function to parameter t (0.0 to 1.0)""" + if interpolation_type == 'ease-in': + # Quadratic ease in + return t * t + elif interpolation_type == 'ease-out': + # Quadratic ease out + return t * (2 - t) + elif interpolation_type == 'ease-in-out': + # Quadratic ease in-out + if t < 0.5: + return 2 * t * t + else: + return -1 + (4 - 2 * t) * t + else: + # Linear (default) + return t + class FL_PathAnimator: RETURN_TYPES = ("IMAGE", "MASK", "STRING",) @@ -298,8 +316,8 @@ Outputs WAN ATI-compatible coordinate strings with proper 121-point resampling f 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) + # Calculate global time (0.0 to 1.0) + global_t = frame / max(frame_count - 1, 1) # Draw each path's shape for path_idx, path in enumerate(scaled_paths): @@ -307,11 +325,42 @@ Outputs WAN ATI-compatible coordinate strings with proper 121-point resampling f if len(points) == 0: continue - # Get position along this path - x, y = self.interpolate_path(points, t) + # Get timeline parameters + start_time = path.get('startTime', 0.0) + end_time = path.get('endTime', 1.0) + interpolation = path.get('interpolation', 'linear') + visibility_mode = path.get('visibilityMode', 'pop') - # Calculate rotation - current_rotation = rotation_speed * t * 360.0 + # Determine if shape should be visible and animated + is_in_timeline = start_time <= global_t <= end_time + + # Skip rendering based on visibility mode + if visibility_mode == 'pop' and not is_in_timeline: + # Pop mode: don't render outside timeline + continue + + # Calculate local time and position + if is_in_timeline and end_time > start_time: + # Animate within timeline + local_t = (global_t - start_time) / (end_time - start_time) + eased_t = apply_interpolation(local_t, interpolation) + x, y = self.interpolate_path(points, eased_t) + # Calculate rotation based on local time + current_rotation = rotation_speed * eased_t * 360.0 + elif visibility_mode == 'static': + # Static mode: show at start or end position when outside timeline + if global_t < start_time: + # Before timeline: show at start position + x, y = self.interpolate_path(points, 0.0) + current_rotation = 0.0 + else: + # After timeline: show at end position + x, y = self.interpolate_path(points, 1.0) + current_rotation = rotation_speed * 360.0 + else: + # Fallback: show at start position + x, y = self.interpolate_path(points, 0.0) + current_rotation = 0.0 # Draw the shape self.draw_shape(draw, shape, x, y, shape_size, current_rotation, diff --git a/pyproject.toml b/pyproject.toml index d989651..0d6bbca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-fl-path-animator" description = "Interactive path animator with visual editor for creating animated shapes that follow user-drawn paths. WAN ATI compatible for stable video generation." -version = "1.0.1" +version = "1.0.2" license = { file = "LICENSE.txt" } dependencies = [ "Pillow>=9.0.0", diff --git a/web/nodes/FL_PathAnimator.js b/web/nodes/FL_PathAnimator.js index 674cf61..f84c552 100644 --- a/web/nodes/FL_PathAnimator.js +++ b/web/nodes/FL_PathAnimator.js @@ -834,7 +834,11 @@ class PathEditorModal { name: 'Perimeter ' + (i + 1), points: [{ x: Math.round(x), y: Math.round(y) }], color: this.getRandomColor(), - isSinglePoint: true + isSinglePoint: true, + startTime: 0.0, + endTime: 1.0, + interpolation: 'linear', + visibilityMode: 'pop' }; this.paths.push(path); } @@ -908,7 +912,11 @@ class PathEditorModal { points: [pos], color: this.currentColor, closed: false, - isSinglePoint: false + isSinglePoint: false, + startTime: 0.0, + endTime: 1.0, + interpolation: 'linear', + visibilityMode: 'pop' }; } else if (this.tool === 'point') { // Add single static point @@ -917,7 +925,11 @@ class PathEditorModal { name: 'Static ' + (this.paths.filter(p => p.isSinglePoint).length + 1), points: [pos], color: this.currentColor, - isSinglePoint: true + isSinglePoint: true, + startTime: 0.0, + endTime: 1.0, + interpolation: 'linear', + visibilityMode: 'pop' }; this.paths.push(path); this.selectedPathIndex = this.paths.length - 1; @@ -1358,7 +1370,16 @@ class PathEditorModal { topRow.appendChild(deleteBtn); item.appendChild(topRow); - item.onclick = () => { + // Add timeline controls if selected + if (isSelected) { + const timelineControls = this.createTimelineControls(path, index); + item.appendChild(timelineControls); + } + + item.onclick = (e) => { + // Don't trigger selection if clicking on controls + if (e.target.closest('.timeline-controls')) return; + this.selectedPathIndex = index; this.updateSidebar(); this.render(); @@ -1368,6 +1389,310 @@ class PathEditorModal { }); } + createTimelineControls(path, pathIndex) { + const container = document.createElement('div'); + container.className = 'timeline-controls'; + container.style.cssText = ` + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + flex-direction: column; + gap: 12px; + `; + + // Timeline Range Slider + const timelineSection = document.createElement('div'); + timelineSection.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + `; + + const timelineLabel = document.createElement('label'); + timelineLabel.textContent = 'Timeline Range'; + timelineLabel.style.cssText = ` + color: #fff; + font-size: 11px; + font-weight: 500; + opacity: 0.9; + `; + + const timelineSliderContainer = document.createElement('div'); + timelineSliderContainer.style.cssText = ` + position: relative; + height: 40px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + padding: 8px; + `; + + // Create range track + const rangeTrack = document.createElement('div'); + rangeTrack.style.cssText = ` + position: absolute; + left: 8px; + right: 8px; + top: 50%; + transform: translateY(-50%); + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + `; + + // Create active range indicator + const activeRange = document.createElement('div'); + const startPercent = (path.startTime || 0) * 100; + const endPercent = (path.endTime || 1) * 100; + activeRange.style.cssText = ` + position: absolute; + left: ${startPercent}%; + width: ${endPercent - startPercent}%; + height: 100%; + background: #4ECDC4; + border-radius: 3px; + `; + + rangeTrack.appendChild(activeRange); + + // Create start handle + const startHandle = this.createRangeHandle('Start', startPercent, true); + + // Create end handle + const endHandle = this.createRangeHandle('End', endPercent, false); + + // Add drag functionality + this.setupRangeHandleDrag(startHandle, endHandle, activeRange, path, pathIndex, true); + this.setupRangeHandleDrag(endHandle, startHandle, activeRange, path, pathIndex, false); + + timelineSliderContainer.appendChild(rangeTrack); + timelineSliderContainer.appendChild(startHandle); + timelineSliderContainer.appendChild(endHandle); + + // Timeline values display + const valuesDisplay = document.createElement('div'); + valuesDisplay.style.cssText = ` + display: flex; + justify-content: space-between; + font-size: 10px; + color: #888; + margin-top: 4px; + `; + valuesDisplay.innerHTML = ` + Start: ${Math.round(startPercent)}% + End: ${Math.round(endPercent)}% + `; + + timelineSection.appendChild(timelineLabel); + timelineSection.appendChild(timelineSliderContainer); + timelineSection.appendChild(valuesDisplay); + + // Interpolation Dropdown + const interpolationSection = document.createElement('div'); + interpolationSection.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + `; + + const interpLabel = document.createElement('label'); + interpLabel.textContent = 'Interpolation'; + interpLabel.style.cssText = ` + color: #fff; + font-size: 11px; + font-weight: 500; + opacity: 0.9; + `; + + const interpSelect = document.createElement('select'); + interpSelect.style.cssText = ` + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #fff; + padding: 6px; + font-size: 11px; + cursor: pointer; + `; + + const interpolationTypes = [ + { value: 'linear', label: 'Linear' }, + { value: 'ease-in', label: 'Ease In' }, + { value: 'ease-out', label: 'Ease Out' }, + { value: 'ease-in-out', label: 'Ease In-Out' } + ]; + + interpolationTypes.forEach(type => { + const option = document.createElement('option'); + option.value = type.value; + option.textContent = type.label; + option.selected = (path.interpolation || 'linear') === type.value; + interpSelect.appendChild(option); + }); + + interpSelect.onchange = (e) => { + e.stopPropagation(); + path.interpolation = e.target.value; + this.savePaths(); + }; + + interpolationSection.appendChild(interpLabel); + interpolationSection.appendChild(interpSelect); + + // Visibility Mode Dropdown + const visibilitySection = document.createElement('div'); + visibilitySection.style.cssText = ` + display: flex; + flex-direction: column; + gap: 6px; + `; + + const visLabel = document.createElement('label'); + visLabel.textContent = 'Visibility Mode'; + visLabel.style.cssText = ` + color: #fff; + font-size: 11px; + font-weight: 500; + opacity: 0.9; + `; + + const visSelect = document.createElement('select'); + visSelect.style.cssText = ` + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #fff; + padding: 6px; + font-size: 11px; + cursor: pointer; + `; + + const visibilityModes = [ + { value: 'pop', label: 'Pop (Appear/Disappear)' }, + { value: 'static', label: 'Static (Always Visible)' } + ]; + + visibilityModes.forEach(mode => { + const option = document.createElement('option'); + option.value = mode.value; + option.textContent = mode.label; + option.selected = (path.visibilityMode || 'pop') === mode.value; + visSelect.appendChild(option); + }); + + visSelect.onchange = (e) => { + e.stopPropagation(); + path.visibilityMode = e.target.value; + this.savePaths(); + }; + + visibilitySection.appendChild(visLabel); + visibilitySection.appendChild(visSelect); + + container.appendChild(timelineSection); + container.appendChild(interpolationSection); + container.appendChild(visibilitySection); + + return container; + } + + createRangeHandle(label, position, isStart) { + const handle = document.createElement('div'); + handle.style.cssText = ` + position: absolute; + left: ${position}%; + top: 50%; + transform: translate(-50%, -50%); + width: 16px; + height: 16px; + background: #4ECDC4; + border: 2px solid #fff; + border-radius: 50%; + cursor: ${isStart ? 'e-resize' : 'w-resize'}; + z-index: 10; + transition: transform 0.1s ease; + `; + + handle.onmouseover = () => { + handle.style.transform = 'translate(-50%, -50%) scale(1.2)'; + }; + + handle.onmouseout = () => { + handle.style.transform = 'translate(-50%, -50%) scale(1)'; + }; + + handle.dataset.label = label; + return handle; + } + + setupRangeHandleDrag(handle, otherHandle, activeRange, path, pathIndex, isStart) { + let isDragging = false; + let container = null; + + const onMouseDown = (e) => { + e.stopPropagation(); + isDragging = true; + container = handle.parentElement; + document.body.style.cursor = isStart ? 'e-resize' : 'w-resize'; + }; + + const onMouseMove = (e) => { + if (!isDragging || !container) return; + + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percent = Math.max(0, Math.min(100, (x / rect.width) * 100)); + + // Get other handle position + const otherPercent = parseFloat(otherHandle.style.left); + + // Constrain to not cross other handle + let constrainedPercent; + if (isStart) { + constrainedPercent = Math.min(percent, otherPercent - 1); + } else { + constrainedPercent = Math.max(percent, otherPercent + 1); + } + + // Update handle position + handle.style.left = `${constrainedPercent}%`; + + // Update active range + const startPercent = isStart ? constrainedPercent : parseFloat(otherHandle.style.left); + const endPercent = isStart ? parseFloat(otherHandle.style.left) : constrainedPercent; + activeRange.style.left = `${startPercent}%`; + activeRange.style.width = `${endPercent - startPercent}%`; + + // Update path data + if (isStart) { + path.startTime = constrainedPercent / 100; + } else { + path.endTime = constrainedPercent / 100; + } + + // Update display + const valuesDisplay = container.parentElement.querySelector('div:last-child'); + if (valuesDisplay) { + valuesDisplay.innerHTML = ` + Start: ${Math.round(startPercent)}% + End: ${Math.round(endPercent)}% + `; + } + }; + + const onMouseUp = () => { + if (isDragging) { + isDragging = false; + document.body.style.cursor = ''; + this.savePaths(); + } + }; + + handle.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + createFooter() { const footer = document.createElement('div'); footer.style.cssText = `