summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-01 01:32:09 +0100
commit5e7c27f542e94ffee5d2106ce318b6f43cc7e738 (patch)
treeb33c3370c01b9b2ee685f1039543bc02f8764f7b /apps/web/src/lib/commands
parent744419469da403be57d30b5b19f6b43246caa57d (diff)
parent354f4dec67ef7eb39d34fca941ca34025a68136b (diff)
downloadopencut-5e7c27f542e94ffee5d2106ce318b6f43cc7e738.tar.gz
Merge branch 'dev' into ripple-editing
Diffstat (limited to 'apps/web/src/lib/commands')
-rw-r--r--apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts433
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts3
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts141
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts76
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts89
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts51
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts43
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts40
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts21
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts38
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts34
16 files changed, 960 insertions, 89 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);
+ });
+});
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 49554e0e..800cbb90 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -13,6 +13,7 @@ import {
isMainTrack,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
export class PasteCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -176,6 +177,10 @@ function buildPastedElements({
...item.element,
id: newElementId,
startTime,
+ animations: cloneAnimations({
+ animations: item.element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
} as TimelineElement);
}
diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
index 9e6ed8cd..7fb46c36 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -5,12 +5,19 @@ import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly rippleEnabled: boolean;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private rippleEnabled = false,
- ) {
+ constructor({
+ elements,
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.rippleEnabled = rippleEnabled;
}
execute(): void {
@@ -19,29 +26,29 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
+ const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
- if (!hasElementsToDelete) {
- return track;
- }
+ if (!hasElementsToDelete) {
+ return track;
+ }
- const deletedElementInfos = elementsToDeleteOnTrack
- .map((target) =>
- track.elements.find((element) => element.id === target.elementId),
- )
- .filter((element): element is NonNullable<typeof element> => element !== undefined)
- .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+ const deletedElementInfos = elementsToDeleteOnTrack
+ .map((target) =>
+ track.elements.find((element) => element.id === target.elementId),
+ )
+ .filter((element): element is NonNullable<typeof element> => element !== undefined)
+ .map((element) => ({ startTime: element.startTime, duration: element.duration }));
- let elements = track.elements.filter(
- (element) =>
- !this.elements.some(
- (target) =>
- target.trackId === track.id && target.elementId === element.id,
- ),
- );
+ let elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id && target.elementId === element.id,
+ ),
+ );
if (this.rippleEnabled && deletedElementInfos.length > 0) {
const sortedByStartDesc = [...deletedElementInfos].sort(
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index cf1bd94c..97b475f4 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -6,6 +6,7 @@ import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
@@ -114,5 +115,14 @@ function buildDuplicateElement({
id: string;
startTime: number;
}): TimelineElement {
- return { ...element, id, name: `${element.name} (copy)`, startTime };
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 3f10baa4..69d94c24 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
+export * from "./keyframes";
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index e1551471..68fc9841 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -173,6 +173,11 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "effect" && !element.effectType) {
+ console.error("Effect element must have effectType");
+ return false;
+ }
+
return true;
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
new file mode 100644
index 00000000..f221e33a
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,3 @@
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-keyframe";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
new file mode 100644
index 00000000..f12f9829
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -0,0 +1,141 @@
+import { EditorCore } from "@/core";
+import {
+ getChannel,
+ getChannelValueAtTime,
+ getElementBaseValueForProperty,
+ removeElementKeyframe,
+ supportsAnimationProperty,
+ withElementBaseValueForProperty,
+} from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+
+function sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): number | null {
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath,
+ });
+ const keyframe = channel?.keyframes.find(
+ (candidate) => candidate.id === keyframeId,
+ );
+ if (!channel || !keyframe) {
+ return null;
+ }
+
+ const baseValue = getElementBaseValueForProperty({ element, propertyPath });
+ if (baseValue === null || typeof baseValue !== "number") {
+ return null;
+ }
+
+ const sampled = getChannelValueAtTime({
+ channel,
+ time: keyframe.time,
+ fallbackValue: baseValue,
+ });
+ return typeof sampled === "number" ? sampled : null;
+}
+
+function removeKeyframeAndPersist({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): TimelineElement {
+ const valueBefore = sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+ });
+
+ const nextAnimations = removeElementKeyframe({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+
+ const isChannelNowEmpty =
+ getChannel({ animations: nextAnimations, propertyPath }) === undefined;
+ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
+
+ const baseElement = shouldPersistToBase
+ ? withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value: valueBefore,
+ })
+ : element;
+
+ return { ...baseElement, animations: nextAnimations };
+}
+
+export class RemoveKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) =>
+ removeKeyframeAndPersist({
+ element,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
new file mode 100644
index 00000000..5328419f
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -0,0 +1,76 @@
+import { EditorCore } from "@/core";
+import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { AnimationPropertyPath } from "@/types/animation";
+import type { TimelineTrack } from "@/types/timeline";
+
+export class RetimeKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+ private readonly nextTime: number;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ nextTime: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.nextTime = nextTime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ if (!Number.isFinite(boundedTime)) return element;
+ return {
+ ...element,
+ animations: retimeElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ time: boundedTime,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
new file mode 100644
index 00000000..370f6865
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -0,0 +1,89 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack } from "@/types/timeline";
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/types/animation";
+
+export class UpsertKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly time: number;
+ private readonly value: AnimationValue;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ return {
+ ...element,
+ animations: upsertElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 2fe06694..6aadcf16 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -14,15 +14,31 @@ import {
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly sourceTrackId: string;
+ private readonly targetTrackId: string;
+ private readonly elementId: string;
+ private readonly newStartTime: number;
+ private readonly createTrack: { type: TrackType; index: number } | undefined;
- constructor(
- private sourceTrackId: string,
- private targetTrackId: string,
- private elementId: string,
- private newStartTime: number,
- private createTrack?: { type: TrackType; index: number },
- ) {
+ constructor({
+ sourceTrackId,
+ targetTrackId,
+ elementId,
+ newStartTime,
+ createTrack,
+ }: {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+ createTrack?: { type: TrackType; index: number };
+ }) {
super();
+ this.sourceTrackId = sourceTrackId;
+ this.targetTrackId = targetTrackId;
+ this.elementId = elementId;
+ this.newStartTime = newStartTime;
+ this.createTrack = createTrack;
}
execute(): void {
@@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
- (t) => t.id === this.sourceTrackId,
+ (track) => track.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
- (el) => el.id === this.elementId,
+ (trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
return;
}
- let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
+ let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
@@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
excludeElementId: this.elementId,
});
+ // keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
@@ -81,12 +98,12 @@ export class MoveElementCommand extends Command {
const isSameTrack = this.sourceTrackId === this.targetTrackId;
- let updatedTracks = tracksToUpdate.map((track) => {
+ let updatedTracks = tracksToUpdate.map((track): TimelineTrack => {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.map((el) =>
- el.id === this.elementId ? movedElement : el,
+ elements: track.elements.map((trackElement) =>
+ trackElement.id === this.elementId ? movedElement : trackElement,
),
};
}
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
if (track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.filter((el) => el.id !== this.elementId),
+ elements: track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ ),
};
}
@@ -105,8 +124,8 @@ export class MoveElementCommand extends Command {
};
}
- return track;
- }) as TimelineTrack[];
+ return track;
+ });
if (!isSameTrack) {
const sourceTrackAfterMove = updatedTracks.find(
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 05a23fff..37ccbb12 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -3,19 +3,33 @@ import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import { rippleShiftElements } from "@/lib/timeline";
+import { splitAnimationsAtTime } from "@/lib/animation";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly splitTime: number;
+ private readonly retainSide: "both" | "left" | "right";
+ private readonly rippleEnabled: boolean;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private splitTime: number,
- private retainSide: "both" | "left" | "right" = "both",
- private rippleEnabled = false,
- ) {
+ constructor({
+ elements,
+ splitTime,
+ retainSide = "both",
+ rippleEnabled = false,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ splitTime: number;
+ retainSide?: "both" | "left" | "right";
+ rippleEnabled?: boolean;
+ }) {
super();
+ this.elements = elements;
+ this.splitTime = splitTime;
+ this.retainSide = retainSide;
+ this.rippleEnabled = rippleEnabled;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -61,6 +75,11 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
+ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
+ animations: element.animations,
+ splitTime: relativeTime,
+ shouldIncludeSplitBoundary: true,
+ });
if (this.retainSide === "left") {
return [
@@ -69,6 +88,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
];
}
@@ -90,11 +110,13 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
}
- const secondElementId = generateUUID();
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
elementId: secondElementId,
@@ -106,6 +128,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
{
...element,
@@ -114,14 +137,12 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
});
- if (
- this.rippleEnabled &&
- leftVisibleDurationForRipple !== null
- ) {
+ if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
elements = rippleShiftElements({
elements,
afterTime: this.splitTime,
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
index 6b3a860b..3366548d 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
@@ -1,28 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly duration: number;
- constructor(
- private trackId: string,
- private elementId: string,
- private duration: number,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ duration,
+ }: {
+ trackId: string;
+ elementId: string;
+ duration: number;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.duration = duration;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, duration: this.duration } : el,
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id !== this.trackId) return track;
+ const newElements = track.elements.map((element) =>
+ element.id === this.elementId
+ ? {
+ ...element,
+ duration: this.duration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: this.duration,
+ }),
+ }
+ : element,
);
- return { ...t, elements: newElements } as typeof t;
+ return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
index 51dcb63d..e25bc0e3 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly startTime: number;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private startTime: number,
- ) {
+ constructor({
+ elements,
+ startTime,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ startTime: number;
+ }) {
super();
+ this.elements = elements;
+ this.startTime = startTime;
}
execute(): void {
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToUpdate) {
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
- (el) => el.elementId === element.id && el.trackId === track.id,
+ (elementEntry) =>
+ elementEntry.elementId === element.id &&
+ elementEntry.trackId === track.id,
);
if (!shouldUpdate) {
return element;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
index 99c63287..6afcb9d0 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
@@ -1,18 +1,35 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elementId: string;
+ private readonly trimStart: number;
+ private readonly trimEnd: number;
+ private readonly startTime: number | undefined;
+ private readonly duration: number | undefined;
- constructor(
- private elementId: string,
- private trimStart: number,
- private trimEnd: number,
- private startTime?: number,
- private duration?: number,
- ) {
+ constructor({
+ elementId,
+ trimStart,
+ trimEnd,
+ startTime,
+ duration,
+ }: {
+ elementId: string;
+ trimStart: number;
+ trimEnd: number;
+ startTime?: number;
+ duration?: number;
+ }) {
super();
+ this.elementId = elementId;
+ this.trimStart = trimStart;
+ this.trimEnd = trimEnd;
+ this.startTime = startTime;
+ this.duration = duration;
}
execute(): void {
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
return element;
}
+ const nextDuration = this.duration ?? element.duration;
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
- duration: this.duration ?? element.duration,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
};
});
return { ...track, elements: newElements } as typeof track;
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
index 4ee54726..643f8e4b 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,28 +1,38 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { updateElementInTracks } from "@/lib/timeline";
export class UpdateElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly updates: Partial<TimelineElement>;
- constructor(
- private trackId: string,
- private elementId: string,
- private updates: Partial<Record<string, unknown>>,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ updates,
+ }: {
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.updates = updates;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, ...this.updates } : el,
- );
- return { ...t, elements: newElements } as typeof t;
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
});
editor.timeline.updateTracks(updatedTracks);