summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands/__tests__
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/lib/commands/__tests__
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/lib/commands/__tests__')
-rw-r--r--apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts433
1 files changed, 433 insertions, 0 deletions
diff --git a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
new file mode 100644
index 00000000..d2356a39
--- /dev/null
+++ b/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
@@ -0,0 +1,433 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { EditorCore } from "@/core";
+import type { TimelineTrack, VideoElement } from "@/types/timeline";
+import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+import { UpdateElementDurationCommand } from "@/lib/commands/timeline/element/update-element-duration";
+import { UpdateElementTrimCommand } from "@/lib/commands/timeline/element/update-element-trim";
+import { SplitElementsCommand } from "@/lib/commands/timeline/element/split-elements";
+import { DuplicateElementsCommand } from "@/lib/commands/timeline/element/duplicate-elements";
+import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe";
+import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe";
+import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe";
+
+type MockEditor = {
+ timeline: {
+ getTracks: () => TimelineTrack[];
+ updateTracks: (tracks: TimelineTrack[]) => void;
+ };
+ selection: {
+ getSelectedElements: () => { trackId: string; elementId: string }[];
+ setSelectedElements: ({
+ elements,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ }) => void;
+ };
+};
+
+const originalGetInstance = EditorCore.getInstance;
+
+function mockEditorCore({ editor }: { editor: MockEditor }): void {
+ (
+ EditorCore as unknown as {
+ getInstance: () => EditorCore;
+ }
+ ).getInstance = () => editor as unknown as EditorCore;
+}
+
+function restoreEditorCore(): void {
+ (
+ EditorCore as unknown as {
+ getInstance: typeof EditorCore.getInstance;
+ }
+ ).getInstance = originalGetInstance;
+}
+
+function buildVideoElement(): VideoElement {
+ return {
+ id: "element-1",
+ name: "Clip",
+ type: "video",
+ mediaId: "media-1",
+ duration: 8,
+ startTime: 1,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: DEFAULT_TRANSFORM,
+ opacity: 1,
+ animations: {
+ channels: {
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [
+ { id: "kf-a", time: 0, value: 1, interpolation: "linear" },
+ { id: "kf-b", time: 3, value: 1.5, interpolation: "linear" },
+ { id: "kf-c", time: 6, value: 2, interpolation: "linear" },
+ ],
+ },
+ },
+ },
+ };
+}
+
+function buildTracks({ element }: { element: VideoElement }): TimelineTrack[] {
+ return [
+ {
+ id: "track-1",
+ name: "Main",
+ type: "video",
+ elements: [element],
+ isMain: true,
+ muted: false,
+ hidden: false,
+ },
+ ];
+}
+
+afterEach(() => {
+ restoreEditorCore();
+});
+
+describe("keyframe-aware timeline commands", () => {
+ test("duration updates clamp keyframes beyond the new duration", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpdateElementDurationCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ duration: 3,
+ }).execute();
+
+ const updatedElement = (updatedTracks[0].elements[0] as VideoElement).animations;
+ expect(
+ updatedElement?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3]);
+ });
+
+ test("trim updates clamp keyframes when duration is changed", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpdateElementTrimCommand({
+ elementId: "element-1",
+ trimStart: 0,
+ trimEnd: 0,
+ startTime: 1,
+ duration: 2,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ expect(updatedElement.duration).toBe(2);
+ expect(
+ updatedElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0]);
+ });
+
+ test("split rebases right-side keyframes and keeps continuity at split time", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new SplitElementsCommand({
+ elements: [{ trackId: "track-1", elementId: "element-1" }],
+ splitTime: 5,
+ }).execute();
+
+ const leftElement = updatedTracks[0].elements.find(
+ (element) => element.id === "element-1",
+ ) as VideoElement;
+ const rightElement = updatedTracks[0].elements.find(
+ (element) => element.id !== "element-1",
+ ) as VideoElement;
+
+ expect(
+ leftElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3, 4]);
+ expect(
+ rightElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 2]);
+ expect(
+ rightElement.animations?.channels["transform.scale"]?.keyframes[0]?.value,
+ ).toBeCloseTo(5 / 3, 4);
+ });
+
+ test("duplicate creates independent keyframe ids for copied element", () => {
+ const tracks = buildTracks({ element: buildVideoElement() });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [{ trackId: "track-1", elementId: "element-1" }],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new DuplicateElementsCommand({
+ elements: [{ trackId: "track-1", elementId: "element-1" }],
+ }).execute();
+
+ const originalElement = updatedTracks.find(
+ (track) => track.id === "track-1",
+ )?.elements[0] as VideoElement;
+ const duplicatedTrack = updatedTracks.find((track) => track.id !== "track-1");
+ const duplicatedElement = duplicatedTrack?.elements[0] as VideoElement;
+
+ expect(duplicatedElement).toBeDefined();
+ expect(
+ duplicatedElement.animations?.channels["transform.scale"]?.keyframes.map(
+ (keyframe) => keyframe.time,
+ ),
+ ).toEqual([0, 3, 6]);
+ expect(
+ duplicatedElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
+ ).not.toBe(
+ originalElement.animations?.channels["transform.scale"]?.keyframes[0]?.id,
+ );
+ });
+});
+
+describe("generic keyframe commands", () => {
+ test("upsert adds or updates keyframe at target time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpsertKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ time: 2,
+ value: 2.5,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframes =
+ updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
+ const atTwo = keyframes.find((keyframe) => Math.abs(keyframe.time - 2) < 0.001);
+ expect(atTwo?.value).toBe(2.5);
+ });
+
+ test("remove deletes keyframe by id", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RemoveKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "kf-b",
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframes =
+ updatedElement.animations?.channels["transform.scale"]?.keyframes ?? [];
+ expect(keyframes).toHaveLength(2);
+ expect(keyframes.find((keyframe) => keyframe.id === "kf-b")).toBeUndefined();
+ expect(updatedElement.transform.scale).toBe(1);
+ });
+
+ test("remove persists value to base property when channel becomes empty", () => {
+ const element: VideoElement = {
+ ...buildVideoElement(),
+ transform: {
+ ...DEFAULT_TRANSFORM,
+ scale: 1,
+ },
+ animations: {
+ channels: {
+ "transform.scale": {
+ valueKind: "number",
+ keyframes: [
+ {
+ id: "only-scale",
+ time: 2,
+ value: 1.43,
+ interpolation: "linear",
+ },
+ ],
+ },
+ },
+ },
+ };
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RemoveKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "only-scale",
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ expect(updatedElement.transform.scale).toBe(1.43);
+ expect(updatedElement.animations?.channels["transform.scale"]).toBeUndefined();
+ });
+
+ test("upsert supports non-transform paths like opacity", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new UpsertKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "opacity",
+ time: 1,
+ value: 0.35,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const opacityChannel = updatedElement.animations?.channels.opacity;
+ expect(opacityChannel?.valueKind).toBe("number");
+ expect(opacityChannel?.keyframes[0]?.value).toBe(0.35);
+ });
+
+ test("retime moves keyframe to new time", () => {
+ const element = buildVideoElement();
+ const tracks = buildTracks({ element });
+ let updatedTracks: TimelineTrack[] = tracks;
+ mockEditorCore({
+ editor: {
+ timeline: {
+ getTracks: () => tracks,
+ updateTracks: (nextTracks) => {
+ updatedTracks = nextTracks;
+ },
+ },
+ selection: {
+ getSelectedElements: () => [],
+ setSelectedElements: () => {},
+ },
+ },
+ });
+
+ new RetimeKeyframeCommand({
+ trackId: "track-1",
+ elementId: "element-1",
+ propertyPath: "transform.scale",
+ keyframeId: "kf-b",
+ nextTime: 4,
+ }).execute();
+
+ const updatedElement = updatedTracks[0].elements[0] as VideoElement;
+ const keyframe = updatedElement.animations?.channels["transform.scale"]?.keyframes.find(
+ (existingKeyframe) => existingKeyframe.id === "kf-b",
+ );
+ expect(keyframe?.time).toBe(4);
+ });
+});