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(); 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, }); }