diff options
Diffstat (limited to 'apps/web/src/lib/commands')
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 { |
