summaryrefslogtreecommitdiff
path: root/apps/web/src/services/renderer
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
commit49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch)
tree1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/services/renderer
parent9b94f89def4f96836c4f42a23a73e97fe647a43c (diff)
downloadopencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz
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.
Diffstat (limited to 'apps/web/src/services/renderer')
-rw-r--r--apps/web/src/services/renderer/nodes/image-node.ts1
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts1
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts35
-rw-r--r--apps/web/src/services/renderer/nodes/video-node.ts3
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts38
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts3
6 files changed, 65 insertions, 16 deletions
diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts
index b3a0be74..6f4f2bd8 100644
--- a/apps/web/src/services/renderer/nodes/image-node.ts
+++ b/apps/web/src/services/renderer/nodes/image-node.ts
@@ -84,6 +84,7 @@ export class ImageNode extends VisualNode<ImageNodeParams> {
source,
sourceWidth: width || renderer.width,
sourceHeight: height || renderer.height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index acf638eb..86f14fb4 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -62,6 +62,7 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
source,
sourceWidth: width,
sourceHeight: height,
+ timelineTime: time,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 76d78b31..0ddb4eae 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -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 &&
diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts
index 8e2ddccd..9ae63661 100644
--- a/apps/web/src/services/renderer/nodes/video-node.ts
+++ b/apps/web/src/services/renderer/nodes/video-node.ts
@@ -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,
});
}
}
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 7179369e..50451b4a 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -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,
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index 530b2eb3..2bbcf25f 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -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,
}),