diff --git a/src/lib/easing-functions.ts b/src/lib/easing-functions.ts new file mode 100644 index 00000000..86282466 --- /dev/null +++ b/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); +} diff --git a/src/lib/easing-presets.ts b/src/lib/easing-presets.ts new file mode 100644 index 00000000..a71fc504 --- /dev/null +++ b/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; + +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]; +} diff --git a/src/lib/speed-curve.ts b/src/lib/speed-curve.ts new file mode 100644 index 00000000..059e0ae2 --- /dev/null +++ b/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(); +const monotonicityCache = new WeakMap(); +const warnedNonMonotonic = new WeakSet(); + +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, + }; +}