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.
This commit is contained in:
Maze Winther
2026-02-27 16:33:57 +01:00
parent 9b94f89def
commit 49426f19cd
55 changed files with 4139 additions and 312 deletions
@@ -2,8 +2,13 @@ import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
const VISUAL_EPSILON = 1 / 1000;
import type { ElementAnimations } from "@/types/animation";
import {
getElementLocalTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
export interface VisualNodeParams {
duration: number;
@@ -11,6 +16,7 @@ export interface VisualNodeParams {
trimStart: number;
trimEnd: number;
transform: Transform;
animations?: ElementAnimations;
opacity: number;
blendMode?: BlendMode;
}
@@ -18,14 +24,22 @@ export interface VisualNodeParams {
export abstract class VisualNode<
Params extends VisualNodeParams = VisualNodeParams,
> extends BaseNode<Params> {
protected getLocalTime(time: number): number {
protected getSourceLocalTime(time: number): number {
return time - this.params.timeOffset + this.params.trimStart;
}
protected getAnimationLocalTime(time: number): number {
return getElementLocalTime({
timelineTime: time,
elementStartTime: this.params.timeOffset,
elementDuration: this.params.duration,
});
}
protected isInRange(time: number): boolean {
const localTime = this.getLocalTime(time);
const localTime = this.getSourceLocalTime(time);
return (
localTime >= this.params.trimStart - VISUAL_EPSILON &&
localTime >= this.params.trimStart - TIME_EPSILON_SECONDS &&
localTime < this.params.trimStart + this.params.duration
);
}
@@ -35,15 +49,27 @@ export abstract class VisualNode<
source,
sourceWidth,
sourceHeight,
timelineTime,
}: {
renderer: CanvasRenderer;
source: CanvasImageSource;
sourceWidth: number;
sourceHeight: number;
timelineTime: number;
}): void {
renderer.context.save();
const { transform, opacity } = this.params;
const animationLocalTime = this.getAnimationLocalTime(timelineTime);
const transform = resolveTransformAtTime({
baseTransform: this.params.transform,
animations: this.params.animations,
localTime: animationLocalTime,
});
const opacity = resolveOpacityAtTime({
baseOpacity: this.params.opacity,
animations: this.params.animations,
localTime: animationLocalTime,
});
const containScale = Math.min(
renderer.width / sourceWidth,
renderer.height / sourceHeight,