635 lines
19 KiB
JavaScript
635 lines
19 KiB
JavaScript
// 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 };
|