1351 lines
30 KiB
TypeScript
1351 lines
30 KiB
TypeScript
import type {
|
|
AnimationBindingInstance,
|
|
AnimationBindingKind,
|
|
AnimationChannel,
|
|
AnimationInterpolation,
|
|
AnimationPath,
|
|
AnimationPropertyPath,
|
|
AnimationValue,
|
|
DiscreteAnimationChannel,
|
|
DiscreteAnimationKey,
|
|
ElementAnimations,
|
|
ScalarAnimationChannel,
|
|
ScalarAnimationKey,
|
|
ScalarCurveKeyframePatch,
|
|
ScalarSegmentType,
|
|
} from "@/animation/types";
|
|
import { generateUUID } from "@/utils/id";
|
|
import {
|
|
cloneAnimationBinding,
|
|
createAnimationBinding,
|
|
decomposeAnimationValue,
|
|
} from "./binding-values";
|
|
import {
|
|
getBezierPoint,
|
|
getDefaultLeftHandle,
|
|
getDefaultRightHandle,
|
|
solveBezierProgressForTime,
|
|
} from "./bezier";
|
|
import {
|
|
getChannelValueAtTime,
|
|
getScalarSegmentInterpolation,
|
|
normalizeChannel,
|
|
} from "./interpolation";
|
|
import {
|
|
coerceAnimationValueForProperty,
|
|
getAnimationPropertyDefinition,
|
|
} from "./property-registry";
|
|
import {
|
|
type MediaTime,
|
|
roundMediaTime,
|
|
subMediaTime,
|
|
ZERO_MEDIA_TIME,
|
|
} from "@/wasm";
|
|
|
|
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 toAnimation({
|
|
animations,
|
|
}: {
|
|
animations: ElementAnimations;
|
|
}): ElementAnimations | undefined {
|
|
const nextBindings = Object.fromEntries(
|
|
Object.entries(animations.bindings).filter(([, binding]) => binding),
|
|
);
|
|
const nextChannels = Object.fromEntries(
|
|
Object.entries(animations.channels).filter(([, channel]) =>
|
|
hasChannelKeys({ channel }),
|
|
),
|
|
);
|
|
if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
bindings: nextBindings,
|
|
channels: nextChannels,
|
|
};
|
|
}
|
|
|
|
function cloneAnimationsState({
|
|
animations,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
}): ElementAnimations {
|
|
return {
|
|
bindings: { ...(animations?.bindings ?? {}) },
|
|
channels: { ...(animations?.channels ?? {}) },
|
|
};
|
|
}
|
|
|
|
function getBindingChannelKind({
|
|
kind,
|
|
}: {
|
|
kind: AnimationBindingKind;
|
|
}): AnimationChannel["kind"] {
|
|
return kind === "discrete" ? "discrete" : "scalar";
|
|
}
|
|
|
|
function getPrimaryComponent({
|
|
binding,
|
|
}: {
|
|
binding: AnimationBindingInstance;
|
|
}) {
|
|
return binding.components[0] ?? null;
|
|
}
|
|
|
|
function getPrimaryChannelId({
|
|
binding,
|
|
}: {
|
|
binding: AnimationBindingInstance;
|
|
}) {
|
|
return getPrimaryComponent({ binding })?.channelId ?? null;
|
|
}
|
|
|
|
function getScalarSegmentType({
|
|
interpolation,
|
|
}: {
|
|
interpolation: AnimationInterpolation;
|
|
}): ScalarSegmentType {
|
|
if (interpolation === "hold") {
|
|
return "step";
|
|
}
|
|
return interpolation === "bezier" ? "bezier" : "linear";
|
|
}
|
|
|
|
function getInterpolationForBinding({
|
|
kind,
|
|
interpolation,
|
|
}: {
|
|
kind: AnimationBindingKind;
|
|
interpolation: AnimationInterpolation | undefined;
|
|
}): AnimationInterpolation {
|
|
if (kind === "discrete") {
|
|
return "hold";
|
|
}
|
|
|
|
if (
|
|
interpolation === "linear" ||
|
|
interpolation === "hold" ||
|
|
interpolation === "bezier"
|
|
) {
|
|
return interpolation;
|
|
}
|
|
|
|
return "linear";
|
|
}
|
|
|
|
function createEmptyChannelForBindingKind({
|
|
kind,
|
|
}: {
|
|
kind: AnimationBindingKind;
|
|
}): AnimationChannel {
|
|
if (kind === "discrete") {
|
|
return {
|
|
kind: "discrete",
|
|
keys: [],
|
|
} satisfies DiscreteAnimationChannel;
|
|
}
|
|
|
|
return {
|
|
kind: "scalar",
|
|
keys: [],
|
|
} satisfies ScalarAnimationChannel;
|
|
}
|
|
|
|
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 getBinding({
|
|
animations,
|
|
propertyPath,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
}): AnimationBindingInstance | undefined {
|
|
return animations?.bindings[propertyPath];
|
|
}
|
|
|
|
function getChannelById({
|
|
animations,
|
|
channelId,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
channelId: string;
|
|
}): AnimationChannel | undefined {
|
|
return animations?.channels[channelId];
|
|
}
|
|
|
|
function getBindingComponent({
|
|
binding,
|
|
componentKey,
|
|
}: {
|
|
binding: AnimationBindingInstance;
|
|
componentKey: string;
|
|
}) {
|
|
return binding.components.find((component) => component.key === componentKey) ?? null;
|
|
}
|
|
|
|
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 = normalizeChannel({
|
|
channel: channel ?? { kind: "discrete", 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 normalizeChannel({
|
|
channel: { kind: "discrete", 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 normalizeChannel({
|
|
channel: { kind: "discrete", keys },
|
|
});
|
|
}
|
|
|
|
keys.push(
|
|
createDiscreteKey({
|
|
id: keyframeId ?? generateUUID(),
|
|
time,
|
|
value,
|
|
}),
|
|
);
|
|
return normalizeChannel({
|
|
channel: { kind: "discrete", 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 = normalizeChannel({
|
|
channel: channel ?? { kind: "scalar", 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 normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
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 normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
keys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
});
|
|
}
|
|
|
|
keys.push(
|
|
createScalarKey({
|
|
id: keyframeId ?? generateUUID(),
|
|
time,
|
|
value,
|
|
interpolation: interpolation ?? defaultInterpolation,
|
|
}),
|
|
);
|
|
return normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
keys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function getChannel({
|
|
animations,
|
|
propertyPath,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
}): AnimationChannel | undefined {
|
|
const binding = getBinding({ animations, propertyPath });
|
|
const primaryChannelId =
|
|
binding != null ? getPrimaryChannelId({ binding }) : null;
|
|
return primaryChannelId ? animations?.channels[primaryChannelId] : undefined;
|
|
}
|
|
|
|
export function upsertPathKeyframe({
|
|
animations,
|
|
propertyPath,
|
|
time,
|
|
value,
|
|
interpolation,
|
|
keyframeId,
|
|
kind,
|
|
defaultInterpolation,
|
|
coerceValue,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
time: MediaTime;
|
|
value: AnimationValue;
|
|
interpolation?: AnimationInterpolation;
|
|
keyframeId?: string;
|
|
kind: AnimationBindingKind;
|
|
defaultInterpolation: AnimationInterpolation;
|
|
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
|
|
}): ElementAnimations | undefined {
|
|
const coercedValue = coerceValue({ value });
|
|
if (coercedValue === null) {
|
|
return animations;
|
|
}
|
|
|
|
const nextAnimations = cloneAnimationsState({ animations });
|
|
const existingBinding = getBinding({
|
|
animations,
|
|
propertyPath,
|
|
});
|
|
const binding =
|
|
existingBinding && existingBinding.kind === kind
|
|
? cloneAnimationBinding({ binding: existingBinding })
|
|
: createAnimationBinding({ path: propertyPath, kind });
|
|
const primaryChannel = getChannel({
|
|
animations,
|
|
propertyPath,
|
|
});
|
|
const targetKey = getTargetKeyMetadata({
|
|
channel: primaryChannel,
|
|
time,
|
|
keyframeId,
|
|
});
|
|
const componentValues = decomposeAnimationValue({
|
|
kind,
|
|
value: coercedValue,
|
|
});
|
|
if (!componentValues) {
|
|
return animations;
|
|
}
|
|
|
|
const explicitInterpolation =
|
|
interpolation != null
|
|
? getInterpolationForBinding({ kind, interpolation })
|
|
: undefined;
|
|
const validatedDefaultInterpolation = getInterpolationForBinding({
|
|
kind,
|
|
interpolation: defaultInterpolation,
|
|
});
|
|
nextAnimations.bindings[propertyPath] = binding;
|
|
for (const component of binding.components) {
|
|
const nextValue = componentValues[component.key];
|
|
if (nextValue == null) {
|
|
continue;
|
|
}
|
|
|
|
const currentChannel = getChannelById({
|
|
animations,
|
|
channelId: component.channelId,
|
|
});
|
|
const targetChannel =
|
|
currentChannel?.kind === getBindingChannelKind({ kind })
|
|
? currentChannel
|
|
: createEmptyChannelForBindingKind({ kind });
|
|
nextAnimations.channels[component.channelId] =
|
|
targetChannel.kind === "discrete"
|
|
? upsertDiscreteChannelKey({
|
|
channel: targetChannel,
|
|
time: targetKey.time,
|
|
value: nextValue as string | boolean,
|
|
keyframeId: targetKey.id,
|
|
})
|
|
: upsertScalarChannelKey({
|
|
channel: targetChannel,
|
|
time: targetKey.time,
|
|
value: nextValue as number,
|
|
interpolation: explicitInterpolation,
|
|
defaultInterpolation: validatedDefaultInterpolation,
|
|
keyframeId: targetKey.id,
|
|
});
|
|
}
|
|
|
|
return toAnimation({
|
|
animations: nextAnimations,
|
|
});
|
|
}
|
|
|
|
export function upsertElementKeyframe({
|
|
animations,
|
|
propertyPath,
|
|
time,
|
|
value,
|
|
interpolation,
|
|
keyframeId,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPropertyPath;
|
|
time: MediaTime;
|
|
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,
|
|
kind: propertyDefinition.kind,
|
|
defaultInterpolation: propertyDefinition.defaultInterpolation,
|
|
coerceValue: ({ value: nextValue }) =>
|
|
coerceAnimationValueForProperty({
|
|
propertyPath,
|
|
value: nextValue,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export function upsertKeyframe({
|
|
channel,
|
|
time,
|
|
value,
|
|
interpolation,
|
|
keyframeId,
|
|
}: {
|
|
channel: AnimationChannel | undefined;
|
|
time: MediaTime;
|
|
value: AnimationValue;
|
|
interpolation?: AnimationInterpolation;
|
|
keyframeId?: string;
|
|
}): AnimationChannel | undefined {
|
|
if (!channel) {
|
|
return undefined;
|
|
}
|
|
|
|
if (channel.kind === "discrete") {
|
|
if (typeof value !== "string" && typeof value !== "boolean") {
|
|
return channel;
|
|
}
|
|
|
|
return upsertDiscreteChannelKey({
|
|
channel,
|
|
time,
|
|
value,
|
|
keyframeId,
|
|
});
|
|
}
|
|
|
|
if (typeof value !== "number") {
|
|
return channel;
|
|
}
|
|
|
|
return upsertScalarChannelKey({
|
|
channel,
|
|
time,
|
|
value,
|
|
interpolation,
|
|
keyframeId,
|
|
});
|
|
}
|
|
|
|
export function removeKeyframe({
|
|
channel,
|
|
keyframeId,
|
|
}: {
|
|
channel: AnimationChannel | undefined;
|
|
keyframeId: string;
|
|
}): AnimationChannel | undefined {
|
|
if (!channel) {
|
|
return undefined;
|
|
}
|
|
|
|
const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
|
|
if (nextKeys.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return normalizeChannel({
|
|
channel: {
|
|
...channel,
|
|
keys: nextKeys,
|
|
} as AnimationChannel,
|
|
});
|
|
}
|
|
|
|
export function retimeKeyframe({
|
|
channel,
|
|
keyframeId,
|
|
time,
|
|
}: {
|
|
channel: AnimationChannel | undefined;
|
|
keyframeId: string;
|
|
time: MediaTime;
|
|
}): AnimationChannel | undefined {
|
|
if (!channel) {
|
|
return undefined;
|
|
}
|
|
|
|
const keyframeByIdIndex = channel.keys.findIndex(
|
|
(keyframe) => keyframe.id === keyframeId,
|
|
);
|
|
if (keyframeByIdIndex < 0) {
|
|
return channel;
|
|
}
|
|
|
|
const nextKeys = [...channel.keys];
|
|
nextKeys[keyframeByIdIndex] = {
|
|
...nextKeys[keyframeByIdIndex],
|
|
time,
|
|
};
|
|
|
|
return normalizeChannel({
|
|
channel: {
|
|
...channel,
|
|
keys: nextKeys,
|
|
} as AnimationChannel,
|
|
});
|
|
}
|
|
|
|
export function setChannel({
|
|
animations,
|
|
propertyPath,
|
|
channel,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
channel: AnimationChannel | undefined;
|
|
}): ElementAnimations | undefined {
|
|
const binding = getBinding({ animations, propertyPath });
|
|
if (!binding) {
|
|
return animations;
|
|
}
|
|
|
|
if (binding.components.length !== 1) {
|
|
throw new Error(
|
|
`setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
|
|
);
|
|
}
|
|
|
|
const primaryComponent = getPrimaryComponent({ binding });
|
|
if (!primaryComponent) {
|
|
return animations;
|
|
}
|
|
|
|
return setBindingComponentChannel({
|
|
animations,
|
|
propertyPath,
|
|
componentKey: primaryComponent.key,
|
|
channel,
|
|
});
|
|
}
|
|
|
|
export function setBindingComponentChannel({
|
|
animations,
|
|
propertyPath,
|
|
componentKey,
|
|
channel,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
componentKey: string;
|
|
channel: AnimationChannel | undefined;
|
|
}): ElementAnimations | undefined {
|
|
const binding = getBinding({ animations, propertyPath });
|
|
if (!binding) {
|
|
return animations;
|
|
}
|
|
|
|
const component = getBindingComponent({
|
|
binding,
|
|
componentKey,
|
|
});
|
|
if (!component) {
|
|
return animations;
|
|
}
|
|
|
|
const nextAnimations = cloneAnimationsState({ animations });
|
|
if (!channel || !hasChannelKeys({ channel })) {
|
|
delete nextAnimations.channels[component.channelId];
|
|
const hasRemainingKeys = binding.components.some((candidate) =>
|
|
hasChannelKeys({
|
|
channel: nextAnimations.channels[candidate.channelId],
|
|
}),
|
|
);
|
|
if (!hasRemainingKeys) {
|
|
delete nextAnimations.bindings[propertyPath];
|
|
}
|
|
return toAnimation({
|
|
animations: nextAnimations,
|
|
});
|
|
}
|
|
|
|
nextAnimations.channels[component.channelId] = normalizeChannel({
|
|
channel,
|
|
});
|
|
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 binding = getBinding({ animations, propertyPath });
|
|
if (!binding) {
|
|
return animations;
|
|
}
|
|
|
|
const component = getBindingComponent({
|
|
binding,
|
|
componentKey,
|
|
});
|
|
if (!component) {
|
|
return animations;
|
|
}
|
|
|
|
const channel = getChannelById({
|
|
animations,
|
|
channelId: component.channelId,
|
|
});
|
|
if (channel?.kind !== "scalar") {
|
|
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: {
|
|
kind: "scalar",
|
|
keys: nextKeys,
|
|
extrapolation: channel.extrapolation,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function cloneAnimations({
|
|
animations,
|
|
shouldRegenerateKeyframeIds = false,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
shouldRegenerateKeyframeIds?: boolean;
|
|
}): ElementAnimations | undefined {
|
|
if (!animations) {
|
|
return undefined;
|
|
}
|
|
|
|
const nextAnimations = cloneAnimationsState({ animations });
|
|
nextAnimations.bindings = Object.fromEntries(
|
|
Object.entries(animations.bindings).map(([path, binding]) => [
|
|
path,
|
|
binding ? cloneAnimationBinding({ binding }) : binding,
|
|
]),
|
|
);
|
|
nextAnimations.channels = {};
|
|
|
|
for (const binding of Object.values(nextAnimations.bindings)) {
|
|
if (!binding) {
|
|
continue;
|
|
}
|
|
|
|
const primaryChannel = getChannelById({
|
|
animations,
|
|
channelId: getPrimaryChannelId({ binding }) ?? "",
|
|
});
|
|
const keyIdMap = new Map<string, string>();
|
|
if (primaryChannel) {
|
|
for (const key of primaryChannel.keys) {
|
|
keyIdMap.set(
|
|
key.id,
|
|
shouldRegenerateKeyframeIds ? generateUUID() : key.id,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const component of binding.components) {
|
|
const currentChannel = getChannelById({
|
|
animations,
|
|
channelId: component.channelId,
|
|
});
|
|
if (!currentChannel) {
|
|
continue;
|
|
}
|
|
|
|
nextAnimations.channels[component.channelId] = normalizeChannel({
|
|
channel: {
|
|
...currentChannel,
|
|
keys: currentChannel.keys.map((key) => ({
|
|
...key,
|
|
id: keyIdMap.get(key.id) ?? key.id,
|
|
})),
|
|
} as AnimationChannel,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 as string | boolean,
|
|
}),
|
|
];
|
|
}
|
|
if (!hasBoundaryOnRight) {
|
|
rightKeys = [
|
|
createDiscreteKey({
|
|
id: rightBoundaryId,
|
|
time: ZERO_MEDIA_TIME,
|
|
value: boundaryValue as string | boolean,
|
|
}),
|
|
...rightKeys,
|
|
];
|
|
}
|
|
}
|
|
|
|
return {
|
|
leftChannel: leftKeys.length
|
|
? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
|
|
: undefined,
|
|
rightChannel: rightKeys.length
|
|
? normalizeChannel({ channel: { kind: "discrete", 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: {
|
|
kind: "scalar",
|
|
keys: leftKeys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
})
|
|
: undefined,
|
|
rightChannel: rightKeys.length
|
|
? normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
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,
|
|
}) as number;
|
|
|
|
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: {
|
|
kind: "scalar",
|
|
keys: leftKeys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
}),
|
|
rightChannel: normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
keys: rightKeys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
leftChannel: leftKeys.length
|
|
? normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
keys: leftKeys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
})
|
|
: undefined,
|
|
rightChannel: rightKeys.length
|
|
? normalizeChannel({
|
|
channel: {
|
|
kind: "scalar",
|
|
keys: rightKeys,
|
|
extrapolation: normalizedChannel.extrapolation,
|
|
},
|
|
})
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
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, binding] of Object.entries(animations.bindings)) {
|
|
if (!binding) {
|
|
continue;
|
|
}
|
|
|
|
const leftBinding = cloneAnimationBinding({ binding });
|
|
const rightBinding = cloneAnimationBinding({ binding });
|
|
const leftBoundaryId = generateUUID();
|
|
const rightBoundaryId = generateUUID();
|
|
let hasLeftKeys = false;
|
|
let hasRightKeys = false;
|
|
|
|
for (const component of binding.components) {
|
|
const channel = getChannelById({
|
|
animations,
|
|
channelId: component.channelId,
|
|
});
|
|
const splitResult =
|
|
channel?.kind === "discrete"
|
|
? splitDiscreteChannelAtTime({
|
|
channel,
|
|
splitTime,
|
|
leftBoundaryId,
|
|
rightBoundaryId,
|
|
shouldIncludeSplitBoundary,
|
|
})
|
|
: splitScalarChannelAtTime({
|
|
channel: channel as ScalarAnimationChannel | undefined,
|
|
splitTime,
|
|
leftBoundaryId,
|
|
rightBoundaryId,
|
|
shouldIncludeSplitBoundary,
|
|
});
|
|
if (splitResult.leftChannel) {
|
|
leftAnimations.channels[component.channelId] = splitResult.leftChannel;
|
|
hasLeftKeys = true;
|
|
}
|
|
if (splitResult.rightChannel) {
|
|
rightAnimations.channels[component.channelId] = splitResult.rightChannel;
|
|
hasRightKeys = true;
|
|
}
|
|
}
|
|
|
|
if (hasLeftKeys) {
|
|
leftAnimations.bindings[propertyPath] = leftBinding;
|
|
}
|
|
if (hasRightKeys) {
|
|
rightAnimations.bindings[propertyPath] = rightBinding;
|
|
}
|
|
}
|
|
|
|
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 binding = getBinding({ animations, propertyPath });
|
|
if (!binding) {
|
|
return animations;
|
|
}
|
|
|
|
const nextAnimations = cloneAnimationsState({ animations });
|
|
for (const component of binding.components) {
|
|
nextAnimations.channels[component.channelId] = removeKeyframe({
|
|
channel: nextAnimations.channels[component.channelId],
|
|
keyframeId,
|
|
});
|
|
}
|
|
const hasRemainingKeys = binding.components.some((component) =>
|
|
hasChannelKeys({
|
|
channel: nextAnimations.channels[component.channelId],
|
|
}),
|
|
);
|
|
if (!hasRemainingKeys) {
|
|
delete nextAnimations.bindings[propertyPath];
|
|
for (const component of binding.components) {
|
|
delete nextAnimations.channels[component.channelId];
|
|
}
|
|
}
|
|
return toAnimation({
|
|
animations: nextAnimations,
|
|
});
|
|
}
|
|
|
|
export function retimeElementKeyframe({
|
|
animations,
|
|
propertyPath,
|
|
keyframeId,
|
|
time,
|
|
}: {
|
|
animations: ElementAnimations | undefined;
|
|
propertyPath: AnimationPath;
|
|
keyframeId: string;
|
|
time: MediaTime;
|
|
}): ElementAnimations | undefined {
|
|
const binding = getBinding({ animations, propertyPath });
|
|
if (!binding) {
|
|
return animations;
|
|
}
|
|
|
|
const nextAnimations = cloneAnimationsState({ animations });
|
|
for (const component of binding.components) {
|
|
nextAnimations.channels[component.channelId] = retimeKeyframe({
|
|
channel: nextAnimations.channels[component.channelId],
|
|
keyframeId,
|
|
time,
|
|
});
|
|
}
|
|
return toAnimation({
|
|
animations: nextAnimations,
|
|
});
|
|
}
|