Browse Source

feat(42-01): port easing library, presets, and speed curve from easy-peasy-ease

- 24 base easing functions + 2 hybrid easing functions (26 total)
- createBezierEasing cubic bezier solver with Newton-Raphson + bisection fallback
- 5 preset bezier handle definitions for curve visualization
- warpTime algorithm with inverse easing for timestamp remapping
- analyzeWarpCurve, validateWarpFunction, calculateWarpedDuration utilities
- Removed deprecated functions and adaptive easing (not needed for user-selected curves)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
4dc08ca74a
  1. 131
      src/lib/easing-functions.ts
  2. 21
      src/lib/easing-presets.ts
  3. 259
      src/lib/speed-curve.ts

131
src/lib/easing-functions.ts

@ -0,0 +1,131 @@
/**
* Easing Functions Library
* Based on easings.net standards
*/
export type EasingFunction = (t: number) => number;
const createAsymmetricEase = (easeIn: EasingFunction, easeOut: EasingFunction): EasingFunction => {
return (t: number): number => {
if (t <= 0.5) {
return 0.5 * easeIn(t * 2);
}
return 0.5 + 0.5 * easeOut((t - 0.5) * 2);
};
};
const baseEasing = {
linear: (t: number): number => t,
easeInQuad: (t: number): number => t * t,
easeOutQuad: (t: number): number => t * (2 - t),
easeInOutQuad: (t: number): number =>
t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
easeInCubic: (t: number): number => t * t * t,
easeOutCubic: (t: number): number => {
const t1 = t - 1;
return t1 * t1 * t1 + 1;
},
easeInOutCubic: (t: number): number =>
t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2,
easeInQuart: (t: number): number => t * t * t * t,
easeOutQuart: (t: number): number => {
const t1 = t - 1;
return 1 - t1 * t1 * t1 * t1;
},
easeInOutQuart: (t: number): number =>
t < 0.5
? 8 * t * t * t * t
: 1 - Math.pow(-2 * t + 2, 4) / 2,
easeInQuint: (t: number): number => t * t * t * t * t,
easeOutQuint: (t: number): number => {
const t1 = t - 1;
return 1 + t1 * t1 * t1 * t1 * t1;
},
easeInOutQuint: (t: number): number =>
t < 0.5
? 16 * t * t * t * t * t
: 1 - Math.pow(-2 * t + 2, 5) / 2,
easeInSine: (t: number): number =>
1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t: number): number =>
Math.sin((t * Math.PI) / 2),
easeInOutSine: (t: number): number =>
-(Math.cos(Math.PI * t) - 1) / 2,
easeInExpo: (t: number): number =>
t === 0 ? 0 : Math.pow(2, 10 * t - 10),
easeOutExpo: (t: number): number =>
t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
easeInOutExpo: (t: number): number =>
t === 0
? 0
: t === 1
? 1
: t < 0.5
? Math.pow(2, 20 * t - 10) / 2
: (2 - Math.pow(2, -20 * t + 10)) / 2,
easeInCirc: (t: number): number =>
1 - Math.sqrt(1 - Math.pow(t, 2)),
easeOutCirc: (t: number): number =>
Math.sqrt(1 - Math.pow(t - 1, 2)),
easeInOutCirc: (t: number): number =>
t < 0.5
? (1 - Math.sqrt(1 - Math.pow(2 * t, 2))) / 2
: (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2,
} as const;
const hybridEasing = {
easeInExpoOutCubic: createAsymmetricEase(baseEasing.easeInExpo, baseEasing.easeOutCubic),
easeInQuartOutQuad: createAsymmetricEase(baseEasing.easeInQuart, baseEasing.easeOutQuad),
} as const;
export const easing = {
...baseEasing,
...hybridEasing,
} as const;
export function createBezierEasing(
p1x: number, p1y: number, p2x: number, p2y: number
): EasingFunction {
const clamp = (value: number) => Math.min(1, Math.max(0, value));
const x1 = clamp(p1x); const y1 = clamp(p1y);
const x2 = clamp(p2x); const y2 = clamp(p2y);
const cx = 3 * x1; const bx = 3 * (x2 - x1) - cx; const ax = 1 - cx - bx;
const cy = 3 * y1; const by = 3 * (y2 - y1) - cy; const ay = 1 - cy - by;
const sampleCurveX = (t: number) => ((ax * t + bx) * t + cx) * t;
const sampleCurveY = (t: number) => ((ay * t + by) * t + cy) * t;
const sampleDerivativeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
const solveCurveX = (x: number) => {
let t2 = x; const epsilon = 1e-6;
for (let i = 0; i < 8; i++) {
const x2 = sampleCurveX(t2) - x;
if (Math.abs(x2) < epsilon) return t2;
const d2 = sampleDerivativeX(t2);
if (Math.abs(d2) < epsilon) break;
t2 -= x2 / d2;
}
let t0 = 0; let t1 = 1; t2 = x;
while (t0 < t1) {
const x2 = sampleCurveX(t2);
if (Math.abs(x2 - x) < epsilon) return t2;
if (x > x2) t0 = t2; else t1 = t2;
t2 = (t1 + t0) / 2;
}
return t2;
};
return (t: number) => {
const clamped = clamp(t);
return sampleCurveY(solveCurveX(clamped));
};
}
export function getEasingFunction(name: string): EasingFunction {
const func = easing[name as keyof typeof easing];
if (!func) { console.warn(`Easing function "${name}" not found, using linear`); return easing.linear; }
return func;
}
export function getAllEasingNames(): string[] {
return Object.keys(easing);
}

21
src/lib/easing-presets.ts

@ -0,0 +1,21 @@
export const DEFAULT_CUSTOM_BEZIER: [number, number, number, number] = [0.42, 0, 0.58, 1];
export const PRESET_BEZIERS = {
easeInExpoOutCubic: [0.85, 0, 0.15, 1],
easeInOutExpo: [1, 0, 0, 1],
easeInQuartOutQuad: [0.8, 0, 0.2, 1],
easeInOutCubic: [0.645, 0.045, 0.355, 1],
easeInOutSine: [0.445, 0.05, 0.55, 0.95],
} as const satisfies Record<string, readonly [number, number, number, number]>;
export type EasingPresetName = keyof typeof PRESET_BEZIERS;
export const EASING_PRESETS: EasingPresetName[] = [
'easeInExpoOutCubic', 'easeInOutExpo', 'easeInQuartOutQuad', 'easeInOutCubic', 'easeInOutSine',
];
export function getPresetBezier(preset?: string | null): [number, number, number, number] {
const handles = preset ? PRESET_BEZIERS[preset as EasingPresetName] : null;
const source = handles ?? DEFAULT_CUSTOM_BEZIER;
return [...source] as [number, number, number, number];
}

259
src/lib/speed-curve.ts

@ -0,0 +1,259 @@
/**
* Speed Curve Utility
* Implements warpTime function for ease-in-out speed curves
* Now supports 30+ easing functions from the easing-functions library
*/
import { easing, type EasingFunction, getEasingFunction } from './easing-functions';
const INVERSE_TOLERANCE = 1e-6;
const INVERSE_MAX_ITERATIONS = 32;
const MONOTONICITY_SAMPLES = 256;
const MONOTONICITY_TOLERANCE = 1e-6;
const inverseCache = new WeakMap<EasingFunction, EasingFunction | null>();
const monotonicityCache = new WeakMap<EasingFunction, boolean>();
const warnedNonMonotonic = new WeakSet<EasingFunction>();
function isMonotonicIncreasing(func: EasingFunction): boolean {
const cached = monotonicityCache.get(func);
if (cached !== undefined) {
return cached;
}
let prev = func(0);
for (let i = 1; i <= MONOTONICITY_SAMPLES; i++) {
const value = func(i / MONOTONICITY_SAMPLES);
if (value + MONOTONICITY_TOLERANCE < prev) {
monotonicityCache.set(func, false);
return false;
}
prev = value;
}
monotonicityCache.set(func, true);
return true;
}
function invertEasingValue(target: number, func: EasingFunction): number {
if (target <= INVERSE_TOLERANCE) {
return 0;
}
if (target >= 1 - INVERSE_TOLERANCE) {
return 1;
}
let low = 0;
let high = 1;
let mid = 0.5;
for (let i = 0; i < INVERSE_MAX_ITERATIONS; i++) {
mid = (low + high) / 2;
const value = func(mid);
const diff = value - target;
if (Math.abs(diff) <= INVERSE_TOLERANCE) {
break;
}
if (diff < 0) {
low = mid;
} else {
high = mid;
}
}
return Math.min(1, Math.max(0, mid));
}
function getInverseEasing(func: EasingFunction): EasingFunction | null {
if (inverseCache.has(func)) {
return inverseCache.get(func)!;
}
if (!isMonotonicIncreasing(func)) {
inverseCache.set(func, null);
return null;
}
const inverse: EasingFunction = (value: number) => {
const clamped = Math.max(0, Math.min(1, value));
return invertEasingValue(clamped, func);
};
inverseCache.set(func, inverse);
return inverse;
}
function mapTimeWithEasing(normalizedTime: number, func: EasingFunction): number {
const inverse = getInverseEasing(func);
if (!inverse) {
if (!warnedNonMonotonic.has(func)) {
console.warn(
`Easing function "${func.name ?? 'anonymous'}" is not monotonic. Falling back to direct mapping - timing may feel inverted.`
);
warnedNonMonotonic.add(func);
}
return func(normalizedTime);
}
return inverse(normalizedTime);
}
/**
* Maps original video timestamps to warped timestamps using any easing function
*
* @param originalTime - Original timestamp in seconds (0 to inputDuration)
* @param inputDuration - Input video duration in seconds (default: 5)
* @param outputDuration - Output video duration in seconds (default: 1.5)
* @param easingFunction - Easing function to use (default: easeInOutCubic) or function name string
* @returns Warped timestamp for the output video
*
* @example
* // Convert 2.5s from a 5s video to a 1.5s video with ease-in-out cubic
* const warpedTime = warpTime(2.5, 5, 1.5, easing.easeInOutCubic); // Returns 0.75
*
* @example
* // Using a preset easing function by name
* const warpedTime = warpTime(2.5, 5, 1.5, 'easeInOutCubic'); // Also returns 0.75
*/
export function warpTime(
originalTime: number,
inputDuration: number = 5,
outputDuration: number = 1.5,
easingFunction: EasingFunction | string = easing.easeInOutCubic
): number {
// Resolve easing function if string is provided
const easingFunc = typeof easingFunction === 'string'
? getEasingFunction(easingFunction)
: easingFunction;
// Normalize original time to 0-1 range
const t = originalTime / inputDuration;
// Clamp t to [0, 1] to handle floating point errors
const clamped = Math.max(0, Math.min(1, t));
// Apply inverse easing so that ease-in curves slow the start instead of accelerating it
const eased = mapTimeWithEasing(clamped, easingFunc);
// Scale to output duration
return eased * outputDuration;
}
/**
* Calculate the duration of a frame after warping
*
* @param originalStart - Original frame start time in seconds
* @param originalDuration - Original frame duration in seconds
* @param inputDuration - Total input video duration in seconds
* @param outputDuration - Total output video duration in seconds
* @param easingFunction - Easing function to use (default: easeInOutCubic)
* @returns Warped frame duration in seconds
*
* @example
* // Frame at 2.5s with 0.033s duration (30fps)
* const warpedDur = calculateWarpedDuration(2.5, 0.033, 5, 1.5, easing.easeInOutCubic);
* // The warped frame will have a different duration based on the curve
*/
export function calculateWarpedDuration(
originalStart: number,
originalDuration: number,
inputDuration: number = 5,
outputDuration: number = 1.5,
easingFunction: EasingFunction | string = easing.easeInOutCubic
): number {
const warpedStart = warpTime(originalStart, inputDuration, outputDuration, easingFunction);
const warpedEnd = warpTime(
originalStart + originalDuration,
inputDuration,
outputDuration,
easingFunction
);
return warpedEnd - warpedStart;
}
/**
* Validate that a warp time function produces valid output
*/
export function validateWarpFunction(
easingFunction: EasingFunction | string = easing.easeInOutCubic,
inputDuration: number = 5,
outputDuration: number = 1.5,
tolerance: number = 0.001
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Check start point
const startWarp = warpTime(0, inputDuration, outputDuration, easingFunction);
if (Math.abs(startWarp - 0) > tolerance) {
errors.push(`Start point should be 0, got ${startWarp}`);
}
// Check end point
const endWarp = warpTime(inputDuration, inputDuration, outputDuration, easingFunction);
if (Math.abs(endWarp - outputDuration) > tolerance) {
errors.push(`End point should be ${outputDuration}, got ${endWarp}`);
}
// Check monotonicity (always increasing)
let prevWarp = 0;
for (let t = 0; t <= inputDuration; t += inputDuration / 100) {
const warp = warpTime(t, inputDuration, outputDuration, easingFunction);
if (warp < prevWarp) {
errors.push(`Monotonicity violation at t=${t.toFixed(2)}: ${warp} < ${prevWarp}`);
break;
}
prevWarp = warp;
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Generate statistics about the warp curve
*/
export function analyzeWarpCurve(
easingFunction: EasingFunction | string = easing.easeInOutCubic,
inputDuration: number = 5,
outputDuration: number = 1.5,
samples: number = 100
): {
speedMultipliers: number[];
minSpeed: number;
maxSpeed: number;
avgSpeed: number;
} {
const speedMultipliers: number[] = [];
const averageSpeed = inputDuration / outputDuration;
for (let i = 0; i < samples; i++) {
const t1 = (i / samples) * inputDuration;
const t2 = ((i + 1) / samples) * inputDuration;
const warp1 = warpTime(t1, inputDuration, outputDuration, easingFunction);
const warp2 = warpTime(t2, inputDuration, outputDuration, easingFunction);
const inputSegmentDuration = t2 - t1;
const outputSegmentDuration = warp2 - warp1;
// Relative speed compared to linear compression (1.0 == same as linear remap)
const absoluteSpeed = inputSegmentDuration / (outputSegmentDuration + 1e-10); // playback speed vs real time
const speedMultiplier = absoluteSpeed / averageSpeed;
speedMultipliers.push(speedMultiplier);
}
const minSpeed = Math.min(...speedMultipliers);
const maxSpeed = Math.max(...speedMultipliers);
const avgSpeed = speedMultipliers.reduce((a, b) => a + b, 0) / speedMultipliers.length;
return {
speedMultipliers,
minSpeed,
maxSpeed,
avgSpeed,
};
}
Loading…
Cancel
Save