summaryrefslogtreecommitdiff
path: root/apps/web/src/animation/keyframes.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/animation/keyframes.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/animation/keyframes.ts')
-rw-r--r--apps/web/src/animation/keyframes.ts1338
1 files changed, 1338 insertions, 0 deletions
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
new file mode 100644
index 00000000..12dc777d
--- /dev/null
+++ b/apps/web/src/animation/keyframes.ts
@@ -0,0 +1,1338 @@
+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";
+
+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: number;
+ 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: number;
+ 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: number;
+ 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: number;
+ 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: number;
+ 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: number;
+ 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: 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,
+ 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: number;
+ 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: number;
+}): 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: number;
+}): 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: number;
+ 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: key.time - 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: 0,
+ 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: number;
+ 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: key.time - 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: q0.x - p0.x,
+ dv: q0.y - p0.y,
+ },
+ },
+ {
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ leftHandle: {
+ dt: r0.x - splitPoint.x,
+ dv: r0.y - splitPoint.y,
+ },
+ segmentToNext: leftKey.segmentToNext,
+ tangentMode: leftKey.tangentMode,
+ },
+ ];
+ rightKeys = [
+ {
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue,
+ rightHandle: {
+ dt: r1.x - splitPoint.x,
+ dv: r1.y - splitPoint.y,
+ },
+ segmentToNext: "bezier",
+ tangentMode: leftKey.tangentMode,
+ },
+ {
+ ...rightKey,
+ time: rightKey.time - splitTime,
+ leftHandle: {
+ dt: q2.x - p3.x,
+ dv: q2.y - p3.y,
+ },
+ },
+ ...normalizedChannel.keys
+ .filter((key) => key.time > rightKey.time)
+ .map((key) => ({
+ ...key,
+ time: key.time - splitTime,
+ })),
+ ];
+ } else {
+ leftKeys = [
+ ...leftKeys,
+ createScalarKey({
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ interpolation: "linear",
+ }),
+ ];
+ rightKeys = [
+ createScalarKey({
+ id: rightBoundaryId,
+ time: 0,
+ 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: number;
+ 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: number;
+}): 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,
+ });
+}