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