summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/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/lib/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/lib/animation/keyframes.ts')
-rw-r--r--apps/web/src/lib/animation/keyframes.ts1338
1 files changed, 0 insertions, 1338 deletions
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
deleted file mode 100644
index a5efb6ee..00000000
--- a/apps/web/src/lib/animation/keyframes.ts
+++ /dev/null
@@ -1,1338 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationBindingKind,
- AnimationChannel,
- AnimationInterpolation,
- AnimationPath,
- AnimationPropertyPath,
- AnimationValue,
- DiscreteAnimationChannel,
- DiscreteAnimationKey,
- ElementAnimations,
- ScalarAnimationChannel,
- ScalarAnimationKey,
- ScalarCurveKeyframePatch,
- ScalarSegmentType,
-} from "@/lib/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,
- });
-}