diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/animation/interpolation.ts | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/animation/interpolation.ts')
| -rw-r--r-- | apps/web/src/animation/interpolation.ts | 375 |
1 files changed, 375 insertions, 0 deletions
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts new file mode 100644 index 00000000..277bfbb7 --- /dev/null +++ b/apps/web/src/animation/interpolation.ts @@ -0,0 +1,375 @@ +import type { + AnimationChannel, + AnimationInterpolation, + AnimationValue, + DiscreteAnimationChannel, + DiscreteValue, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, +} from "@/animation/types"; +import { clamp } from "@/utils/math"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; + +function byTimeAscending({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): number { + return leftTime - rightTime; +} + +function isWithinTimePair({ + time, + leftTime, + rightTime, +}: { + time: number; + leftTime: number; + rightTime: number; +}): boolean { + return time >= leftTime && time <= rightTime; +} + +function lerpNumber({ + leftValue, + rightValue, + progress, +}: { + leftValue: number; + rightValue: number; + progress: number; +}): number { + return leftValue + (rightValue - leftValue) * progress; +} + +function normalizeRightHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["rightHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.min(span, Math.max(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeLeftHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["leftHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.max(-span, Math.min(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeScalarKey({ + key, +}: { + key: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + ...key, + tangentMode: key.tangentMode ?? "flat", + segmentToNext: key.segmentToNext ?? "linear", + }; +} + +function normalizeScalarChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel { + const sortedKeys = [...channel.keys] + .map((key) => normalizeScalarKey({ key })) + .sort((leftKey, rightKey) => + byTimeAscending({ + leftTime: leftKey.time, + rightTime: rightKey.time, + }), + ); + const nextKeys = sortedKeys.map((key, index) => { + const previousKey = sortedKeys[index - 1]; + const nextKey = sortedKeys[index + 1]; + return { + ...key, + leftHandle: + previousKey != null + ? normalizeLeftHandle({ + handle: key.leftHandle, + leftKey: previousKey, + rightKey: key, + }) + : undefined, + rightHandle: + nextKey != null + ? normalizeRightHandle({ + handle: key.rightHandle, + leftKey: key, + rightKey: nextKey, + }) + : undefined, + }; + }); + + return { + ...channel, + keys: nextKeys, + }; +} + +export function normalizeChannel<TChannel extends AnimationChannel>({ + channel, +}: { + channel: TChannel; +}): TChannel { + if (channel.kind === "scalar") { + return normalizeScalarChannel({ + channel, + }) as TChannel; + } + + return { + ...channel, + keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, + time, +}: { + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; + time: number; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; + } + + const span = neighborKey.time - edgeKey.time; + if (span === 0) { + return edgeKey.value; + } + + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); +} + +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + fallbackValue: number; +}): number { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { + return fallbackValue; + } + + if (time <= firstKey.time) { + if (time < firstKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.before ?? "hold", + edgeKey: firstKey, + neighborKey: normalizedChannel.keys[1], + time, + }); + } + + return firstKey.value; + } + + if (time >= lastKey.time) { + if (time > lastKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.after ?? "hold", + edgeKey: lastKey, + neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], + time, + }); + } + + return lastKey.value; + } + + for ( + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ + ) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (time === rightKey.time) { + return rightKey.value; + } + + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { + continue; + } + + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; + if (span === 0) { + return rightKey.value; + } + + const progress = clamp({ + value: (time - leftKey.time) / span, + min: 0, + max: 1, + }); + if (leftKey.segmentToNext === "linear") { + return lerpNumber({ + leftValue: leftKey.value, + rightValue: rightKey.value, + progress, + }); + } + + const curveProgress = solveBezierProgressForTime({ + time, + leftKey, + rightKey, + }); + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + return getBezierPoint({ + progress: curveProgress, + p0: leftKey.value, + p1: leftKey.value + rightHandle.dv, + p2: rightKey.value + leftHandle.dv, + p3: rightKey.value, + }); + } + + return lastKey.value; +} + +export function getDiscreteChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: DiscreteValue; +}): DiscreteValue { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, + }); + let currentValue = fallbackValue; + for (const key of normalizedChannel.keys) { + if (time < key.time) { + break; + } + currentValue = key.value; + } + return currentValue; +} + +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: AnimationChannel | undefined; + time: number; + fallbackValue: AnimationValue; +}): AnimationValue { + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + if (channel.kind === "scalar") { + return typeof fallbackValue === "number" + ? getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, + }) + : fallbackValue; + } + + if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { + return fallbackValue; + } + + return getDiscreteChannelValueAtTime({ + channel, + time, + fallbackValue, + }); +} |
