// Animation system models: Frame, Keyframe, AnimationCurve, AnimationData import { context, config, pointerList, startProps } from '../state.js'; // Get invoke from Tauri global const { invoke } = window.__TAURI__.core; // Helper function for UUID generation function uuidv4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => ( +c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) ).toString(16), ); } class Frame { constructor(frameType = "normal", uuid = undefined) { this.keys = {}; this.shapes = []; this.frameType = frameType; this.keyTypes = new Set() if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } pointerList[this.idx] = this; } get exists() { return true; } saveState() { startProps[this.idx] = structuredClone(this.keys); } copy(idx) { let newFrame = new Frame( this.frameType, idx.slice(0, 8) + this.idx.slice(8), ); newFrame.keys = structuredClone(this.keys); newFrame.shapes = []; for (let shape of this.shapes) { newFrame.shapes.push(shape.copy(idx)); } return newFrame; } static fromJSON(json, Shape = null) { if (!json) { return undefined } // Shape parameter passed in to avoid circular dependency // Will be provided by the calling code that has access to both modules const frame = new Frame(json.frameType, json.idx); frame.keyTypes = new Set(json.keyTypes) frame.keys = json.keys; if (Shape) { for (let i in json.shapes) { const shape = json.shapes[i]; frame.shapes.push(Shape.fromJSON(shape)); } } return frame; } toJSON(randomizeUuid = false) { const json = {}; json.type = "Frame"; json.frameType = this.frameType; json.keyTypes = Array.from(this.keyTypes) if (randomizeUuid) { json.idx = uuidv4(); } else { json.idx = this.idx; } json.keys = structuredClone(this.keys); json.shapes = []; for (let shape of this.shapes) { json.shapes.push(shape.toJSON(randomizeUuid)); } return json; } addShape(shape, sendToBack) { if (sendToBack) { this.shapes.unshift(shape); } else { this.shapes.push(shape); } } removeShape(shape) { let shapeIndex = this.shapes.indexOf(shape); if (shapeIndex >= 0) { this.shapes.splice(shapeIndex, 1); } } } class TempFrame { constructor() {} get exists() { return false; } get idx() { return "tempFrame"; } get keys() { return {}; } get shapes() { return []; } get frameType() { return "temp"; } copy() { return this; } addShape() {} removeShape() {} } const tempFrame = new TempFrame(); // Animation system classes class Keyframe { constructor(time, value, interpolation = "linear", uuid = undefined) { this.time = time; this.value = value; this.interpolation = interpolation; // 'linear', 'bezier', 'step', 'hold' // For bezier interpolation this.easeIn = { x: 0.42, y: 0 }; // Default ease-in control point this.easeOut = { x: 0.58, y: 1 }; // Default ease-out control point if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } static fromJSON(json) { const keyframe = new Keyframe(json.time, json.value, json.interpolation, json.idx); if (json.easeIn) keyframe.easeIn = json.easeIn; if (json.easeOut) keyframe.easeOut = json.easeOut; return keyframe; } toJSON() { return { idx: this.idx, time: this.time, value: this.value, interpolation: this.interpolation, easeIn: this.easeIn, easeOut: this.easeOut }; } } class AnimationCurve { constructor(parameter, uuid = undefined, parentAnimationData = null) { this.parameter = parameter; // e.g., "x", "y", "rotation", "scale_x", "exists" this.keyframes = []; // Always kept sorted by time this.parentAnimationData = parentAnimationData; // Reference to parent AnimationData for duration updates if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } addKeyframe(keyframe) { // Time resolution based on framerate - half a frame's duration // This can be exposed via UI later const framerate = context.config?.framerate || 24; const timeResolution = (1 / framerate) / 2; // Check if there's already a keyframe within the time resolution const existingKeyframe = this.getKeyframeAtTime(keyframe.time, timeResolution); if (existingKeyframe) { // Update the existing keyframe's value instead of adding a new one existingKeyframe.value = keyframe.value; existingKeyframe.interpolation = keyframe.interpolation; if (keyframe.easeIn) existingKeyframe.easeIn = keyframe.easeIn; if (keyframe.easeOut) existingKeyframe.easeOut = keyframe.easeOut; // Sync update to backend if this is an automation curve this._syncAutomationKeyframeToBackend(existingKeyframe); } else { // Add new keyframe this.keyframes.push(keyframe); // Keep sorted by time this.keyframes.sort((a, b) => a.time - b.time); } // Update animation duration after adding keyframe if (this.parentAnimationData) { this.parentAnimationData.updateDuration(); } // Sync to backend if this is an automation curve this._syncAutomationKeyframeToBackend(keyframe); } removeKeyframe(keyframe) { const index = this.keyframes.indexOf(keyframe); if (index >= 0) { this.keyframes.splice(index, 1); // Update animation duration after removing keyframe if (this.parentAnimationData) { this.parentAnimationData.updateDuration(); } // Sync to backend if this is an automation curve this._syncAutomationKeyframeRemovalToBackend(keyframe); } } getKeyframeAtTime(time, timeResolution = 0) { if (this.keyframes.length === 0) return null; // If no tolerance, use exact match with binary search if (timeResolution === 0) { let left = 0; let right = this.keyframes.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (this.keyframes[mid].time === time) { return this.keyframes[mid]; } else if (this.keyframes[mid].time < time) { left = mid + 1; } else { right = mid - 1; } } return null; } // With tolerance, find the closest keyframe within timeResolution let left = 0; let right = this.keyframes.length - 1; let closest = null; let closestDist = Infinity; // Binary search to find the insertion point while (left <= right) { const mid = Math.floor((left + right) / 2); const dist = Math.abs(this.keyframes[mid].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[mid]; } if (this.keyframes[mid].time < time) { left = mid + 1; } else { right = mid - 1; } } // Also check adjacent keyframes for closest match if (left < this.keyframes.length) { const dist = Math.abs(this.keyframes[left].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[left]; } } if (right >= 0) { const dist = Math.abs(this.keyframes[right].time - time); if (dist < closestDist) { closestDist = dist; closest = this.keyframes[right]; } } return closestDist < timeResolution ? closest : null; } // Find the two keyframes that bracket the given time getBracketingKeyframes(time) { if (this.keyframes.length === 0) return { prev: null, next: null }; if (this.keyframes.length === 1) return { prev: this.keyframes[0], next: this.keyframes[0] }; // Binary search to find the last keyframe at or before time let left = 0; let right = this.keyframes.length - 1; let prevIndex = -1; while (left <= right) { const mid = Math.floor((left + right) / 2); if (this.keyframes[mid].time <= time) { prevIndex = mid; // This could be our answer left = mid + 1; // But check if there's a better one to the right } else { right = mid - 1; // Time is too large, search left } } // If time is before all keyframes if (prevIndex === -1) { return { prev: this.keyframes[0], next: this.keyframes[0], t: 0 }; } // If time is after all keyframes if (prevIndex === this.keyframes.length - 1) { return { prev: this.keyframes[prevIndex], next: this.keyframes[prevIndex], t: 1 }; } const prev = this.keyframes[prevIndex]; const next = this.keyframes[prevIndex + 1]; const t = (time - prev.time) / (next.time - prev.time); return { prev, next, t }; } interpolate(time) { if (this.keyframes.length === 0) { return null; } const { prev, next, t } = this.getBracketingKeyframes(time); if (!prev || !next) { return null; } if (prev === next) { return prev.value; } // Handle different interpolation types switch (prev.interpolation) { case "step": case "hold": return prev.value; case "linear": // Simple linear interpolation if (typeof prev.value === "number" && typeof next.value === "number") { return prev.value + (next.value - prev.value) * t; } return prev.value; case "bezier": // Cubic bezier interpolation using control points if (typeof prev.value === "number" && typeof next.value === "number") { // Use ease-in/ease-out control points const easedT = this.cubicBezierEase(t, prev.easeOut, next.easeIn); return prev.value + (next.value - prev.value) * easedT; } return prev.value; case "zero": // Return 0 for the entire interval (used for inactive segments) return 0; default: return prev.value; } } // Cubic bezier easing function cubicBezierEase(t, easeOut, easeIn) { // Simplified cubic bezier for 0,0 -> easeOut -> easeIn -> 1,1 const u = 1 - t; return 3 * u * u * t * easeOut.y + 3 * u * t * t * easeIn.y + t * t * t; } // Display color for this curve in timeline (based on parameter type) - Phase 4 get displayColor() { // Auto-determined from parameter name if (this.parameter.endsWith('.x')) return '#7a00b3' // purple if (this.parameter.endsWith('.y')) return '#ff00ff' // magenta if (this.parameter.endsWith('.rotation')) return '#5555ff' // blue if (this.parameter.endsWith('.scale_x')) return '#ffaa00' // orange if (this.parameter.endsWith('.scale_y')) return '#ffff55' // yellow if (this.parameter.endsWith('.exists')) return '#55ff55' // green if (this.parameter.endsWith('.zOrder')) return '#55ffff' // cyan if (this.parameter.endsWith('.frameNumber')) return '#ff5555' // red return '#ffffff' // default white } static fromJSON(json) { const curve = new AnimationCurve(json.parameter, json.idx); for (let kfJson of json.keyframes || []) { curve.keyframes.push(Keyframe.fromJSON(kfJson)); } return curve; } toJSON() { return { idx: this.idx, parameter: this.parameter, keyframes: this.keyframes.map(kf => kf.toJSON()) }; } // Helper method to sync keyframe additions to backend for automation curves _syncAutomationKeyframeToBackend(keyframe) { // Check if this is an automation curve (parameter starts with "automation.") if (!this.parameter.startsWith('automation.')) { return; // Not an automation curve, skip backend sync } // Extract node ID from parameter (e.g., "automation.5" -> 5) const nodeIdStr = this.parameter.split('.')[1]; const nodeId = parseInt(nodeIdStr, 10); if (isNaN(nodeId)) { console.error(`Invalid automation node ID: ${nodeIdStr}`); return; } // Convert keyframe to backend format const backendKeyframe = { time: keyframe.time, value: keyframe.value, interpolation: keyframe.interpolation || 'linear', ease_out: keyframe.easeOut ? [keyframe.easeOut.x, keyframe.easeOut.y] : [0.58, 1.0], ease_in: keyframe.easeIn ? [keyframe.easeIn.x, keyframe.easeIn.y] : [0.42, 0.0] }; // Call Tauri command (fire-and-forget) // Note: Need to get track_id from context - for now, find it from the curve's parent const track = window.root?.audioTracks?.find(t => t.animationData && Object.values(t.animationData.curves).includes(this) ); if (!track || track.audioTrackId === null) { console.error('Could not find track for automation curve sync'); return; } invoke('automation_add_keyframe', { trackId: track.audioTrackId, nodeId: nodeId, keyframe: backendKeyframe }).catch(err => { console.error(`Failed to sync automation keyframe to backend: ${err}`); }); } // Helper method to sync keyframe removals to backend for automation curves _syncAutomationKeyframeRemovalToBackend(keyframe) { // Check if this is an automation curve (parameter starts with "automation.") if (!this.parameter.startsWith('automation.')) { return; // Not an automation curve, skip backend sync } // Extract node ID from parameter (e.g., "automation.5" -> 5) const nodeIdStr = this.parameter.split('.')[1]; const nodeId = parseInt(nodeIdStr, 10); if (isNaN(nodeId)) { console.error(`Invalid automation node ID: ${nodeIdStr}`); return; } // Call Tauri command (fire-and-forget) // Note: Need to get track_id from context - for now, find it from the curve's parent const track = window.root?.audioTracks?.find(t => t.animationData && Object.values(t.animationData.curves).includes(this) ); if (!track || track.audioTrackId === null) { console.error('Could not find track for automation curve sync'); return; } invoke('automation_remove_keyframe', { trackId: track.audioTrackId, nodeId: nodeId, time: keyframe.time }).catch(err => { console.error(`Failed to sync automation keyframe removal to backend: ${err}`); }); } } class AnimationData { constructor(parentLayer = null, uuid = undefined) { this.curves = {}; // parameter name -> AnimationCurve this.duration = 0; // Duration in seconds (max time of all keyframes) this.parentLayer = parentLayer; // Reference to parent Layer for updating segment keyframes if (!uuid) { this.idx = uuidv4(); } else { this.idx = uuid; } } getCurve(parameter) { return this.curves[parameter]; } getOrCreateCurve(parameter) { if (!this.curves[parameter]) { this.curves[parameter] = new AnimationCurve(parameter, undefined, this); } return this.curves[parameter]; } addKeyframe(parameter, keyframe) { const curve = this.getOrCreateCurve(parameter); curve.addKeyframe(keyframe); } removeKeyframe(parameter, keyframe) { const curve = this.curves[parameter]; if (curve) { curve.removeKeyframe(keyframe); } } removeCurve(parameter) { delete this.curves[parameter]; } setCurve(parameter, curve) { // Set parent reference for duration tracking curve.parentAnimationData = this; this.curves[parameter] = curve; // Update duration after adding curve with keyframes this.updateDuration(); } interpolate(parameter, time) { const curve = this.curves[parameter]; if (!curve) return null; return curve.interpolate(time); } // Get all animated values at a given time getValuesAtTime(time) { const values = {}; for (let parameter in this.curves) { values[parameter] = this.curves[parameter].interpolate(time); } return values; } /** * Update the duration based on all keyframes * Called automatically when keyframes are added/removed */ updateDuration() { // Calculate max time from all keyframes in all curves let maxTime = 0; for (let parameter in this.curves) { const curve = this.curves[parameter]; if (curve.keyframes && curve.keyframes.length > 0) { const lastKeyframe = curve.keyframes[curve.keyframes.length - 1]; maxTime = Math.max(maxTime, lastKeyframe.time); } } // Update this AnimationData's duration this.duration = maxTime; // If this layer belongs to a nested group, update the segment keyframes in the parent if (this.parentLayer && this.parentLayer.parentObject) { this.updateParentSegmentKeyframes(); } } /** * Update segment keyframes in parent layer when this layer's duration changes * This ensures that nested group segments automatically resize when internal animation is added */ updateParentSegmentKeyframes() { const parentObject = this.parentLayer.parentObject; // Get the layer that contains this nested object (parentObject.parentLayer) if (!parentObject.parentLayer || !parentObject.parentLayer.animationData) { return; } const parentLayer = parentObject.parentLayer; // Get the frameNumber curve for this nested object using the correct naming convention const curveName = `child.${parentObject.idx}.frameNumber`; const frameNumberCurve = parentLayer.animationData.getCurve(curveName); if (!frameNumberCurve || frameNumberCurve.keyframes.length < 2) { return; } // Update the last keyframe to match the new duration const lastKeyframe = frameNumberCurve.keyframes[frameNumberCurve.keyframes.length - 1]; const newFrameValue = Math.ceil(this.duration * config.framerate) + 1; // +1 because frameNumber is 1-indexed const newTime = this.duration; // Only update if the time or value actually changed if (lastKeyframe.value !== newFrameValue || lastKeyframe.time !== newTime) { lastKeyframe.value = newFrameValue; lastKeyframe.time = newTime; // Re-sort keyframes in case the time change affects order frameNumberCurve.keyframes.sort((a, b) => a.time - b.time); // Don't recursively call updateDuration to avoid infinite loop } } static fromJSON(json, parentLayer = null) { const animData = new AnimationData(parentLayer, json.idx); for (let param in json.curves || {}) { const curve = AnimationCurve.fromJSON(json.curves[param]); curve.parentAnimationData = animData; // Restore parent reference animData.curves[param] = curve; } // Recalculate duration after loading all curves animData.updateDuration(); return animData; } toJSON() { const curves = {}; for (let param in this.curves) { curves[param] = this.curves[param].toJSON(); } return { idx: this.idx, curves: curves }; } } export { Frame, TempFrame, tempFrame, Keyframe, AnimationCurve, AnimationData };