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
@@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
source,
sourceWidth: width || renderer.width,
sourceHeight: height || renderer.height,
timelineTime: time,
});
}
}
@@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
source,
sourceWidth: width,
sourceHeight: height,
timelineTime: time,
});
}
}
@@ -12,6 +12,11 @@ import {
getTextBackgroundRect,
measureTextBlock,
} from "@/lib/text/layout";
import {
getElementLocalTime,
resolveOpacityAtTime,
resolveTransformAtTime,
} from "@/lib/animation";
function scaleFontSize({
fontSize,
@@ -86,16 +91,28 @@ export class TextNode extends BaseNode<TextNodeParams> {
renderer.context.save();
const x = this.params.transform.position.x + this.params.canvasCenter.x;
const y = this.params.transform.position.y + this.params.canvasCenter.y;
const localTime = getElementLocalTime({
timelineTime: time,
elementStartTime: this.params.startTime,
elementDuration: this.params.duration,
});
const transform = resolveTransformAtTime({
baseTransform: this.params.transform,
animations: this.params.animations,
localTime,
});
const opacity = resolveOpacityAtTime({
baseOpacity: this.params.opacity,
animations: this.params.animations,
localTime,
});
const x = transform.position.x + this.params.canvasCenter.x;
const y = transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
renderer.context.scale(
this.params.transform.scale,
this.params.transform.scale,
);
if (this.params.transform.rotate) {
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
renderer.context.scale(transform.scale, transform.scale);
if (transform.rotate) {
renderer.context.rotate((transform.rotate * Math.PI) / 180);
}
const fontWeight = this.params.fontWeight === "bold" ? "bold" : "normal";
@@ -138,7 +155,7 @@ export class TextNode extends BaseNode<TextNodeParams> {
? this.params.blendMode
: "source-over"
) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity;
renderer.context.globalAlpha = opacity;
if (
this.params.background.color &&
@@ -16,7 +16,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
return;
}
const videoTime = this.getLocalTime(time);
const videoTime = this.getSourceLocalTime(time);
const frame = await videoCache.getFrameAt({
mediaId: this.params.mediaId,
file: this.params.file,
@@ -29,6 +29,7 @@ export class VideoNode extends VisualNode<VideoNodeParams> {
source: frame.canvas,
sourceWidth: frame.canvas.width,
sourceHeight: frame.canvas.height,
timelineTime: time,
});
}
}
@@ -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,
@@ -68,6 +68,7 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
}),
@@ -82,6 +83,7 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
...(params.isPreview && {
@@ -112,6 +114,7 @@ export function buildScene(params: BuildSceneParams) {
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
animations: element.animations,
opacity: element.opacity,
blendMode: element.blendMode,
}),