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/core/managers/timeline-manager.ts | 138 +++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 11 deletions(-) (limited to 'apps/web/src/core/managers/timeline-manager.ts') diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index c8ac5a79..11c976a8 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -5,6 +5,11 @@ import type { TimelineElement, ClipboardItem, } from "@/types/timeline"; +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, +} from "@/types/animation"; import { calculateTotalDuration } from "@/lib/timeline"; import { AddTrackCommand, @@ -24,6 +29,9 @@ import { UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, + UpsertKeyframeCommand, + RemoveKeyframeCommand, + RetimeKeyframeCommand, } from "@/lib/commands/timeline"; import { BatchCommand, PreviewTracker } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; @@ -61,7 +69,11 @@ export class TimelineManager { trimEnd: number; pushHistory?: boolean; }): void { - const command = new UpdateElementTrimCommand(elementId, trimStart, trimEnd); + const command = new UpdateElementTrimCommand({ + elementId, + trimStart, + trimEnd, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -80,11 +92,11 @@ export class TimelineManager { duration: number; pushHistory?: boolean; }): void { - const command = new UpdateElementDurationCommand( + const command = new UpdateElementDurationCommand({ trackId, elementId, duration, - ); + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -99,7 +111,10 @@ export class TimelineManager { elements: { trackId: string; elementId: string }[]; startTime: number; }): void { - const command = new UpdateElementStartTimeCommand(elements, startTime); + const command = new UpdateElementStartTimeCommand({ + elements, + startTime, + }); this.editor.command.execute({ command }); } @@ -116,13 +131,13 @@ export class TimelineManager { newStartTime: number; createTrack?: { type: TrackType; index: number }; }): void { - const command = new MoveElementCommand( + const command = new MoveElementCommand({ sourceTrackId, targetTrackId, elementId, newStartTime, createTrack, - ); + }); this.editor.command.execute({ command }); } @@ -145,7 +160,11 @@ export class TimelineManager { splitTime: number; retainSide?: "both" | "left" | "right"; }): { trackId: string; elementId: string }[] { - const command = new SplitElementsCommand(elements, splitTime, retainSide); + const command = new SplitElementsCommand({ + elements, + splitTime, + retainSide, + }); this.editor.command.execute({ command }); return command.getRightSideElements(); } @@ -197,7 +216,7 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new DeleteElementsCommand(elements); + const command = new DeleteElementsCommand({ elements }); this.editor.command.execute({ command }); } @@ -208,13 +227,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial>; + updates: Partial; }>; pushHistory?: boolean; }): void { const commands = updates.map( ({ trackId, elementId, updates: elementUpdates }) => - new UpdateElementCommand(trackId, elementId, elementUpdates), + new UpdateElementCommand({ + trackId, + elementId, + updates: elementUpdates, + }), ); const command = commands.length === 1 ? commands[0] : new BatchCommand(commands); @@ -225,6 +248,99 @@ export class TimelineManager { } } + upsertKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation = "linear", + keyframeId, + }) => + new UpsertKeyframeCommand({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + removeKeyframes({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ trackId, elementId, propertyPath, keyframeId }) => + new RemoveKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + + retimeKeyframe({ + trackId, + elementId, + propertyPath, + keyframeId, + time, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPropertyPath; + keyframeId: string; + time: number; + }): void { + const command = new RetimeKeyframeCommand({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime: time, + }); + this.editor.command.execute({ command }); + } + isPreviewActive(): boolean { return this.previewTracker.isActive(); } @@ -235,7 +351,7 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial>; + updates: Partial; }>; }): void { const tracks = this.getTracks(); -- 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/core/managers/timeline-manager.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