From 49426f19cd8eaed7cf6c47999b66fee7ee637a56 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 16:33:57 +0100 Subject: feat: implement keyframe animation system Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules. --- apps/web/src/lib/animation/keyframe-query.ts | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 apps/web/src/lib/animation/keyframe-query.ts (limited to 'apps/web/src/lib/animation/keyframe-query.ts') diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts new file mode 100644 index 00000000..f3288e7a --- /dev/null +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -0,0 +1,67 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationPropertyPath, + ElementAnimations, + ElementKeyframe, +} from "@/types/animation"; + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel || channel.keyframes.length === 0) { + return []; + } + + return channel.keyframes.map((keyframe) => ({ + propertyPath: propertyPath as AnimationPropertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + })); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): boolean { + const channel = animations?.channels[propertyPath]; + return Boolean(channel && channel.keyframes.length > 0); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; +}): ElementKeyframe | null { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.keyframes.length === 0) return null; + const keyframe = channel.keyframes.find( + (kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS, + ); + if (!keyframe) return null; + return { + propertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + }; +} -- cgit v1.3.1 From 6226ceb0526047dff1dd957d38a77f36b75e1bfc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Feb 2026 20:34:33 +0100 Subject: fix coderabbit issues --- apps/web/src/core/managers/timeline-manager.ts | 2 +- apps/web/src/lib/animation/keyframe-query.ts | 2 +- apps/web/src/lib/commands/timeline/element/move-elements.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) (limited to 'apps/web/src/lib/animation/keyframe-query.ts') diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 11c976a8..e0fd9915 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -272,7 +272,7 @@ export class TimelineManager { propertyPath, time, value, - interpolation = "linear", + interpolation, keyframeId, }) => new UpsertKeyframeCommand({ diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index f3288e7a..6626e3e8 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -54,7 +54,7 @@ export function getKeyframeAtTime({ const channel = animations?.channels[propertyPath]; if (!channel || channel.keyframes.length === 0) return null; const keyframe = channel.keyframes.find( - (kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS, + (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, ); if (!keyframe) return null; return { diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 973d9cb2..6aadcf16 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -98,7 +98,7 @@ export class MoveElementCommand extends Command { const isSameTrack = this.sourceTrackId === this.targetTrackId; - let updatedTracks = tracksToUpdate.map((track) => { + let updatedTracks = tracksToUpdate.map((track): TimelineTrack => { if (isSameTrack && track.id === this.sourceTrackId) { return { ...track, @@ -124,8 +124,8 @@ export class MoveElementCommand extends Command { }; } - return track; - }) as TimelineTrack[]; + return track; + }); if (!isSameTrack) { const sourceTrackAfterMove = updatedTracks.find( -- cgit v1.3.1