OpenCut/apps/web/src/lib/animation/keyframes.ts

727 lines
15 KiB
TypeScript

import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
AnimationPath,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
ColorAnimationChannel,
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
VectorAnimationChannel,
} from "@/lib/animation/types";
import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
import { snapToStep } from "@/utils/math";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
type NumericRange,
} from "./property-registry";
function isNearlySameTime({
leftTime,
rightTime,
}: {
leftTime: number;
rightTime: number;
}): boolean {
return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS;
}
function toAnimation({
channelEntries,
}: {
channelEntries: Array<[string, AnimationChannel]>;
}): ElementAnimations | undefined {
if (channelEntries.length === 0) {
return undefined;
}
return {
channels: Object.fromEntries(channelEntries),
};
}
function toChannel({
keyframes,
valueKind,
}: {
keyframes: AnimationKeyframe[];
valueKind: AnimationValueKind;
}): AnimationChannel {
return normalizeChannel({
channel: {
valueKind,
keyframes,
} as AnimationChannel,
});
}
export function getChannel({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: string;
}): AnimationChannel | undefined {
return animations?.channels[propertyPath];
}
function getInterpolationForChannel({
channel,
interpolation,
}: {
channel: AnimationChannel;
interpolation: AnimationInterpolation | undefined;
}): AnimationInterpolation {
if (channel.valueKind === "discrete") {
return "hold";
}
if (interpolation === "linear" || interpolation === "hold") {
return interpolation;
}
return "linear";
}
function buildKeyframe({
channel,
id,
time,
value,
interpolation,
}: {
channel: AnimationChannel;
id: string;
time: number;
value: AnimationValue;
interpolation: AnimationInterpolation;
}): AnimationKeyframe {
if (channel.valueKind === "number") {
if (typeof value !== "number") {
throw new Error("Number channel keyframes require numeric values");
}
return {
id,
time,
value,
interpolation: interpolation === "hold" ? "hold" : "linear",
};
}
if (channel.valueKind === "color") {
if (typeof value !== "string") {
throw new Error("Color channel keyframes require string values");
}
return {
id,
time,
value,
interpolation: interpolation === "hold" ? "hold" : "linear",
};
}
if (channel.valueKind === "vector") {
if (!isVectorValue(value)) {
throw new Error("Vector channel keyframes require {x, y} values");
}
return {
id,
time,
value,
interpolation: interpolation === "hold" ? "hold" : "linear",
};
}
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error(
"Discrete channel keyframes require boolean or string values",
);
}
return {
id,
time,
value,
interpolation: "hold",
};
}
function createEmptyChannelForValueKind({
valueKind,
}: {
valueKind: AnimationValueKind;
}): AnimationChannel {
if (valueKind === "number") {
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
if (valueKind === "color") {
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
if (valueKind === "vector") {
return {
valueKind: "vector",
keyframes: [],
} satisfies VectorAnimationChannel;
}
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
function clampNumericRange({
value,
numericRange,
}: {
value: number;
numericRange: NumericRange | undefined;
}): number {
if (!numericRange) {
return value;
}
const steppedValue =
numericRange.step != null
? snapToStep({ value, step: numericRange.step })
: value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
return Math.min(maxValue, Math.max(minValue, steppedValue));
}
function coerceAnimationValueForPath({
value,
valueKind,
numericRange,
}: {
value: AnimationValue;
valueKind: AnimationValueKind;
numericRange?: NumericRange;
}): AnimationValue | null {
if (valueKind === "number") {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return clampNumericRange({ value, numericRange });
}
if (valueKind === "color") {
return typeof value === "string" ? value : null;
}
if (valueKind === "vector") {
return isVectorValue(value) ? value : null;
}
return typeof value === "string" || typeof value === "boolean" ? value : null;
}
export function upsertPathKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
valueKind,
defaultInterpolation,
numericRange,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
numericRange?: NumericRange;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForPath({
value,
valueKind,
numericRange,
});
if (coercedValue === null) {
return animations;
}
const channel = getChannel({ animations, propertyPath });
const targetChannel =
channel && channel.valueKind === valueKind
? channel
: createEmptyChannelForValueKind({ valueKind });
const updatedChannel = upsertKeyframe({
channel: targetChannel,
time,
value: coercedValue,
interpolation: interpolation ?? defaultInterpolation,
keyframeId,
});
return (
setChannel({
animations,
propertyPath,
channel: updatedChannel,
}) ?? { channels: {} }
);
}
export function upsertElementKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPropertyPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): ElementAnimations | undefined {
const coercedValue = coerceAnimationValueForProperty({
propertyPath,
value,
});
if (coercedValue === null) {
return animations;
}
const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
return upsertPathKeyframe({
animations,
propertyPath,
time,
value: coercedValue,
interpolation,
keyframeId,
valueKind: propertyDefinition.valueKind,
defaultInterpolation: getDefaultInterpolationForProperty({
propertyPath,
}),
numericRange: propertyDefinition.numericRange,
});
}
export function upsertKeyframe({
channel,
time,
value,
interpolation,
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const currentKeyframes = channel.keyframes;
const nextKeyframes = [...currentKeyframes];
const nextInterpolation = getInterpolationForChannel({
channel,
interpolation,
});
if (keyframeId) {
const keyframeByIdIndex = nextKeyframes.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex >= 0) {
nextKeyframes[keyframeByIdIndex] = buildKeyframe({
channel,
id: nextKeyframes[keyframeByIdIndex].id,
time,
value,
interpolation: nextInterpolation,
});
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
}
const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: time }),
);
if (keyframeAtTimeIndex >= 0) {
nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({
channel,
id: nextKeyframes[keyframeAtTimeIndex].id,
time: nextKeyframes[keyframeAtTimeIndex].time,
value,
interpolation: nextInterpolation,
});
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
nextKeyframes.push(
buildKeyframe({
channel,
id: keyframeId ?? generateUUID(),
time,
value,
interpolation: nextInterpolation,
}),
);
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function removeKeyframe({
channel,
keyframeId,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const nextKeyframes = channel.keyframes.filter(
(keyframe) => keyframe.id !== keyframeId,
);
if (nextKeyframes.length === 0) {
return undefined;
}
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function retimeKeyframe({
channel,
keyframeId,
time,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
time: number;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
const keyframeByIdIndex = channel.keyframes.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex < 0) {
return channel;
}
const nextKeyframes = [...channel.keyframes];
nextKeyframes[keyframeByIdIndex] = {
...nextKeyframes[keyframeByIdIndex],
time,
};
return toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
});
}
export function setChannel({
animations,
propertyPath,
channel,
}: {
animations: ElementAnimations | undefined;
propertyPath: string;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const currentChannels = animations?.channels ?? {};
const nextChannelEntries = Object.entries(currentChannels)
.filter(([path]) => path !== propertyPath)
.filter(([, ch]) => ch && ch.keyframes.length > 0)
.map(([path, ch]) => [path, ch] as [string, AnimationChannel]);
if (channel && channel.keyframes.length > 0) {
nextChannelEntries.push([propertyPath, channel]);
}
return toAnimation({
channelEntries: nextChannelEntries,
});
}
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
}: {
animations: ElementAnimations | undefined;
shouldRegenerateKeyframeIds?: boolean;
}): ElementAnimations | undefined {
if (!animations) {
return undefined;
}
const clonedEntries = Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel || channel.keyframes.length === 0) {
return [];
}
const clonedKeyframes = channel.keyframes.map((keyframe) => ({
...keyframe,
id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id,
}));
return [
[
propertyPath,
toChannel({
keyframes: clonedKeyframes,
valueKind: channel.valueKind,
}),
] as [string, AnimationChannel],
];
},
);
return toAnimation({
channelEntries: clonedEntries,
});
}
export function clampAnimationsToDuration({
animations,
duration,
}: {
animations: ElementAnimations | undefined;
duration: number;
}): ElementAnimations | undefined {
if (!animations) {
return undefined;
}
const clampedEntries = Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
if (!channel) {
return [];
}
const nextKeyframes = channel.keyframes.filter(
(keyframe) => keyframe.time >= 0 && keyframe.time <= duration,
);
if (nextKeyframes.length === 0) {
return [];
}
return [
[
propertyPath,
toChannel({
keyframes: nextKeyframes,
valueKind: channel.valueKind,
}),
] as [string, AnimationChannel],
];
},
);
return toAnimation({
channelEntries: clampedEntries,
});
}
export function splitAnimationsAtTime({
animations,
splitTime,
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
splitTime: number;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
rightAnimations: ElementAnimations | undefined;
} {
if (!animations) {
return { leftAnimations: undefined, rightAnimations: undefined };
}
const leftChannels: Array<[string, AnimationChannel]> = [];
const rightChannels: Array<[string, AnimationChannel]> = [];
for (const [propertyPath, channel] of Object.entries(animations.channels)) {
if (!channel || channel.keyframes.length === 0) {
continue;
}
const normalizedChannel = normalizeChannel({ channel });
let leftKeyframes = normalizedChannel.keyframes.filter(
(keyframe) => keyframe.time <= splitTime,
);
let rightKeyframes = normalizedChannel.keyframes
.filter((keyframe) => keyframe.time >= splitTime)
.map((keyframe) => ({
...keyframe,
time: keyframe.time - splitTime,
}));
const hasBoundaryOnLeft = leftKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }),
);
const hasBoundaryOnRight = rightKeyframes.some((keyframe) =>
isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }),
);
if (
shouldIncludeSplitBoundary &&
(!hasBoundaryOnLeft || !hasBoundaryOnRight)
) {
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: normalizedChannel.keyframes[0].value,
});
const knownPropertyPath = isAnimationPropertyPath(propertyPath)
? propertyPath
: null;
const boundaryInterpolation = knownPropertyPath
? getDefaultInterpolationForProperty({
propertyPath: knownPropertyPath,
})
: normalizedChannel.valueKind === "discrete"
? "hold"
: "linear";
if (!hasBoundaryOnLeft) {
leftKeyframes = [
...leftKeyframes,
buildKeyframe({
channel: normalizedChannel,
id: generateUUID(),
time: splitTime,
value: boundaryValue,
interpolation: boundaryInterpolation,
}),
];
}
if (!hasBoundaryOnRight) {
rightKeyframes = [
buildKeyframe({
channel: normalizedChannel,
id: generateUUID(),
time: 0,
value: boundaryValue,
interpolation: boundaryInterpolation,
}),
...rightKeyframes,
];
}
}
const leftChannel = leftKeyframes.length
? toChannel({
keyframes: leftKeyframes,
valueKind: normalizedChannel.valueKind,
})
: undefined;
const rightChannel = rightKeyframes.length
? toChannel({
keyframes: rightKeyframes,
valueKind: normalizedChannel.valueKind,
})
: undefined;
if (leftChannel) {
leftChannels.push([propertyPath, leftChannel]);
}
if (rightChannel) {
rightChannels.push([propertyPath, rightChannel]);
}
}
return {
leftAnimations: toAnimation({ channelEntries: leftChannels }),
rightAnimations: toAnimation({ channelEntries: rightChannels }),
};
}
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
const updatedChannel = removeKeyframe({
channel,
keyframeId,
});
return setChannel({
animations,
propertyPath,
channel: updatedChannel,
});
}
export function retimeElementKeyframe({
animations,
propertyPath,
keyframeId,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
const updatedChannel = retimeKeyframe({
channel,
keyframeId,
time,
});
return setChannel({
animations,
propertyPath,
channel: updatedChannel,
});
}