eefd7abf83
Reorganize components into category folders (forms/canvas/overlays/etc.); add the media-editor headless family (timeline, curve-editor, waveform, crop, color picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag state, gesture-leak teardown, shallowRef color state, rect caching) and replace source `any` with proper types.
135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
import { solveBezierX } from '../../internal/spline';
|
|
import { framesToSeconds, secondsToFrames } from '../../internal/scale';
|
|
import { DEFAULT_KEYFRAME_EASING } from './context';
|
|
import type { KeyframeTrackKeyframeData } from './context';
|
|
|
|
/**
|
|
* Sort keyframes ascending by `time`, returning a NEW array (never mutating the
|
|
* input). Stable for equal times (a tie breaks on `id`) so the order is
|
|
* deterministic across reconciles and neighbour-clamping stays predictable.
|
|
*/
|
|
export function sortKeyframes(keyframes: readonly KeyframeTrackKeyframeData[]): KeyframeTrackKeyframeData[] {
|
|
return keyframes
|
|
.slice()
|
|
.sort((a, b) => (a.time - b.time) || a.id.localeCompare(b.id));
|
|
}
|
|
|
|
/**
|
|
* Linear interpolate between `a` and `b` by `t ∈ [0, 1]`.
|
|
*/
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
/**
|
|
* Sample the animated value at an arbitrary `time` (seconds) over a SORTED
|
|
* keyframe list.
|
|
*
|
|
* Finds the bracketing pair `[k, k+1]`, computes the normalized progress along
|
|
* the segment, applies the starting keyframe's cubic-bezier easing (via the
|
|
* spline `solveBezierX`, defaulting to {@link DEFAULT_KEYFRAME_EASING} — a linear
|
|
* ramp), and lerps the value. The result is CONSTANT outside the keyframe range
|
|
* (held at the first / last keyframe's value) and for the 0- and 1-keyframe
|
|
* degenerate cases.
|
|
*
|
|
* `valueRange` is accepted for parity with the projection model but does not
|
|
* affect the sampled value (values are sampled in their own space, never
|
|
* normalized) — it is reserved so callers can pass it without a second overload.
|
|
*
|
|
* @param keyframes Keyframes sorted ascending by `time`.
|
|
* @param time Time to sample, in seconds.
|
|
* @param valueRange Optional value domain (unused by the maths; see above).
|
|
*/
|
|
export function sampleKeyframes(
|
|
keyframes: readonly KeyframeTrackKeyframeData[],
|
|
time: number,
|
|
_valueRange?: readonly [number, number],
|
|
): number {
|
|
const n = keyframes.length;
|
|
if (n === 0) return 0;
|
|
const first = keyframes[0]!;
|
|
if (n === 1) return first.value;
|
|
const last = keyframes[n - 1]!;
|
|
|
|
// Held constant outside the keyframe range.
|
|
if (time <= first.time) return first.value;
|
|
if (time >= last.time) return last.value;
|
|
|
|
// Binary search for the segment [lo, lo+1] containing `time`.
|
|
let lo = 0;
|
|
let hi = n - 1;
|
|
while (hi - lo > 1) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (keyframes[mid]!.time <= time) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
|
|
const a = keyframes[lo]!;
|
|
const b = keyframes[lo + 1]!;
|
|
const span = b.time - a.time;
|
|
if (span <= 0) return a.value;
|
|
|
|
const progress = (time - a.time) / span;
|
|
const easing = a.easing ?? DEFAULT_KEYFRAME_EASING;
|
|
// Easing maps normalized progress (x) to eased progress (y) in [0, 1].
|
|
const eased = solveBezierX(easing[0], easing[1], easing[2], easing[3], progress);
|
|
return lerp(a.value, b.value, eased);
|
|
}
|
|
|
|
/**
|
|
* Clamp a candidate `time` for the keyframe at `index` so it stays ordered
|
|
* relative to its neighbours by at least `minTimeBetween` seconds (unless
|
|
* `allowOverlap`), and never goes below `0`. `keyframes` MUST be sorted by time.
|
|
*
|
|
* @param keyframes Keyframes sorted ascending by `time`.
|
|
* @param index Index of the keyframe being moved.
|
|
* @param time Candidate time (seconds).
|
|
* @param options Neighbour-clamp configuration.
|
|
*/
|
|
export function clampKeyframeTime(
|
|
keyframes: readonly KeyframeTrackKeyframeData[],
|
|
index: number,
|
|
time: number,
|
|
options: { allowOverlap: boolean; minTimeBetween: number; duration?: number },
|
|
): number {
|
|
const { allowOverlap, minTimeBetween, duration } = options;
|
|
let v = Math.max(0, time);
|
|
if (duration !== undefined && duration > 0) v = Math.min(v, duration);
|
|
|
|
if (!allowOverlap) {
|
|
const prev = keyframes[index - 1];
|
|
const next = keyframes[index + 1];
|
|
if (prev !== undefined) v = Math.max(v, prev.time + minTimeBetween);
|
|
if (next !== undefined) v = Math.min(v, next.time - minTimeBetween);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* Snap a `time` (seconds) to the nearest whole frame at `fps`. The default
|
|
* frame-grid quantizer used as the keyboard nudge granularity / snap fallback.
|
|
*/
|
|
export function snapTimeToFrame(time: number, fps: number): number {
|
|
if (fps <= 0) return time;
|
|
return framesToSeconds(secondsToFrames(time, fps), fps);
|
|
}
|
|
|
|
/**
|
|
* Round to `decimals` places, trimming float noise (no trailing-zero padding).
|
|
*/
|
|
function round(value: number, decimals: number): number {
|
|
const f = 10 ** decimals;
|
|
return Math.round(value * f) / f;
|
|
}
|
|
|
|
/**
|
|
* The default `aria-valuetext` value-token for a keyframe: the animated property
|
|
* (when present) followed by the value, e.g. `"opacity 0.5"` or just `"0.5"`.
|
|
* The time is announced separately by the caller (a slider's `aria-valuetext`
|
|
* leads with the formatted time).
|
|
*/
|
|
export function defaultKeyframeValueText(value: number, property?: string, decimals = 3): string {
|
|
const v = round(value, decimals);
|
|
return property ? `${property} ${v}` : `${v}`;
|
|
}
|