summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/commands
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
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/index.ts1
-rw-r--r--apps/web/src/lib/commands/media/add-media-asset.ts34
-rw-r--r--apps/web/src/lib/commands/media/remove-media-asset.ts34
-rw-r--r--apps/web/src/lib/commands/project/update-project-settings.ts2
-rw-r--r--apps/web/src/lib/commands/scene/create-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/delete-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/remove-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/rename-scene.ts2
-rw-r--r--apps/web/src/lib/commands/scene/toggle-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/add-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts22
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts45
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts20
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts30
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts64
-rw-r--r--apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts114
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts107
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts25
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/add-track.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/remove-track.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts2
45 files changed, 496 insertions, 589 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
deleted file mode 100644
index d2356a39..00000000
--- a/apps/web/src/lib/commands/__tests__/keyframe-aware-commands.test.ts
+++ /dev/null
@@ -1,433 +0,0 @@
-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/index.ts b/apps/web/src/lib/commands/index.ts
index a2119fc1..03de07c4 100644
--- a/apps/web/src/lib/commands/index.ts
+++ b/apps/web/src/lib/commands/index.ts
@@ -1,6 +1,5 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
-export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./media";
diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts
index ddcca426..a3dec0e6 100644
--- a/apps/web/src/lib/commands/media/add-media-asset.ts
+++ b/apps/web/src/lib/commands/media/add-media-asset.ts
@@ -1,8 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import { toast } from "sonner";
+import type { MediaAsset } from "@/lib/media/types";
import { generateUUID } from "@/utils/id";
import { storageService } from "@/services/storage/service";
+import { hasMediaId } from "@/lib/timeline/element-utils";
export class AddMediaAssetCommand extends Command {
private assetId: string;
@@ -37,6 +39,36 @@ export class AddMediaAssetCommand extends Command {
})
.catch((error) => {
console.error("Failed to save media item:", error);
+
+ const currentAssets = editor.media.getAssets();
+ editor.media.setAssets({
+ assets: currentAssets.filter((asset) => asset.id !== this.assetId),
+ });
+
+ const currentTracks = editor.timeline.getTracks();
+ const orphanedElements: Array<{ trackId: string; elementId: string }> =
+ [];
+
+ for (const track of currentTracks) {
+ for (const element of track.elements) {
+ if (hasMediaId(element) && element.mediaId === this.assetId) {
+ orphanedElements.push({
+ trackId: track.id,
+ elementId: element.id,
+ });
+ }
+ }
+ }
+
+ if (orphanedElements.length > 0) {
+ editor.timeline.deleteElements({ elements: orphanedElements });
+ }
+
+ if (storageService.isQuotaExceededError({ error })) {
+ toast.error("Not enough browser storage", {
+ description: error instanceof Error ? error.message : undefined,
+ });
+ }
});
}
diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts
index ee9ffa2d..f519baef 100644
--- a/apps/web/src/lib/commands/media/remove-media-asset.ts
+++ b/apps/web/src/lib/commands/media/remove-media-asset.ts
@@ -1,10 +1,10 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { MediaAsset } from "@/types/assets";
+import type { MediaAsset } from "@/lib/media/types";
import { storageService } from "@/services/storage/service";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveMediaAssetCommand extends Command {
private savedAssets: MediaAsset[] | null = null;
@@ -33,6 +33,13 @@ export class RemoveMediaAssetCommand extends Command {
return;
}
+ if (this.removedAsset.url) {
+ URL.revokeObjectURL(this.removedAsset.url);
+ }
+ if (this.removedAsset.thumbnailUrl) {
+ URL.revokeObjectURL(this.removedAsset.thumbnailUrl);
+ }
+
videoCache.clearVideo({ mediaId: this.assetId });
editor.media.setAssets({
@@ -63,23 +70,30 @@ export class RemoveMediaAssetCommand extends Command {
undo(): void {
const editor = EditorCore.getInstance();
- if (this.savedAssets) {
- editor.media.setAssets({ assets: this.savedAssets });
- }
+ if (this.savedAssets && this.removedAsset) {
+ const restoredAsset: MediaAsset = {
+ ...this.removedAsset,
+ url: URL.createObjectURL(this.removedAsset.file),
+ };
- if (this.savedTracks) {
- editor.timeline.updateTracks(this.savedTracks);
- }
+ editor.media.setAssets({
+ assets: this.savedAssets.map((a) =>
+ a.id === this.assetId ? restoredAsset : a,
+ ),
+ });
- if (this.removedAsset) {
storageService
.saveMediaAsset({
projectId: this.projectId,
- mediaAsset: this.removedAsset,
+ mediaAsset: restoredAsset,
})
.catch((error) => {
console.error("Failed to restore media item on undo:", error);
});
}
+
+ if (this.savedTracks) {
+ editor.timeline.updateTracks(this.savedTracks);
+ }
}
}
diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts
index b7ab75ee..b16431c1 100644
--- a/apps/web/src/lib/commands/project/update-project-settings.ts
+++ b/apps/web/src/lib/commands/project/update-project-settings.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TProject, TProjectSettings } from "@/types/project";
+import type { TProject, TProjectSettings } from "@/lib/project/types";
export class UpdateProjectSettingsCommand extends Command {
private savedSettings: TProjectSettings | null = null;
diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts
index 5fea5d53..28bda2fd 100644
--- a/apps/web/src/lib/commands/scene/create-scene.ts
+++ b/apps/web/src/lib/commands/scene/create-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { buildDefaultScene } from "@/lib/scenes";
export class CreateSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts
index 83066e24..58c790e6 100644
--- a/apps/web/src/lib/commands/scene/delete-scene.ts
+++ b/apps/web/src/lib/commands/scene/delete-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes";
export class DeleteSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts
index 64382f03..28d7a45c 100644
--- a/apps/web/src/lib/commands/scene/move-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/move-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts
index a3029366..19515b97 100644
--- a/apps/web/src/lib/commands/scene/remove-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import {
getFrameTime,
diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts
index dc62369d..f1d65e27 100644
--- a/apps/web/src/lib/commands/scene/rename-scene.ts
+++ b/apps/web/src/lib/commands/scene/rename-scene.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
export class RenameSceneCommand extends Command {
diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
index ac76d8fc..518a30aa 100644
--- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TScene } from "@/types/timeline";
+import type { TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts
index 5ce91a36..6aeef42b 100644
--- a/apps/web/src/lib/commands/scene/update-bookmark.ts
+++ b/apps/web/src/lib/commands/scene/update-bookmark.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { Bookmark, TScene } from "@/types/timeline";
+import type { Bookmark, TScene } from "@/lib/timeline";
import { updateSceneInArray } from "@/lib/scenes";
import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 800cbb90..00758eb7 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
ClipboardItem,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { wouldElementOverlap } from "@/lib/timeline/element-utils";
import {
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 7fb46c36..4b439870 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { isMainTrack, rippleShiftElements } from "@/lib/timeline";
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 97b475f4..207e2373 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
index 5299749d..34ca12ab 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
import { buildDefaultEffectInstance } from "@/lib/effects";
function addEffectToElement({
diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
index b475474f..1b309f13 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function removeEffectFromElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
index 81c66503..d946be18 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function reorderEffectsOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
index 70bf955d..25e7c356 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts
@@ -1,7 +1,7 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
export function toggleEffectOnElement({
element,
diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
index d9bf0aad..c3443c22 100644
--- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
+++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts
@@ -1,8 +1,8 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
import { isVisualElement, updateElementInTracks } from "@/lib/timeline";
-import type { EffectParamValues } from "@/types/effects";
-import type { TimelineTrack, VisualElement } from "@/types/timeline";
+import type { ParamValues } from "@/lib/params";
+import type { TimelineTrack, VisualElement } from "@/lib/timeline";
function updateEffectParamsOnElement({
element,
@@ -11,7 +11,7 @@ function updateEffectParamsOnElement({
}: {
element: VisualElement;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
}): VisualElement {
const currentEffects = element.effects ?? [];
const updated = currentEffects.map((effect) => {
@@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command {
private readonly trackId: string;
private readonly elementId: string;
private readonly effectId: string;
- private readonly params: Partial<EffectParamValues>;
+ private readonly params: Partial<ParamValues>;
constructor({
trackId,
@@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: string;
elementId: string;
effectId: string;
- params: Partial<EffectParamValues>;
+ params: Partial<ParamValues>;
}) {
super();
this.trackId = trackId;
@@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command {
trackId: this.trackId,
elementId: this.elementId,
elementPredicate: isVisualElement,
- update: (element) => {
- return updateEffectParamsOnElement({
- element: element as VisualElement,
- effectId: this.effectId,
- params: this.params,
- });
+ update: (element) => {
+ return updateEffectParamsOnElement({
+ element: element as VisualElement,
+ effectId: this.effectId,
+ params: this.params,
+ });
},
});
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 413d1adf..899754fb 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
export * from "./keyframes";
export * from "./effects";
+export * from "./masks";
+export * from "./retime";
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 68fc9841..acd72b66 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -6,7 +6,7 @@ import type {
TimelineElement,
TrackType,
ElementType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import {
requiresMediaId,
@@ -19,8 +19,9 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/track-utils";
-import type { MediaAsset } from "@/types/assets";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { MediaAsset } from "@/lib/media/types";
+import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
type InsertElementPlacement =
| { mode: "explicit"; trackId: string }
@@ -168,6 +169,14 @@ export class InsertElementCommand extends Command {
return false;
}
+ if (element.type === "graphic") {
+ registerDefaultGraphics();
+ if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) {
+ console.error("Graphic element must have a valid definitionId");
+ return false;
+ }
+ }
+
if (element.type === "text" && !element.content) {
console.error("Text element must have content");
return false;
@@ -344,9 +353,6 @@ export class InsertElementCommand extends Command {
}: {
element: { type: ElementType };
}): TrackType {
- if (element.type === "video" || element.type === "image") {
- return "video";
- }
- return element.type;
+ return ELEMENT_TRACK_MAP[element.type];
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
index d4922532..984a5ed7 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class RemoveEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
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
index f12f9829..eb5cf742 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -2,15 +2,13 @@ import { EditorCore } from "@/core";
import {
getChannel,
getChannelValueAtTime,
- getElementBaseValueForProperty,
removeElementKeyframe,
- supportsAnimationProperty,
- withElementBaseValueForProperty,
+ resolveAnimationTarget,
} 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";
+import type { AnimationPath, AnimationValue } from "@/lib/animation/types";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
function sampleValueBeforeRemoval({
element,
@@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
-}): number | null {
+}): AnimationValue | null {
const channel = getChannel({
animations: element.animations,
propertyPath,
@@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({
return null;
}
- const baseValue = getElementBaseValueForProperty({ element, propertyPath });
- if (baseValue === null || typeof baseValue !== "number") {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return null;
+ }
+ const baseValue = target.getBaseValue();
+ if (baseValue === null) {
return null;
}
- const sampled = getChannelValueAtTime({
+ return getChannelValueAtTime({
channel,
time: keyframe.time,
fallbackValue: baseValue,
});
- return typeof sampled === "number" ? sampled : null;
}
function removeKeyframeAndPersist({
@@ -51,9 +52,14 @@ function removeKeyframeAndPersist({
keyframeId,
}: {
element: TimelineElement;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): TimelineElement {
+ const target = resolveAnimationTarget({ element, path: propertyPath });
+ if (!target) {
+ return element;
+ }
+
const valueBefore = sampleValueBeforeRemoval({
element,
propertyPath,
@@ -71,11 +77,7 @@ function removeKeyframeAndPersist({
const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
const baseElement = shouldPersistToBase
- ? withElementBaseValueForProperty({
- element,
- propertyPath,
- value: valueBefore,
- })
+ ? target.setBaseValue(valueBefore)
: element;
return { ...baseElement, animations: nextAnimations };
@@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
constructor({
@@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}) {
super();
@@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) =>
removeKeyframeAndPersist({
element,
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
index 5328419f..9e912f27 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -1,15 +1,15 @@
import { EditorCore } from "@/core";
-import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation";
+import { resolveAnimationTarget, retimeElementKeyframe } 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";
+import type { AnimationPath } from "@/lib/animation/types";
+import type { TimelineTrack } from "@/lib/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 propertyPath: AnimationPath;
private readonly keyframeId: string;
private readonly nextTime: number;
@@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
nextTime: number;
}) {
@@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ if (!resolveAnimationTarget({ element, path: this.propertyPath })) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
- if (!Number.isFinite(boundedTime)) return element;
return {
...element,
animations: retimeElementKeyframe({
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index f5236279..285bd80c 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command";
import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel";
import { updateElementInTracks } from "@/lib/timeline";
import { isVisualElement } from "@/lib/timeline/element-utils";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
export class UpsertEffectParamKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
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
index 370f6865..28e7feff 100644
--- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -1,19 +1,19 @@
import { EditorCore } from "@/core";
import { Command } from "@/lib/commands/base-command";
-import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation";
import { updateElementInTracks } from "@/lib/timeline";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import type {
+ AnimationPath,
AnimationInterpolation,
- AnimationPropertyPath,
AnimationValue,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export class UpsertKeyframeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly trackId: string;
private readonly elementId: string;
- private readonly propertyPath: AnimationPropertyPath;
+ private readonly propertyPath: AnimationPath;
private readonly time: number;
private readonly value: AnimationValue;
private readonly interpolation: AnimationInterpolation | undefined;
@@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command {
}: {
trackId: string;
elementId: string;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
value: AnimationValue;
interpolation?: AnimationInterpolation;
@@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command {
tracks: this.savedState,
trackId: this.trackId,
elementId: this.elementId,
- elementPredicate: (element) =>
- supportsAnimationProperty({
- element,
- propertyPath: this.propertyPath,
- }),
update: (element) => {
+ const target = resolveAnimationTarget({
+ element,
+ path: this.propertyPath,
+ });
+ if (!target) {
+ return element;
+ }
+
const boundedTime = Math.max(0, Math.min(this.time, element.duration));
return {
...element,
- animations: upsertElementKeyframe({
+ animations: upsertPathKeyframe({
animations: element.animations,
propertyPath: this.propertyPath,
time: boundedTime,
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
+ valueKind: target.valueKind,
+ defaultInterpolation: target.defaultInterpolation,
+ numericRange: target.numericRange,
}),
};
},
diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts
new file mode 100644
index 00000000..acc47184
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts
@@ -0,0 +1,2 @@
+export { RemoveMaskCommand } from "./remove-mask";
+export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted";
diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
new file mode 100644
index 00000000..ee09cbdc
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts
@@ -0,0 +1,64 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+function removeMaskFromElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId);
+ return { ...element, masks: filteredMasks };
+}
+
+export class RemoveMaskCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ removeMaskFromElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
new file mode 100644
index 00000000..ac3c8c7c
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { isMaskableElement, updateElementInTracks } from "@/lib/timeline";
+import type { Mask } from "@/lib/masks/types";
+import type { TimelineTrack, MaskableElement } from "@/lib/timeline";
+
+export function toggleMaskInvertedOnElement({
+ element,
+ maskId,
+}: {
+ element: MaskableElement;
+ maskId: string;
+}): MaskableElement {
+ const currentMasks = element.masks ?? [];
+ const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({
+ ...mask,
+ params: {
+ ...mask.params,
+ inverted: !mask.params.inverted,
+ },
+ });
+ const updatedMasks = currentMasks.map((mask) =>
+ mask.id !== maskId ? mask : toggleMask(mask),
+ );
+
+ return { ...element, masks: updatedMasks };
+}
+
+export class ToggleMaskInvertedCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly maskId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ maskId,
+ }: {
+ trackId: string;
+ elementId: string;
+ maskId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.maskId = maskId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isMaskableElement,
+ update: (element) =>
+ toggleMaskInvertedOnElement({
+ element: element as MaskableElement,
+ maskId: this.maskId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ 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 51e18c21..a36e8e93 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -4,7 +4,7 @@ import type {
TimelineTrack,
TimelineElement,
TrackType,
-} from "@/types/timeline";
+} from "@/lib/timeline";
import {
buildEmptyTrack,
isMainTrack,
diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts
new file mode 100644
index 00000000..5daa59bf
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts
@@ -0,0 +1 @@
+export { UpdateElementRetimeCommand } from "./update-element-retime";
diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
new file mode 100644
index 00000000..d1f859e6
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
@@ -0,0 +1,114 @@
+import { EditorCore } from "@/core";
+import { clampRetimeRate } from "@/constants/retime-constants";
+import { clampAnimationsToDuration } from "@/lib/animation";
+import { Command } from "@/lib/commands/base-command";
+import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime";
+import { isRetimableElement, updateElementInTracks } from "@/lib/timeline";
+import type { RetimeConfig, TimelineTrack } from "@/lib/timeline";
+
+function getSourceDuration({
+ trimStart,
+ trimEnd,
+ duration,
+ sourceDuration,
+ retime,
+}: {
+ trimStart: number;
+ trimEnd: number;
+ duration: number;
+ sourceDuration?: number;
+ retime?: RetimeConfig;
+}): number {
+ if (typeof sourceDuration === "number") {
+ return sourceDuration;
+ }
+
+ return (
+ trimStart +
+ getSourceSpanAtClipTime({
+ clipTime: duration,
+ retime,
+ }) +
+ trimEnd
+ );
+}
+
+export class UpdateElementRetimeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly retime: RetimeConfig | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ retime,
+ }: {
+ trackId: string;
+ elementId: string;
+ retime?: RetimeConfig;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.retime = retime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: isRetimableElement,
+ update: (element) => {
+ if (!isRetimableElement(element)) {
+ return element;
+ }
+
+ const nextRetime = this.retime
+ ? {
+ ...this.retime,
+ rate: clampRetimeRate({ rate: this.retime.rate }),
+ }
+ : undefined;
+ const sourceDuration = getSourceDuration({
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ duration: element.duration,
+ sourceDuration: element.sourceDuration,
+ retime: element.retime,
+ });
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - element.trimStart - element.trimEnd,
+ );
+ const nextDuration = getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ });
+
+ return {
+ ...element,
+ retime: nextRetime,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
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 37ccbb12..2acac683 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -1,9 +1,10 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
+import { getSourceSpanAtClipTime } from "@/lib/retime";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
+ const retimeRef = isRetimableElement(element)
+ ? element.retime
+ : undefined;
+ const leftSourceSpan = getSourceSpanAtClipTime({
+ clipTime: leftVisibleDuration,
+ retime: retimeRef,
+ });
+ const totalSourceSpan = getSourceSpanAtClipTime({
+ clipTime: element.duration,
+ retime: retimeRef,
+ });
+ const rightSourceSpan = totalSourceSpan - leftSourceSpan;
const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
animations: element.animations,
splitTime: relativeTime,
@@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command {
if (this.retainSide === "left") {
return [
- {
- ...element,
- duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
- name: `${element.name} (left)`,
- animations: leftAnimations,
- },
- ];
- }
-
- if (this.retainSide === "right") {
- if (this.rippleEnabled && elementsToSplit.length === 1) {
- leftVisibleDurationForRipple = leftVisibleDuration;
- }
- const newId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: newId,
- });
- return [
- {
- ...element,
- id: newId,
- startTime: this.splitTime,
- duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
- name: `${element.name} (right)`,
- animations: rightAnimations,
- },
- ];
- }
-
- // "both" - split into two pieces
- const secondElementId = generateUUID();
- this.rightSideElements.push({
- trackId: track.id,
- elementId: secondElementId,
- });
-
- return [
{
...element,
duration: leftVisibleDuration,
- trimEnd: element.trimEnd + rightVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
name: `${element.name} (left)`,
animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
+ ];
+ }
+
+ if (this.retainSide === "right") {
+ if (this.rippleEnabled && elementsToSplit.length === 1) {
+ leftVisibleDurationForRipple = leftVisibleDuration;
+ }
+ const newId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: newId,
+ });
+ return [
{
...element,
- id: secondElementId,
+ id: newId,
startTime: this.splitTime,
duration: rightVisibleDuration,
- trimStart: element.trimStart + leftVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
name: `${element.name} (right)`,
animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
},
];
+ }
+
+ // "both" - split into two pieces
+ const secondElementId = generateUUID();
+ this.rightSideElements.push({
+ trackId: track.id,
+ elementId: secondElementId,
+ });
+
+ return [
+ {
+ ...element,
+ duration: leftVisibleDuration,
+ trimEnd: element.trimEnd + rightSourceSpan,
+ name: `${element.name} (left)`,
+ animations: leftAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ {
+ ...element,
+ id: secondElementId,
+ startTime: this.splitTime,
+ duration: rightVisibleDuration,
+ trimStart: element.trimStart + leftSourceSpan,
+ name: `${element.name} (right)`,
+ animations: rightAnimations,
+ ...(retimeRef !== undefined ? { retime: retimeRef } : {}),
+ },
+ ];
});
if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
index 1fa7421e..f416e7c5 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementHaveAudio } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
index daeb759a..24d51c15 100644
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { canElementBeHidden } from "@/lib/timeline/element-utils";
import { EditorCore } from "@/core";
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 3366548d..d88cf9cb 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,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
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 e25bc0e3..75773ec2 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
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
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 7548638a..b2c77418 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,8 +1,9 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { clampAnimationsToDuration } from "@/lib/animation";
-import { rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
+import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command {
if (!targetElement) return track;
const nextDuration = this.duration ?? targetElement.duration;
- const nextStartTime = this.startTime ?? targetElement.startTime;
+ const requestedStartTime = this.startTime ?? targetElement.startTime;
+ const nextStartTime = enforceMainTrackStart({
+ tracks: this.savedState ?? [],
+ targetTrackId: track.id,
+ requestedStartTime,
+ excludeElementId: this.elementId,
+ });
const oldEndTime = targetElement.startTime + targetElement.duration;
const newEndTime = nextStartTime + nextDuration;
@@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command {
trimEnd: this.trimEnd,
startTime: nextStartTime,
duration: nextDuration,
+ ...(isRetimableElement(targetElement)
+ ? { retime: targetElement.retime }
+ : {}),
animations: clampAnimationsToDuration({
animations: targetElement.animations,
duration: nextDuration,
@@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command {
if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
const shiftedOthers = rippleShiftElements({
- elements: track.elements.filter((element) => element.id !== this.elementId),
+ elements: track.elements.filter(
+ (element) => element.id !== this.elementId,
+ ),
afterTime: oldEndTime,
shiftAmount,
});
@@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command {
elements: track.elements.map((element) =>
element.id === this.elementId
? updatedElement
- : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element)
+ : (shiftedOthers.find((shifted) => shifted.id === element.id) ??
+ element),
),
} as typeof track;
}
@@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command {
return {
...track,
elements: track.elements.map((element) =>
- element.id === this.elementId ? updatedElement : element
+ element.id === this.elementId ? updatedElement : element,
),
} 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 643f8e4b..cf0c5cbc 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { updateElementInTracks } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts
index 95338888..dd4ceb64 100644
--- a/apps/web/src/lib/commands/timeline/track/add-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/add-track.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TrackType, TimelineTrack } from "@/types/timeline";
+import type { TrackType, TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
import {
diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts
index a32aacd7..35f29b1c 100644
--- a/apps/web/src/lib/commands/timeline/track/remove-track.ts
+++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts
@@ -1,6 +1,6 @@
import { Command } from "@/lib/commands/base-command";
import { EditorCore } from "@/core";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { getMainTrack } from "@/lib/timeline";
export class RemoveTrackCommand extends Command {
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
index 3fe526c7..9acf732a 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTracktHaveAudio } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
index e81714ac..4bfd53c0 100644
--- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
+++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
import { canTrackBeHidden } from "@/lib/timeline";
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
index 2ba059c7..a4370cab 100644
--- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
+++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
@@ -1,5 +1,5 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
export class TracksSnapshotCommand extends Command {