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

1362 lines
30 KiB
TypeScript

import type {
AnimationChannel,
ChannelData,
AnimationInterpolation,
AnimationPath,
DiscreteAnimationChannel,
DiscreteAnimationKey,
ElementAnimations,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
import type {
ChannelComponentDefinition,
ParamChannelLayout,
ParamValue,
} from "@/params";
import {
getChannelsFromData,
isCompositeChannelData,
isAnimationStorageKey,
isLeafChannelData,
} from "./channel-data";
import {
getDefaultLeftHandle,
getDefaultRightHandle,
solveBezierProgressForTime,
} from "./bezier";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
isScalarChannel,
normalizeChannel,
normalizeDiscreteChannel,
normalizeScalarChannel,
} from "./interpolation";
import {
type MediaTime,
roundMediaTime,
subMediaTime,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { generateUUID } from "@/utils/id";
function isNearlySameTime({
leftTime,
rightTime,
}: {
leftTime: number;
rightTime: number;
}): boolean {
return leftTime === rightTime;
}
function hasChannelKeys({
channel,
}: {
channel: AnimationChannel | undefined;
}): boolean {
return Boolean(channel && channel.keys.length > 0);
}
function hasChannelData({ data }: { data: ChannelData | undefined }): boolean {
return getChannelsFromData({ data }).some((channel) =>
hasChannelKeys({ channel }),
);
}
function toAnimation({
animations,
}: {
animations: ElementAnimations;
}): ElementAnimations | undefined {
const nextAnimations = Object.fromEntries(
Object.entries(animations).filter(
([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }),
),
);
if (Object.keys(nextAnimations).length === 0) {
return undefined;
}
return nextAnimations;
}
function cloneAnimationsState({
animations,
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations {
return { ...(animations ?? {}) };
}
function getChannelFromData({
data,
componentKey,
}: {
data: ChannelData | undefined;
componentKey: string;
}): AnimationChannel | undefined {
if (isLeafChannelData(data)) {
return componentKey === "value" ? data : undefined;
}
if (isCompositeChannelData(data)) {
return data[componentKey];
}
return undefined;
}
type LayoutComponent = ChannelComponentDefinition<string>;
function getLayoutComponents({
channelLayout,
}: {
channelLayout: ParamChannelLayout;
}): LayoutComponent[] {
return channelLayout.kind === "leaf"
? [channelLayout.component]
: channelLayout.components;
}
function getPrimaryComponentKey({
channelLayout,
}: {
channelLayout: ParamChannelLayout;
}): string {
return getLayoutComponents({ channelLayout })[0]?.key ?? "value";
}
function getPrimaryChannelFromData({
data,
channelLayout,
}: {
data: ChannelData | undefined;
channelLayout: ParamChannelLayout;
}): AnimationChannel | undefined {
return getChannelFromData({
data,
componentKey: getPrimaryComponentKey({ channelLayout }),
});
}
function setChannelInData({
data,
componentKey,
channel,
}: {
data: ChannelData | undefined;
componentKey: string;
channel: AnimationChannel | undefined;
}): ChannelData | undefined {
if (componentKey === "value") {
return channel;
}
const components = isCompositeChannelData(data) ? { ...data } : {};
if (channel && hasChannelKeys({ channel })) {
components[componentKey] = channel;
} else {
delete components[componentKey];
}
return Object.keys(components).length > 0 ? components : undefined;
}
function getChannelDataEntries({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel | undefined]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
return isCompositeChannelData(data) ? Object.entries(data) : [];
}
function getScalarSegmentType({
interpolation,
}: {
interpolation: AnimationInterpolation;
}): ScalarSegmentType {
if (interpolation === "hold") {
return "step";
}
return interpolation === "bezier" ? "bezier" : "linear";
}
function getInterpolationForComponent({
component,
interpolation,
}: {
component: LayoutComponent;
interpolation: AnimationInterpolation | undefined;
}): AnimationInterpolation {
if (component.valueKind === "discrete") {
return "hold";
}
if (
interpolation === "linear" ||
interpolation === "hold" ||
interpolation === "bezier"
) {
return interpolation;
}
return component.defaultInterpolation;
}
function decomposeChannelLayoutValue({
channelLayout,
value,
}: {
channelLayout: ParamChannelLayout;
value: ParamValue;
}): Record<string, ParamValue> | null {
if (channelLayout.kind === "leaf") {
return { [channelLayout.component.key]: value };
}
if (typeof value !== "string") {
return null;
}
const components = channelLayout.decompose(value);
return components ? { ...components } : null;
}
function createScalarKey({
id,
time,
value,
interpolation,
previousKey,
}: {
id: string;
time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey;
}): ScalarAnimationKey {
return {
id,
time,
value,
leftHandle: previousKey?.leftHandle,
rightHandle: previousKey?.rightHandle,
segmentToNext:
previousKey?.segmentToNext ??
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
tangentMode: previousKey?.tangentMode ?? "flat",
};
}
function createDiscreteKey({
id,
time,
value,
}: {
id: string;
time: MediaTime;
value: string | boolean;
}): DiscreteAnimationKey {
return {
id,
time,
value,
};
}
function isDiscreteChannel(
channel: AnimationChannel | undefined,
): channel is DiscreteAnimationChannel {
return channel != null && !isScalarChannel(channel);
}
function getChannelData({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): ChannelData | undefined {
return animations?.[propertyPath];
}
function getTargetKeyMetadata({
channel,
time,
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: MediaTime;
keyframeId?: string;
}) {
const normalizedChannel =
channel != null ? normalizeChannel({ channel }) : undefined;
const keys = normalizedChannel?.keys ?? [];
if (keyframeId) {
const keyById = keys.find((key) => key.id === keyframeId);
if (keyById) {
return {
id: keyById.id,
time,
};
}
}
const keyAtTime = keys.find((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: time }),
);
if (keyAtTime) {
return {
id: keyAtTime.id,
time: keyAtTime.time,
};
}
return {
id: keyframeId ?? generateUUID(),
time,
};
}
function upsertDiscreteChannelKey({
channel,
time,
value,
keyframeId,
}: {
channel: DiscreteAnimationChannel | undefined;
time: MediaTime;
value: string | boolean;
keyframeId?: string;
}): DiscreteAnimationChannel {
const normalizedChannel = normalizeDiscreteChannel({
channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
const existingIndex = keys.findIndex((key) => key.id === keyframeId);
if (existingIndex >= 0) {
keys[existingIndex] = createDiscreteKey({
id: keys[existingIndex].id,
time,
value,
});
return normalizeDiscreteChannel({
channel: { keys },
});
}
}
const existingAtTimeIndex = keys.findIndex((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: time }),
);
if (existingAtTimeIndex >= 0) {
keys[existingAtTimeIndex] = createDiscreteKey({
id: keys[existingAtTimeIndex].id,
time: keys[existingAtTimeIndex].time,
value,
});
return normalizeDiscreteChannel({
channel: { keys },
});
}
keys.push(
createDiscreteKey({
id: keyframeId ?? generateUUID(),
time,
value,
}),
);
return normalizeDiscreteChannel({
channel: { keys },
});
}
function upsertScalarChannelKey({
channel,
time,
value,
interpolation,
defaultInterpolation,
keyframeId,
}: {
channel: ScalarAnimationChannel | undefined;
time: MediaTime;
value: number;
interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string;
}): ScalarAnimationChannel {
const normalizedChannel = normalizeScalarChannel({
channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
const existingIndex = keys.findIndex((key) => key.id === keyframeId);
if (existingIndex >= 0) {
keys[existingIndex] = createScalarKey({
id: keys[existingIndex].id,
time,
value,
interpolation,
previousKey:
interpolation != null
? {
...keys[existingIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
}
: keys[existingIndex],
});
return normalizeScalarChannel({
channel: {
keys,
extrapolation: normalizedChannel.extrapolation,
},
});
}
}
const existingAtTimeIndex = keys.findIndex((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: time }),
);
if (existingAtTimeIndex >= 0) {
keys[existingAtTimeIndex] = createScalarKey({
id: keys[existingAtTimeIndex].id,
time: keys[existingAtTimeIndex].time,
value,
interpolation,
previousKey:
interpolation != null
? {
...keys[existingAtTimeIndex],
segmentToNext: getScalarSegmentType({ interpolation }),
}
: keys[existingAtTimeIndex],
});
return normalizeScalarChannel({
channel: {
keys,
extrapolation: normalizedChannel.extrapolation,
},
});
}
keys.push(
createScalarKey({
id: keyframeId ?? generateUUID(),
time,
value,
interpolation: interpolation ?? defaultInterpolation,
}),
);
return normalizeScalarChannel({
channel: {
keys,
extrapolation: normalizedChannel.extrapolation,
},
});
}
export function getChannel({
animations,
propertyPath,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): AnimationChannel | undefined {
const data = getChannelData({ animations, propertyPath });
if (isLeafChannelData(data)) {
return data;
}
return getChannelsFromData({ data })[0];
}
export function upsertPathKeyframe({
animations,
propertyPath,
time,
value,
interpolation,
keyframeId,
channelLayout,
coerceValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: MediaTime;
value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
channelLayout: ParamChannelLayout;
coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
}): ElementAnimations | undefined {
const coercedValue = coerceValue({ value });
if (coercedValue === null) {
return animations;
}
const nextAnimations = cloneAnimationsState({ animations });
const currentData = getChannelData({ animations, propertyPath });
const primaryChannel = getPrimaryChannelFromData({
data: currentData,
channelLayout,
});
const targetKey = getTargetKeyMetadata({
channel: primaryChannel,
time,
keyframeId,
});
const componentValues = decomposeChannelLayoutValue({
channelLayout,
value: coercedValue,
});
if (!componentValues) {
return animations;
}
let nextData: ChannelData | undefined = currentData;
for (const component of getLayoutComponents({ channelLayout })) {
const componentKey = component.key;
const nextValue = componentValues[componentKey];
if (nextValue == null) {
continue;
}
const currentChannel = getChannelFromData({
data: currentData,
componentKey,
});
if (component.valueKind === "discrete") {
if (typeof nextValue !== "string" && typeof nextValue !== "boolean") {
continue;
}
const nextChannel = upsertDiscreteChannelKey({
channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined,
time: targetKey.time,
value: nextValue,
keyframeId: targetKey.id,
});
nextData = setChannelInData({
data: nextData,
componentKey,
channel: nextChannel,
});
continue;
}
if (typeof nextValue !== "number") {
continue;
}
const nextChannel = upsertScalarChannelKey({
channel:
currentChannel != null && isScalarChannel(currentChannel)
? currentChannel
: undefined,
time: targetKey.time,
value: nextValue,
interpolation:
interpolation != null
? getInterpolationForComponent({ component, interpolation })
: undefined,
defaultInterpolation: component.defaultInterpolation,
keyframeId: targetKey.id,
});
nextData = setChannelInData({
data: nextData,
componentKey,
channel: nextChannel,
});
}
nextAnimations[propertyPath] = nextData;
return toAnimation({
animations: nextAnimations,
});
}
export function upsertKeyframe({
channel,
time,
value,
interpolation,
keyframeId,
}: {
channel: AnimationChannel | undefined;
time: MediaTime;
value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
if (typeof value === "string" || typeof value === "boolean") {
return upsertDiscreteChannelKey({
channel: isDiscreteChannel(channel) ? channel : undefined,
time,
value,
keyframeId,
});
}
if (typeof value !== "number") {
return channel;
}
return upsertScalarChannelKey({
channel: isScalarChannel(channel) ? channel : undefined,
time,
value,
interpolation,
keyframeId,
});
}
export function removeKeyframe({
channel,
keyframeId,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
if (isScalarChannel(channel)) {
const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
if (nextKeys.length === 0) {
return undefined;
}
return normalizeScalarChannel({
channel: {
...channel,
keys: nextKeys,
},
});
}
const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
if (nextKeys.length === 0) {
return undefined;
}
return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
},
});
}
export function retimeKeyframe({
channel,
keyframeId,
time,
}: {
channel: AnimationChannel | undefined;
keyframeId: string;
time: MediaTime;
}): AnimationChannel | undefined {
if (!channel) {
return undefined;
}
if (isScalarChannel(channel)) {
const keyframeByIdIndex = channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex < 0) {
return channel;
}
const nextKeys = [...channel.keys];
nextKeys[keyframeByIdIndex] = {
...nextKeys[keyframeByIdIndex],
time,
};
return normalizeScalarChannel({
channel: {
...channel,
keys: nextKeys,
},
});
}
const keyframeByIdIndex = channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
if (keyframeByIdIndex < 0) {
return channel;
}
const nextKeys = [...channel.keys];
nextKeys[keyframeByIdIndex] = {
...nextKeys[keyframeByIdIndex],
time,
};
return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
},
});
}
export function setChannel({
animations,
propertyPath,
channel,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
return setBindingComponentChannel({
animations,
propertyPath,
componentKey: "value",
channel,
});
}
export function setBindingComponentChannel({
animations,
propertyPath,
componentKey,
channel,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
channel: AnimationChannel | undefined;
}): ElementAnimations | undefined {
const nextAnimations = cloneAnimationsState({ animations });
nextAnimations[propertyPath] = setChannelInData({
data: nextAnimations[propertyPath],
componentKey,
channel:
channel && hasChannelKeys({ channel })
? normalizeChannel({ channel })
: undefined,
});
return toAnimation({
animations: nextAnimations,
});
}
export function updateScalarKeyframeCurve({
animations,
propertyPath,
componentKey,
keyframeId,
patch,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
componentKey: string;
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}): ElementAnimations | undefined {
const channel = getChannelFromData({
data: getChannelData({ animations, propertyPath }),
componentKey,
});
if (!channel || !isScalarChannel(channel)) {
return animations;
}
const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
if (keyframeIndex < 0) {
return animations;
}
const nextKeys = [...channel.keys];
const currentKey = nextKeys[keyframeIndex];
nextKeys[keyframeIndex] = {
...currentKey,
leftHandle:
patch.leftHandle === undefined
? currentKey.leftHandle
: patch.leftHandle ?? undefined,
rightHandle:
patch.rightHandle === undefined
? currentKey.rightHandle
: patch.rightHandle ?? undefined,
segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
tangentMode: patch.tangentMode ?? currentKey.tangentMode,
};
return setBindingComponentChannel({
animations,
propertyPath,
componentKey,
channel: {
keys: nextKeys,
extrapolation: channel.extrapolation,
},
});
}
function cloneChannelWithKeyIds({
channel,
keyIdMap,
}: {
channel: AnimationChannel;
keyIdMap: Map<string, string>;
}): AnimationChannel {
return isScalarChannel(channel)
? normalizeScalarChannel({
channel: {
...channel,
keys: channel.keys.map((key) => ({
...key,
id: keyIdMap.get(key.id) ?? key.id,
})),
},
})
: normalizeDiscreteChannel({
channel: {
...channel,
keys: channel.keys.map((key) => ({
...key,
id: keyIdMap.get(key.id) ?? key.id,
})),
},
});
}
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
}: {
animations: ElementAnimations | undefined;
shouldRegenerateKeyframeIds?: boolean;
}): ElementAnimations | undefined {
if (!animations) {
return undefined;
}
const nextAnimations = cloneAnimationsState({ animations });
for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
isAnimationStorageKey({ key }),
)) {
const channels = getChannelsFromData({ data });
const primaryChannel = channels[0];
const keyIdMap = new Map<string, string>();
if (primaryChannel) {
for (const key of primaryChannel.keys) {
keyIdMap.set(
key.id,
shouldRegenerateKeyframeIds ? generateUUID() : key.id,
);
}
}
if (isLeafChannelData(data)) {
nextAnimations[propertyPath] = cloneChannelWithKeyIds({
channel: data,
keyIdMap,
});
continue;
}
if (isCompositeChannelData(data)) {
nextAnimations[propertyPath] = Object.fromEntries(
Object.entries(data).map(([componentKey, channel]) => [
componentKey,
channel
? cloneChannelWithKeyIds({ channel, keyIdMap })
: undefined,
]),
);
}
}
return toAnimation({
animations: nextAnimations,
});
}
export function clampAnimationsToDuration({
animations,
duration,
}: {
animations: ElementAnimations | undefined;
duration: MediaTime;
}): ElementAnimations | undefined {
if (!animations || duration <= 0) {
return undefined;
}
return splitAnimationsAtTime({
animations,
splitTime: duration,
shouldIncludeSplitBoundary: true,
}).leftAnimations;
}
function lerpPoint({
left,
right,
progress,
}: {
left: { x: number; y: number };
right: { x: number; y: number };
progress: number;
}) {
return {
x: left.x + (right.x - left.x) * progress,
y: left.y + (right.y - left.y) * progress,
};
}
function splitDiscreteChannelAtTime({
channel,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
}: {
channel: DiscreteAnimationChannel | undefined;
splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
}) {
if (!channel || channel.keys.length === 0) {
return {
leftChannel: undefined,
rightChannel: undefined,
};
}
const normalizedChannel = normalizeChannel({ channel });
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
if (shouldIncludeSplitBoundary) {
const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
);
const hasBoundaryOnRight = rightKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
);
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: normalizedChannel.keys[0].value,
});
if (!hasBoundaryOnLeft) {
leftKeys = [
...leftKeys,
createDiscreteKey({
id: leftBoundaryId,
time: splitTime,
value: boundaryValue,
}),
];
}
if (!hasBoundaryOnRight) {
rightKeys = [
createDiscreteKey({
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
value: boundaryValue,
}),
...rightKeys,
];
}
}
return {
leftChannel: leftKeys.length
? normalizeChannel({ channel: { keys: leftKeys } })
: undefined,
rightChannel: rightKeys.length
? normalizeChannel({ channel: { keys: rightKeys } })
: undefined,
};
}
function splitScalarChannelAtTime({
channel,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
}: {
channel: ScalarAnimationChannel | undefined;
splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
}) {
if (!channel || channel.keys.length === 0) {
return {
leftChannel: undefined,
rightChannel: undefined,
};
}
const normalizedChannel = normalizeChannel({ channel });
let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
let rightKeys = normalizedChannel.keys
.filter((key) => key.time >= splitTime)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
}));
const hasBoundaryOnLeft = leftKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
);
const hasBoundaryOnRight = rightKeys.some((key) =>
isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
);
if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) {
return {
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
})
: undefined,
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
})
: undefined,
};
}
for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) {
const leftKey = normalizedChannel.keys[keyIndex];
const rightKey = normalizedChannel.keys[keyIndex + 1];
if (
!(
splitTime > leftKey.time &&
splitTime < rightKey.time
)
) {
continue;
}
const boundaryValue = getChannelValueAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: leftKey.value,
});
if (leftKey.segmentToNext === "bezier") {
const rightHandle =
leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
const leftHandle =
rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
const progress = solveBezierProgressForTime({
time: splitTime,
leftKey,
rightKey,
});
const p0 = { x: leftKey.time, y: leftKey.value };
const p1 = {
x: leftKey.time + rightHandle.dt,
y: leftKey.value + rightHandle.dv,
};
const p2 = {
x: rightKey.time + leftHandle.dt,
y: rightKey.value + leftHandle.dv,
};
const p3 = { x: rightKey.time, y: rightKey.value };
const q0 = lerpPoint({ left: p0, right: p1, progress });
const q1 = lerpPoint({ left: p1, right: p2, progress });
const q2 = lerpPoint({ left: p2, right: p3, progress });
const r0 = lerpPoint({ left: q0, right: q1, progress });
const r1 = lerpPoint({ left: q1, right: q2, progress });
const splitPoint = lerpPoint({ left: r0, right: r1, progress });
leftKeys = [
...normalizedChannel.keys.filter((key) => key.time < splitTime),
{
...leftKey,
rightHandle: {
dt: roundMediaTime({ time: q0.x - p0.x }),
dv: q0.y - p0.y,
},
},
{
id: leftBoundaryId,
time: splitTime,
value: boundaryValue,
leftHandle: {
dt: roundMediaTime({ time: r0.x - splitPoint.x }),
dv: r0.y - splitPoint.y,
},
segmentToNext: leftKey.segmentToNext,
tangentMode: leftKey.tangentMode,
},
];
rightKeys = [
{
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
value: boundaryValue,
rightHandle: {
dt: roundMediaTime({ time: r1.x - splitPoint.x }),
dv: r1.y - splitPoint.y,
},
segmentToNext: "bezier",
tangentMode: leftKey.tangentMode,
},
{
...rightKey,
time: subMediaTime({ a: rightKey.time, b: splitTime }),
leftHandle: {
dt: roundMediaTime({ time: q2.x - p3.x }),
dv: q2.y - p3.y,
},
},
...normalizedChannel.keys
.filter((key) => key.time > rightKey.time)
.map((key) => ({
...key,
time: subMediaTime({ a: key.time, b: splitTime }),
})),
];
} else {
leftKeys = [
...leftKeys,
createScalarKey({
id: leftBoundaryId,
time: splitTime,
value: boundaryValue,
interpolation: "linear",
}),
];
rightKeys = [
createScalarKey({
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
value: boundaryValue,
interpolation: getScalarSegmentInterpolation({
segment: leftKey.segmentToNext,
}),
}),
...rightKeys,
];
}
return {
leftChannel: normalizeChannel({
channel: {
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
}),
rightChannel: normalizeChannel({
channel: {
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
}),
};
}
return {
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
})
: undefined,
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
})
: undefined,
};
}
function splitChannelAtTime({
channel,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
}: {
channel: AnimationChannel | undefined;
splitTime: MediaTime;
leftBoundaryId: string;
rightBoundaryId: string;
shouldIncludeSplitBoundary: boolean;
}) {
return channel != null && !isScalarChannel(channel)
? splitDiscreteChannelAtTime({
channel,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
})
: splitScalarChannelAtTime({
channel:
channel != null && isScalarChannel(channel) ? channel : undefined,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
});
}
export function splitAnimationsAtTime({
animations,
splitTime,
shouldIncludeSplitBoundary = true,
}: {
animations: ElementAnimations | undefined;
splitTime: MediaTime;
shouldIncludeSplitBoundary?: boolean;
}): {
leftAnimations: ElementAnimations | undefined;
rightAnimations: ElementAnimations | undefined;
} {
if (!animations) {
return { leftAnimations: undefined, rightAnimations: undefined };
}
const leftAnimations = cloneAnimationsState({ animations: undefined });
const rightAnimations = cloneAnimationsState({ animations: undefined });
for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
isAnimationStorageKey({ key }),
)) {
if (!data) {
continue;
}
const leftBoundaryId = generateUUID();
const rightBoundaryId = generateUUID();
for (const [componentKey, channel] of getChannelDataEntries({ data })) {
const splitResult = splitChannelAtTime({
channel,
splitTime,
leftBoundaryId,
rightBoundaryId,
shouldIncludeSplitBoundary,
});
if (splitResult.leftChannel) {
leftAnimations[propertyPath] = setChannelInData({
data: leftAnimations[propertyPath],
componentKey,
channel: splitResult.leftChannel,
});
}
if (splitResult.rightChannel) {
rightAnimations[propertyPath] = setChannelInData({
data: rightAnimations[propertyPath],
componentKey,
channel: splitResult.rightChannel,
});
}
}
}
return {
leftAnimations: toAnimation({ animations: leftAnimations }),
rightAnimations: toAnimation({ animations: rightAnimations }),
};
}
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const data = getChannelData({ animations, propertyPath });
if (!data) {
return animations;
}
const nextAnimations = cloneAnimationsState({ animations });
if (isLeafChannelData(data)) {
nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId });
} else if (isCompositeChannelData(data)) {
let nextData: ChannelData | undefined = data;
for (const [componentKey, channel] of Object.entries(data)) {
nextData = setChannelInData({
data: nextData,
componentKey,
channel: removeKeyframe({ channel, keyframeId }),
});
}
nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
});
}
export function retimeElementKeyframe({
animations,
propertyPath,
keyframeId,
time,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
keyframeId: string;
time: MediaTime;
}): ElementAnimations | undefined {
const data = getChannelData({ animations, propertyPath });
if (!data) {
return animations;
}
const nextAnimations = cloneAnimationsState({ animations });
if (isLeafChannelData(data)) {
nextAnimations[propertyPath] = retimeKeyframe({
channel: data,
keyframeId,
time,
});
} else if (isCompositeChannelData(data)) {
let nextData: ChannelData | undefined = data;
for (const [componentKey, channel] of Object.entries(data)) {
nextData = setChannelInData({
data: nextData,
componentKey,
channel: retimeKeyframe({ channel, keyframeId, time }),
});
}
nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
});
}