mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
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:
@@ -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,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user