Browse Source
- 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
3 changed files with 411 additions and 0 deletions
@ -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); |
||||
|
} |
||||
@ -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]; |
||||
|
} |
||||
@ -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…
Reference in new issue