diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/commands | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/commands')
53 files changed, 3826 insertions, 0 deletions
diff --git a/apps/web/src/commands/base-command.ts b/apps/web/src/commands/base-command.ts new file mode 100644 index 00000000..2ab9af09 --- /dev/null +++ b/apps/web/src/commands/base-command.ts @@ -0,0 +1,31 @@ +import type { EditorSelectionPatch } from "@/selection/editor-selection"; +import type { ElementRef } from "@/timeline/types"; + +export interface CommandResult { + selection?: EditorSelectionPatch; +} + +export function createElementSelectionResult( + selectedElements: ElementRef[], +): CommandResult { + return { + selection: { + selectedElements, + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; +} + +export abstract class Command { + abstract execute(): CommandResult | undefined; + + undo(): void { + throw new Error("Undo not implemented for this command"); + } + + redo(): CommandResult | undefined { + return this.execute(); + } +} diff --git a/apps/web/src/commands/batch-command.ts b/apps/web/src/commands/batch-command.ts new file mode 100644 index 00000000..c4ee7e0b --- /dev/null +++ b/apps/web/src/commands/batch-command.ts @@ -0,0 +1,39 @@ +import { Command, type CommandResult } from "./base-command"; + +export class BatchCommand extends Command { + constructor(private commands: Command[]) { + super(); + } + + execute(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + + for (const command of this.commands) { + const result = command.execute(); + if (result?.selection !== undefined) { + latestSelectionResult = result; + } + } + + return latestSelectionResult; + } + + undo(): void { + for (const command of [...this.commands].reverse()) { + command.undo(); + } + } + + redo(): CommandResult | undefined { + let latestSelectionResult: CommandResult | undefined; + + for (const command of this.commands) { + const result = command.redo(); + if (result?.selection !== undefined) { + latestSelectionResult = result; + } + } + + return latestSelectionResult; + } +} diff --git a/apps/web/src/commands/index.ts b/apps/web/src/commands/index.ts new file mode 100644 index 00000000..78927ca9 --- /dev/null +++ b/apps/web/src/commands/index.ts @@ -0,0 +1,8 @@ +export { Command } from "./base-command"; +export type { CommandResult } from "./base-command"; +export { BatchCommand } from "./batch-command"; + +export * from "./timeline"; +export * from "./media"; +export * from "./scene"; +export * from "./project"; diff --git a/apps/web/src/commands/media/add-media-asset.ts b/apps/web/src/commands/media/add-media-asset.ts new file mode 100644 index 00000000..d22241a2 --- /dev/null +++ b/apps/web/src/commands/media/add-media-asset.ts @@ -0,0 +1,135 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { toast } from "sonner"; +import type { MediaAsset } from "@/media/types"; +import { generateUUID } from "@/utils/id"; +import { storageService } from "@/services/storage/service"; +import type { FrameRate } from "opencut-wasm"; +import { hasMediaId } from "@/timeline/element-utils"; +import { frameRatesEqual, getHighestImportedVideoFps } from "@/fps/utils"; +import { UpdateProjectSettingsCommand } from "@/commands/project"; + +export class AddMediaAssetCommand extends Command { + private assetId: string; + private savedAssets: MediaAsset[] | null = null; + private createdAsset: MediaAsset | null = null; + private previousProjectFps: FrameRate | null = null; + private appliedProjectFps: FrameRate | null = null; + + constructor( + private projectId: string, + private asset: Omit<MediaAsset, "id">, + ) { + super(); + this.assetId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedAssets = [...editor.media.getAssets()]; + + this.createdAsset = { + ...this.asset, + id: this.assetId, + }; + + editor.media.setAssets({ + assets: [...this.savedAssets, this.createdAsset], + }); + this.previousProjectFps = editor.project.getActiveOrNull()?.settings.fps ?? null; + this.appliedProjectFps = editor.project.ratchetFpsForImportedMedia({ + importedAssets: [this.createdAsset], + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: this.createdAsset, + }) + .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.scenes.getActiveScene().tracks; + const orphanedElements: Array<{ trackId: string; elementId: string }> = + []; + + for (const track of [ + ...currentTracks.overlay, + currentTracks.main, + ...currentTracks.audio, + ]) { + 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 }); + } + + this.restoreProjectFpsAfterFailedSave({ editor }); + + if (storageService.isQuotaExceededError({ error })) { + toast.error("Not enough browser storage", { + description: error instanceof Error ? error.message : undefined, + }); + } + }); + + return undefined; + } + + undo(): void { + if (this.savedAssets) { + const editor = EditorCore.getInstance(); + editor.media.setAssets({ assets: this.savedAssets }); + + if (this.createdAsset) { + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item on undo:", error); + }); + } + } + } + + getAssetId(): string { + return this.assetId; + } + + private restoreProjectFpsAfterFailedSave({ + editor, + }: { + editor: EditorCore; + }): void { + if (this.previousProjectFps === null || this.appliedProjectFps === null) return; + + const activeProject = editor.project.getActiveOrNull(); + if (!activeProject) return; + if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; + + const highestRemainingVideoFps = getHighestImportedVideoFps({ + mediaAssets: editor.media.getAssets(), + }); + const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator; + if ( + highestRemainingVideoFps !== null && + highestRemainingVideoFps >= appliedFpsFloat + ) { + return; + } + + new UpdateProjectSettingsCommand({ fps: this.previousProjectFps }).execute(); + } +} diff --git a/apps/web/src/commands/media/index.ts b/apps/web/src/commands/media/index.ts new file mode 100644 index 00000000..466222f2 --- /dev/null +++ b/apps/web/src/commands/media/index.ts @@ -0,0 +1,2 @@ +export { AddMediaAssetCommand } from "./add-media-asset"; +export { RemoveMediaAssetCommand } from "./remove-media-asset"; diff --git a/apps/web/src/commands/media/remove-media-asset.ts b/apps/web/src/commands/media/remove-media-asset.ts new file mode 100644 index 00000000..8da62181 --- /dev/null +++ b/apps/web/src/commands/media/remove-media-asset.ts @@ -0,0 +1,111 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { MediaAsset } from "@/media/types"; +import { buildWaveformSourceKey } from "@/media/waveform-summary"; +import { storageService } from "@/services/storage/service"; +import { videoCache } from "@/services/video-cache/service"; +import { waveformCache } from "@/services/waveform-cache/service"; +import { hasMediaId } from "@/timeline/element-utils"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveMediaAssetCommand extends Command { + private savedAssets: MediaAsset[] | null = null; + private savedTracks: SceneTracks | null = null; + private removedAsset: MediaAsset | null = null; + + constructor( + private projectId: string, + private assetId: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const assets = editor.media.getAssets(); + + this.savedAssets = [...assets]; + this.savedTracks = editor.scenes.getActiveScene().tracks; + + this.removedAsset = + assets.find((media) => media.id === this.assetId) ?? null; + + if (!this.removedAsset) { + console.error("Media asset not found:", this.assetId); + return; + } + + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + + videoCache.clearVideo({ mediaId: this.assetId }); + waveformCache.clearSource({ + sourceKey: buildWaveformSourceKey({ + kind: "media", + id: this.assetId, + }), + }); + + editor.media.setAssets({ + assets: assets.filter((media) => media.id !== this.assetId), + }); + + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + + for (const track of [ + ...this.savedTracks.overlay, + this.savedTracks.main, + ...this.savedTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + elementsToRemove.push({ trackId: track.id, elementId: element.id }); + } + } + } + + if (elementsToRemove.length > 0) { + editor.timeline.deleteElements({ elements: elementsToRemove }); + } + + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item:", error); + }); + } + + undo(): void { + const editor = EditorCore.getInstance(); + + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; + + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + 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/commands/preview-tracker.ts b/apps/web/src/commands/preview-tracker.ts new file mode 100644 index 00000000..68c8dee3 --- /dev/null +++ b/apps/web/src/commands/preview-tracker.ts @@ -0,0 +1,23 @@ +export class PreviewTracker<T> { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/commands/project/index.ts b/apps/web/src/commands/project/index.ts new file mode 100644 index 00000000..a9f26edc --- /dev/null +++ b/apps/web/src/commands/project/index.ts @@ -0,0 +1 @@ +export * from "./update-project-settings"; diff --git a/apps/web/src/commands/project/update-project-settings.ts b/apps/web/src/commands/project/update-project-settings.ts new file mode 100644 index 00000000..8d653db2 --- /dev/null +++ b/apps/web/src/commands/project/update-project-settings.ts @@ -0,0 +1,46 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TProject, TProjectSettings } from "@/project/types"; + +export class UpdateProjectSettingsCommand extends Command { + private savedSettings: TProjectSettings | null = null; + private savedUpdatedAt: Date | null = null; + + constructor(private updates: Partial<TProjectSettings>) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + this.savedSettings = activeProject.settings; + this.savedUpdatedAt = activeProject.metadata.updatedAt; + + const updatedProject: TProject = { + ...activeProject, + settings: { ...activeProject.settings, ...this.updates }, + metadata: { ...activeProject.metadata, updatedAt: new Date() }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } + + undo(): void { + if (!this.savedSettings || !this.savedUpdatedAt) return; + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const updatedProject: TProject = { + ...activeProject, + settings: this.savedSettings, + metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } +} diff --git a/apps/web/src/commands/scene/create-scene.ts b/apps/web/src/commands/scene/create-scene.ts new file mode 100644 index 00000000..b554fe90 --- /dev/null +++ b/apps/web/src/commands/scene/create-scene.ts @@ -0,0 +1,41 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { buildDefaultScene } from "@/timeline/scenes"; + +export class CreateSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private createdScene: TScene | null = null; + + constructor( + private name: string, + private isMain: boolean = false, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedScenes = [...editor.scenes.getScenes()]; + + this.createdScene = buildDefaultScene({ + name: this.name, + isMain: this.isMain, + }); + + const updatedScenes = [...this.savedScenes, this.createdScene]; + editor.scenes.setScenes({ scenes: updatedScenes }); + return undefined; + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } + + getSceneId(): string { + return this.createdScene?.id ?? ""; + } +} diff --git a/apps/web/src/commands/scene/delete-scene.ts b/apps/web/src/commands/scene/delete-scene.ts new file mode 100644 index 00000000..92de518f --- /dev/null +++ b/apps/web/src/commands/scene/delete-scene.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { canDeleteScene, getFallbackSceneAfterDelete } from "@/timeline/scenes"; + +export class DeleteSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private savedActiveSceneId: string | null = null; + private deletedScene: TScene | null = null; + + constructor(private sceneId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + const activeScene = editor.scenes.getActiveScene(); + + this.savedScenes = [...scenes]; + this.savedActiveSceneId = activeScene?.id ?? null; + + this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; + + if (!this.deletedScene) { + console.error("Scene not found:", this.sceneId); + return; + } + + const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); + if (!canDelete) { + console.error("Cannot delete scene:", reason); + return; + } + + const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); + + const newActiveScene = getFallbackSceneAfterDelete({ + scenes: updatedScenes, + deletedSceneId: this.sceneId, + currentSceneId: activeScene?.id ?? null, + }); + + editor.scenes.setScenes({ + scenes: updatedScenes, + activeSceneId: newActiveScene?.id, + }); + } + + undo(): void { + if (this.savedScenes && this.deletedScene) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ + scenes: this.savedScenes, + activeSceneId: this.savedActiveSceneId ?? undefined, + }); + } + } +} diff --git a/apps/web/src/commands/scene/index.ts b/apps/web/src/commands/scene/index.ts new file mode 100644 index 00000000..2b843c99 --- /dev/null +++ b/apps/web/src/commands/scene/index.ts @@ -0,0 +1,7 @@ +export { CreateSceneCommand } from "./create-scene"; +export { DeleteSceneCommand } from "./delete-scene"; +export { RenameSceneCommand } from "./rename-scene"; +export { ToggleBookmarkCommand } from "./toggle-bookmark"; +export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/commands/scene/move-bookmark.ts b/apps/web/src/commands/scene/move-bookmark.ts new file mode 100644 index 00000000..a1624eb0 --- /dev/null +++ b/apps/web/src/commands/scene/move-bookmark.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/timeline/bookmarks/index"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/remove-bookmark.ts b/apps/web/src/commands/scene/remove-bookmark.ts new file mode 100644 index 00000000..ea592556 --- /dev/null +++ b/apps/web/src/commands/scene/remove-bookmark.ts @@ -0,0 +1,59 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + removeBookmarkFromArray, +} from "@/timeline/bookmarks/index"; + +export class RemoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = removeBookmarkFromArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + if (updatedBookmarks.length === activeScene.bookmarks.length) { + return; + } + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/rename-scene.ts b/apps/web/src/commands/scene/rename-scene.ts new file mode 100644 index 00000000..19d6bfd8 --- /dev/null +++ b/apps/web/src/commands/scene/rename-scene.ts @@ -0,0 +1,46 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; + +export class RenameSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private previousName: string | null = null; + + constructor( + private sceneId: string, + private newName: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + + this.savedScenes = [...scenes]; + + const scene = scenes.find((s) => s.id === this.sceneId); + if (!scene) { + console.error("Scene not found:", this.sceneId); + return; + } + + this.previousName = scene.name; + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: this.sceneId, + updates: { name: this.newName, updatedAt: new Date() }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes && this.previousName !== null) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/toggle-bookmark.ts b/apps/web/src/commands/scene/toggle-bookmark.ts new file mode 100644 index 00000000..4193c76e --- /dev/null +++ b/apps/web/src/commands/scene/toggle-bookmark.ts @@ -0,0 +1,55 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + toggleBookmarkInArray, +} from "@/timeline/bookmarks/index"; + +export class ToggleBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = toggleBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/scene/update-bookmark.ts b/apps/web/src/commands/scene/update-bookmark.ts new file mode 100644 index 00000000..0d6a0193 --- /dev/null +++ b/apps/web/src/commands/scene/update-bookmark.ts @@ -0,0 +1,58 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/timeline"; +import { updateSceneInArray } from "@/timeline/scenes"; +import { + getFrameTime, + updateBookmarkInArray, +} from "@/timeline/bookmarks/index"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial<Omit<Bookmark, "time">>, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/commands/timeline/clipboard/index.ts b/apps/web/src/commands/timeline/clipboard/index.ts new file mode 100644 index 00000000..e893b62b --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/index.ts @@ -0,0 +1,2 @@ +export { PasteCommand } from "./paste"; +export { PasteKeyframesCommand } from "./paste-keyframes"; diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts new file mode 100644 index 00000000..e05b49fa --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -0,0 +1,135 @@ +import { EditorCore } from "@/core"; +import { + getKeyframeAtTime, + resolveAnimationTarget, + updateScalarKeyframeCurve, + upsertPathKeyframe, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import type { KeyframeClipboardItem } from "@/clipboard"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { updateElementInSceneTracks } from "@/timeline"; +import { generateUUID } from "@/utils/id"; + +function pasteKeyframesIntoElement({ + element, + time, + clipboardItems, +}: { + element: TimelineElement; + time: number; + clipboardItems: KeyframeClipboardItem[]; +}): TimelineElement { + let nextElement = element; + + for (const item of clipboardItems) { + const target = resolveAnimationTarget({ + element: nextElement, + path: item.propertyPath, + }); + if (!target) { + continue; + } + + const keyframeTime = Math.max( + 0, + Math.min(time + item.timeOffset, nextElement.duration), + ); + const nextAnimations = upsertPathKeyframe({ + animations: nextElement.animations, + propertyPath: item.propertyPath, + time: keyframeTime, + value: item.value, + interpolation: item.interpolation, + keyframeId: generateUUID(), + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + const pastedKeyframe = getKeyframeAtTime({ + animations: nextAnimations, + propertyPath: item.propertyPath, + time: keyframeTime, + }); + + let patchedAnimations = nextAnimations; + if (pastedKeyframe) { + for (const curvePatch of item.curvePatches) { + const nextPatchedAnimations = updateScalarKeyframeCurve({ + animations: patchedAnimations, + propertyPath: item.propertyPath, + componentKey: curvePatch.componentKey, + keyframeId: pastedKeyframe.id, + patch: curvePatch.patch, + }); + patchedAnimations = nextPatchedAnimations ?? patchedAnimations; + } + } + + nextElement = { + ...nextElement, + animations: patchedAnimations, + }; + } + + return nextElement; +} + +export class PasteKeyframesCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly time: number; + private readonly clipboardItems: KeyframeClipboardItem[]; + + constructor({ + trackId, + elementId, + time, + clipboardItems, + }: { + trackId: string; + elementId: string; + time: number; + clipboardItems: KeyframeClipboardItem[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) { + return undefined; + } + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + pasteKeyframesIntoElement({ + element, + time: this.time, + clipboardItems: this.clipboardItems, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts new file mode 100644 index 00000000..4523566e --- /dev/null +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -0,0 +1,193 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { ElementClipboardItem } from "@/clipboard"; +import { generateUUID } from "@/utils/id"; +import { + applyPlacement, + resolveTrackPlacement, + enforceMainTrackStart, +} from "@/timeline/placement"; +import { cloneAnimations } from "@/animation"; + +export class PasteCommand extends Command { + private savedState: SceneTracks | null = null; + private pastedElements: { trackId: string; elementId: string }[] = []; + private readonly time: number; + private readonly clipboardItems: ElementClipboardItem[]; + + constructor({ + time, + clipboardItems, + }: { + time: number; + clipboardItems: ElementClipboardItem[]; + }) { + super(); + this.time = time; + this.clipboardItems = clipboardItems; + } + + execute(): CommandResult | undefined { + if (this.clipboardItems.length === 0) return undefined; + + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.pastedElements = []; + + const minStart = Math.min( + ...this.clipboardItems.map((item) => item.element.startTime), + ); + + let updatedTracks = this.savedState; + const itemsByTrackId = groupClipboardItemsByTrackId({ + clipboardItems: this.clipboardItems, + }); + + for (const [trackId, items] of itemsByTrackId) { + const elementsToAdd = buildPastedElements({ + items, + minStart, + time: this.time, + }); + + if (elementsToAdd.length === 0) { + continue; + } + + const trackType = items[0].trackType; + const sourceTrackIndex = [ + ...updatedTracks.overlay, + updatedTracks.main, + ...updatedTracks.audio, + ].findIndex((track) => track.id === trackId); + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType, + timeSpans: elementsToAdd.map((element) => ({ + startTime: element.startTime, + duration: element.duration, + })), + strategy: { type: "aboveSource", sourceTrackIndex }, + }); + if (!placementResult) { + continue; + } + + let elementsForPlacement = elementsToAdd; + if (placementResult.kind === "existingTrack") { + const targetTrack = + placementResult.trackIndex < updatedTracks.overlay.length + ? updatedTracks.overlay[placementResult.trackIndex] + : placementResult.trackIndex === updatedTracks.overlay.length + ? updatedTracks.main + : updatedTracks.audio[ + placementResult.trackIndex - updatedTracks.overlay.length - 1 + ]; + if (targetTrack?.id === updatedTracks.main.id) { + const earliestElement = elementsToAdd.reduce((earliest, element) => + element.startTime < earliest.startTime ? element : earliest, + ); + const adjustedEarliestStartTime = enforceMainTrackStart({ + tracks: updatedTracks, + targetTrackId: targetTrack.id, + requestedStartTime: earliestElement.startTime, + }); + const delta = adjustedEarliestStartTime - earliestElement.startTime; + + if (delta !== 0) { + elementsForPlacement = elementsToAdd.map((element) => ({ + ...element, + startTime: Math.max(0, element.startTime + delta), + })); + } + } + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: elementsForPlacement, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of elementsForPlacement) { + this.pastedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.pastedElements.length > 0) { + return createElementSelectionResult(this.pastedElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getPastedElements(): { trackId: string; elementId: string }[] { + return this.pastedElements; + } +} + +function groupClipboardItemsByTrackId({ + clipboardItems, +}: { + clipboardItems: ElementClipboardItem[]; +}): Map<string, ElementClipboardItem[]> { + const groupedItems = new Map<string, ElementClipboardItem[]>(); + + for (const item of clipboardItems) { + const existingItems = groupedItems.get(item.trackId) ?? []; + groupedItems.set(item.trackId, [...existingItems, item]); + } + + return groupedItems; +} + +function buildPastedElements({ + items, + minStart, + time, +}: { + items: ElementClipboardItem[]; + minStart: number; + time: number; +}): TimelineElement[] { + const elementsToAdd: TimelineElement[] = []; + + for (const item of items) { + const relativeOffset = item.element.startTime - minStart; + const startTime = Math.max(0, time + relativeOffset); + const newElementId = generateUUID(); + + elementsToAdd.push({ + ...item.element, + id: newElementId, + startTime, + animations: cloneAnimations({ + animations: item.element.animations, + shouldRegenerateKeyframeIds: true, + }), + } as TimelineElement); + } + + return elementsToAdd; +} diff --git a/apps/web/src/commands/timeline/element/delete-elements.ts b/apps/web/src/commands/timeline/element/delete-elements.ts new file mode 100644 index 00000000..ad0267b7 --- /dev/null +++ b/apps/web/src/commands/timeline/element/delete-elements.ts @@ -0,0 +1,72 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import type { TimelineTrack } from "@/timeline"; + +function removeTrackElements<TTrack extends TimelineTrack>({ + track, + elements, +}: { + track: TTrack; + elements: { trackId: string; elementId: string }[]; +}): TTrack { + const nextElements = track.elements.filter( + (element) => + !elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + return { ...track, elements: nextElements } as TTrack; +} + +export class DeleteElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly elements: { trackId: string; elementId: string }[]; + + constructor({ + elements, + }: { + elements: { trackId: string; elementId: string }[]; + }) { + super(); + this.elements = elements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + main: removeTrackElements({ + track: this.savedState.main, + elements: this.elements, + }), + audio: this.savedState.audio.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + }; + + editor.timeline.updateTracks(updatedTracks); + + return { + selection: { + selectedElements: [], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts new file mode 100644 index 00000000..8f65f7c7 --- /dev/null +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -0,0 +1,134 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; +import { cloneAnimations } from "@/animation"; + +interface DuplicateElementsParams { + elements: { trackId: string; elementId: string }[]; +} + +export class DuplicateElementsCommand extends Command { + private duplicatedElements: { trackId: string; elementId: string }[] = []; + private savedState: SceneTracks | null = null; + private elements: DuplicateElementsParams["elements"]; + + constructor({ elements }: DuplicateElementsParams) { + super(); + this.elements = elements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.duplicatedElements = []; + + let updatedTracks = this.savedState; + + for (const track of [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ]) { + const elementsToDuplicate = this.elements.filter( + (elementEntry) => elementEntry.trackId === track.id, + ); + + if (elementsToDuplicate.length === 0) { + continue; + } + + const elementIdsToDuplicate = new Set( + elementsToDuplicate.map((element) => element.elementId), + ); + const newTrackElements: TimelineElement[] = []; + + for (const element of track.elements) { + if (!elementIdsToDuplicate.has(element.id)) { + continue; + } + + const newId = generateUUID(); + newTrackElements.push( + buildDuplicateElement({ + element, + id: newId, + startTime: element.startTime, + }), + ); + } + + const placementResult = resolveTrackPlacement({ + tracks: updatedTracks, + trackType: track.type, + timeSpans: [], + strategy: { type: "alwaysNew", position: "highest" }, + }); + if (!placementResult || placementResult.kind !== "newTrack") { + continue; + } + + const applied = applyPlacement({ + tracks: updatedTracks, + placementResult, + elements: newTrackElements, + }); + if (!applied) { + continue; + } + + updatedTracks = applied.updatedTracks; + + for (const element of newTrackElements) { + this.duplicatedElements.push({ + trackId: applied.targetTrackId, + elementId: element.id, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + if (this.duplicatedElements.length > 0) { + return createElementSelectionResult(this.duplicatedElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getDuplicatedElements(): { trackId: string; elementId: string }[] { + return this.duplicatedElements; + } +} + +function buildDuplicateElement({ + element, + id, + startTime, +}: { + element: TimelineElement; + id: string; + startTime: number; +}): TimelineElement { + return { + ...element, + id, + name: `${element.name} (copy)`, + startTime, + animations: cloneAnimations({ + animations: element.animations, + shouldRegenerateKeyframeIds: true, + }), + }; +} diff --git a/apps/web/src/commands/timeline/element/effects/add-effect.ts b/apps/web/src/commands/timeline/element/effects/add-effect.ts new file mode 100644 index 00000000..be08b433 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/add-effect.ts @@ -0,0 +1,75 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; +import { buildDefaultEffectInstance } from "@/effects"; + +function addEffectToElement({ + element, + effectType, +}: { + element: VisualElement; + effectType: string; +}): VisualElement { + const instance = buildDefaultEffectInstance({ effectType }); + const currentEffects = element.effects ?? []; + return { ...element, effects: [...currentEffects, instance] }; +} + +export class AddClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private effectId: string | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectType: string; + + constructor({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectType = effectType; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const updated = addEffectToElement({ + element: element as VisualElement, + effectType: this.effectType, + }); + const effects = updated.effects ?? []; + this.effectId = effects[effects.length - 1]?.id ?? null; + return updated; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getEffectId(): string | null { + return this.effectId; + } +} diff --git a/apps/web/src/commands/timeline/element/effects/index.ts b/apps/web/src/commands/timeline/element/effects/index.ts new file mode 100644 index 00000000..133ce661 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/index.ts @@ -0,0 +1,5 @@ +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/commands/timeline/element/effects/remove-effect.ts new file mode 100644 index 00000000..e6ef9934 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/remove-effect.ts @@ -0,0 +1,66 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function removeEffectFromElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const filtered = currentEffects.filter((effect) => effect.id !== effectId); + return { ...element, effects: filtered }; +} + +export class RemoveClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return removeEffectFromElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts new file mode 100644 index 00000000..554804e2 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/reorder-effect.ts @@ -0,0 +1,74 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function reorderEffectsOnElement({ + element, + fromIndex, + toIndex, +}: { + element: VisualElement; + fromIndex: number; + toIndex: number; +}): VisualElement { + const effects = [...(element.effects ?? [])]; + const [moved] = effects.splice(fromIndex, 1); + effects.splice(toIndex, 0, moved); + return { ...element, effects }; +} + +export class ReorderClipEffectsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly fromIndex: number; + private readonly toIndex: number; + + constructor({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return reorderEffectsOnElement({ + element: element as VisualElement, + fromIndex: this.fromIndex, + toIndex: this.toIndex, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts new file mode 100644 index 00000000..89149a79 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/toggle-effect.ts @@ -0,0 +1,68 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +export function toggleEffectOnElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => + effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, + ); + return { ...element, effects: updated }; +} + +export class ToggleClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return toggleEffectOnElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts new file mode 100644 index 00000000..62b3c937 --- /dev/null +++ b/apps/web/src/commands/timeline/element/effects/update-effect-params.ts @@ -0,0 +1,87 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/timeline"; +import type { ParamValues } from "@/params"; +import type { SceneTracks, VisualElement } from "@/timeline"; + +function updateEffectParamsOnElement({ + element, + effectId, + params, +}: { + element: VisualElement; + effectId: string; + params: Partial<ParamValues>; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => { + if (effect.id !== effectId) { + return effect; + } + + const nextParams = { ...effect.params }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + + return { ...effect, params: nextParams }; + }); + return { ...element, effects: updated }; +} + +export class UpdateClipEffectParamsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly params: Partial<ParamValues>; + + constructor({ + trackId, + elementId, + effectId, + params, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial<ParamValues>; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.params = params; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/index.ts b/apps/web/src/commands/timeline/element/index.ts new file mode 100644 index 00000000..ff365801 --- /dev/null +++ b/apps/web/src/commands/timeline/element/index.ts @@ -0,0 +1,11 @@ +export { InsertElementCommand } from "./insert-element"; +export { DeleteElementsCommand } from "./delete-elements"; +export { DuplicateElementsCommand } from "./duplicate-elements"; +export { SplitElementsCommand } from "./split-elements"; +export { UpdateElementsCommand } from "./update-elements"; +export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; +export { MoveElementCommand } from "./move-elements"; + +export * from "./keyframes"; +export * from "./effects"; +export * from "./masks"; diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts new file mode 100644 index 00000000..49ac6f2d --- /dev/null +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -0,0 +1,291 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + CreateTimelineElement, + SceneTracks, + TimelineElement, + TrackType, +} from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { requiresMediaId } from "@/timeline/element-utils"; +import type { MediaAsset } from "@/media/types"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { floatToFrameRate } from "@/fps/utils"; +import { graphicsRegistry, registerDefaultGraphics } from "@/graphics"; +import { + applyPlacement, + canElementGoOnTrack, + resolveTrackPlacement, + validateElementTrackCompatibility, +} from "@/timeline/placement"; + +type InsertElementPlacement = + | { mode: "explicit"; trackId: string } + | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; + +export interface InsertElementParams { + element: CreateTimelineElement; + placement: InsertElementPlacement; +} + +export class InsertElementCommand extends Command { + private elementId: string; + private savedState: SceneTracks | null = null; + private targetTrackId: string | null = null; + + constructor({ element, placement }: InsertElementParams) { + super(); + this.elementId = generateUUID(); + this.element = element; + this.placement = placement; + } + + private element: CreateTimelineElement; + private placement: InsertElementPlacement; + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + if (!this.validateElementBasics({ element: this.element })) { + return; + } + + const totalElementsInTimeline = + this.savedState.main.elements.length + + this.savedState.overlay.reduce( + (total, track) => total + track.elements.length, + 0, + ) + + this.savedState.audio.reduce( + (total, track) => total + track.elements.length, + 0, + ); + const isFirstElement = totalElementsInTimeline === 0; + + const newElement = this.buildElement({ element: this.element }); + const updateResult = this.applyPlacementResult({ + tracks: this.savedState, + element: newElement, + }); + + if (!updateResult) { + return; + } + + const { updatedTracks, targetTrackId } = updateResult; + this.targetTrackId = targetTrackId; + + const isVisualMedia = + newElement.type === "video" || newElement.type === "image"; + + if (isFirstElement && isVisualMedia) { + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + const asset = mediaAssets.find( + (item: MediaAsset) => item.id === newElement.mediaId, + ); + + if (asset?.width && asset?.height) { + const nextCanvasSize = { width: asset.width, height: asset.height }; + const shouldSetOriginalCanvasSize = + !activeProject?.settings.originalCanvasSize; + editor.project.updateSettings({ + settings: { + canvasSize: nextCanvasSize, + ...(shouldSetOriginalCanvasSize + ? { originalCanvasSize: nextCanvasSize } + : {}), + }, + pushHistory: false, + }); + } + + if (asset?.type === "video" && asset?.fps) { + editor.project.updateSettings({ + settings: { fps: floatToFrameRate(asset.fps) }, + pushHistory: false, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + return { + selection: { + selectedElements: [ + { trackId: targetTrackId, elementId: this.elementId }, + ], + selectedKeyframes: [], + keyframeSelectionAnchor: null, + selectedMaskPoints: null, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getElementId(): string { + return this.elementId; + } + + getTrackId(): string | null { + return this.targetTrackId; + } + + private buildElement({ + element, + }: { + element: CreateTimelineElement; + }): TimelineElement { + return { + ...element, + id: this.elementId, + startTime: element.startTime, + trimStart: element.trimStart ?? 0, + trimEnd: element.trimEnd ?? 0, + duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION, + } as TimelineElement; + } + + private validateElementBasics({ + element, + }: { + element: CreateTimelineElement; + }): boolean { + if (requiresMediaId({ element }) && !("mediaId" in element)) { + console.error("Element requires mediaId"); + return false; + } + + if ( + element.type === "audio" && + element.sourceType === "library" && + !element.sourceUrl + ) { + console.error("Library audio element must have sourceUrl"); + return false; + } + + if (element.type === "sticker" && !element.stickerId) { + console.error("Sticker element must have stickerId"); + 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; + } + + if (element.type === "effect" && !element.effectType) { + console.error("Effect element must have effectType"); + return false; + } + + return true; + } + + private applyPlacementResult({ + tracks, + element, + }: { + tracks: SceneTracks; + element: TimelineElement; + }): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const placement = this.placement; + + if ( + placement.mode === "auto" && + placement.trackType && + !canElementGoOnTrack({ + elementType: element.type, + trackType: placement.trackType, + }) + ) { + console.error( + `${element.type} elements cannot be placed on ${placement.trackType} tracks`, + ); + return null; + } + + const placementResult = resolveTrackPlacement({ + tracks, + ...(placement.mode === "auto" && placement.trackType + ? { trackType: placement.trackType } + : { elementType: element.type }), + timeSpans: [ + { + startTime: element.startTime, + duration: element.duration, + }, + ], + strategy: + placement.mode === "explicit" + ? { type: "explicit", trackId: placement.trackId } + : { type: "firstAvailable" }, + }); + if (!placementResult) { + if (placement.mode === "explicit") { + const targetTrack = + tracks.main.id === placement.trackId + ? tracks.main + : (tracks.overlay.find((track) => track.id === placement.trackId) ?? + tracks.audio.find((track) => track.id === placement.trackId)); + if (!targetTrack) { + console.error("Track not found:", placement.trackId); + return null; + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + console.error(validation.errorMessage); + } + + return null; + } + + const elementToPlace = + placementResult.kind === "existingTrack" + ? { + ...element, + startTime: placementResult.adjustedStartTime ?? element.startTime, + } + : element; + + const appliedPlacement = applyPlacement({ + tracks, + placementResult, + elements: [elementToPlace], + newTrackInsertIndexOverride: + placement.mode === "auto" && typeof placement.insertIndex === "number" + ? placement.insertIndex + : undefined, + }); + if (!appliedPlacement) { + return null; + } + + return { + updatedTracks: appliedPlacement.updatedTracks, + targetTrackId: appliedPlacement.targetTrackId, + }; + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/index.ts b/apps/web/src/commands/timeline/element/keyframes/index.ts new file mode 100644 index 00000000..319a827b --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/index.ts @@ -0,0 +1,6 @@ +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./update-scalar-keyframe-curve"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts new file mode 100644 index 00000000..c049d5bb --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -0,0 +1,69 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { removeEffectParamKeyframe } from "@/animation/effect-param-channel"; +import { updateElementInSceneTracks } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const animations = removeEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts new file mode 100644 index 00000000..ed917406 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -0,0 +1,107 @@ +import { EditorCore } from "@/core"; +import { + hasKeyframesForPath, + removeElementKeyframe, + resolveAnimationTarget, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { AnimationPath, AnimationValue } from "@/animation/types"; +import type { SceneTracks, TimelineElement } from "@/timeline"; + +function removeKeyframeAndPersist({ + element, + propertyPath, + keyframeId, + valueAtPlayhead, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; + valueAtPlayhead: AnimationValue | null; +}): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + + const nextAnimations = removeElementKeyframe({ + animations: element.animations, + propertyPath, + keyframeId, + }); + + const isChannelNowEmpty = !hasKeyframesForPath({ + animations: nextAnimations, + propertyPath, + }); + + // When clearing all keyframes, preserve the value at the playhead (what the user was seeing) + const shouldPersistToBase = isChannelNowEmpty && valueAtPlayhead !== null; + + const baseElement = shouldPersistToBase + ? target.setBaseValue({ value: valueAtPlayhead }) + : element; + + return { ...baseElement, animations: nextAnimations }; +} + +export class RemoveKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + private readonly valueAtPlayhead: AnimationValue | null; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + valueAtPlayhead, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + valueAtPlayhead: AnimationValue | null; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.valueAtPlayhead = valueAtPlayhead; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + removeKeyframeAndPersist({ + element, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + valueAtPlayhead: this.valueAtPlayhead, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts new file mode 100644 index 00000000..ccbdbc5d --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { AnimationPath } from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class RetimeKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + private readonly nextTime: number; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + nextTime: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.nextTime = nextTime; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + return { + ...element, + animations: retimeElementKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + time: boundedTime, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts new file mode 100644 index 00000000..4a3db39d --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -0,0 +1,85 @@ +import { EditorCore } from "@/core"; +import { + resolveAnimationTarget, + updateScalarKeyframeCurve, +} from "@/animation"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { + AnimationPath, + ScalarCurveKeyframePatch, +} from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class UpdateScalarKeyframeCurveCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly componentKey: string; + private readonly keyframeId: string; + private readonly patch: ScalarCurveKeyframePatch; + + constructor({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.componentKey = componentKey; + this.keyframeId = keyframeId; + this.patch = patch; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + return { + ...element, + animations: updateScalarKeyframeCurve({ + animations: element.animations, + propertyPath: this.propertyPath, + componentKey: this.componentKey, + keyframeId: this.keyframeId, + patch: this.patch, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts new file mode 100644 index 00000000..eae8f039 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -0,0 +1,104 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + buildEffectParamPath, + resolveAnimationTarget, + upsertPathKeyframe, +} from "@/animation"; +import { updateElementInSceneTracks } from "@/timeline"; +import { isVisualElement } from "@/timeline/element-utils"; +import type { AnimationInterpolation } from "@/animation/types"; +import type { SceneTracks } from "@/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly time: number; + private readonly value: number | string | boolean; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number | string | boolean; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const propertyPath = buildEffectParamPath({ + effectId: this.effectId, + paramKey: this.paramKey, + }); + const target = resolveAnimationTarget({ + element, + path: propertyPath, + }); + if (!target) { + return element; + } + + const animations = upsertPathKeyframe({ + animations: element.animations, + propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts new file mode 100644 index 00000000..30514146 --- /dev/null +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -0,0 +1,96 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/animation"; +import { updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks } from "@/timeline"; +import type { + AnimationPath, + AnimationInterpolation, + AnimationValue, +} from "@/animation/types"; + +export class UpsertKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly time: number; + private readonly value: AnimationValue; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + 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: upsertPathKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts new file mode 100644 index 00000000..1445b1f8 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -0,0 +1,131 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + getCustomMaskClosedStateAfterPointRemoval, + removeCustomMaskPoints, +} from "@/masks/custom-path"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function deletePointsFromCustomMask({ + mask, + pointIds, +}: { + mask: CustomMask; + pointIds: string[]; +}): CustomMask { + const points = mask.params.path; + const nextPoints = removeCustomMaskPoints({ points, pointIds }); + if (nextPoints.length === points.length) { + return mask; + } + + return { + ...mask, + params: { + ...mask.params, + path: nextPoints, + closed: getCustomMaskClosedStateAfterPointRemoval({ + wasClosed: mask.params.closed, + remainingPointCount: nextPoints.length, + }), + }, + }; +} + +function deletePointsFromElementMask({ + element, + maskId, + pointIds, +}: { + element: MaskableElement; + maskId: string; + pointIds: string[]; +}): { element: MaskableElement; didDeletePoints: boolean } { + const currentMasks = element.masks ?? []; + let didDeletePoints = false; + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const nextMask = deletePointsFromCustomMask({ + mask, + pointIds, + }); + didDeletePoints ||= nextMask !== mask; + return nextMask; + }); + + return { + element: didDeletePoints ? { ...element, masks: nextMasks } : element, + didDeletePoints, + }; +} + +export class DeleteCustomMaskPointsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly pointIds: string[]; + + constructor({ + trackId, + elementId, + maskId, + pointIds, + }: { + trackId: string; + elementId: string; + maskId: string; + pointIds: string[]; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.pointIds = pointIds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didDeletePoints = false; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = deletePointsFromElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + pointIds: this.pointIds, + }); + didDeletePoints ||= result.didDeletePoints; + return result.element; + }, + }); + + if (didDeletePoints) { + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: null, + }, + }; + } + + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts new file mode 100644 index 00000000..dd6373b1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -0,0 +1,4 @@ +export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts new file mode 100644 index 00000000..58559af6 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -0,0 +1,169 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { CustomMask } from "@/masks/types"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { MaskableElement, SceneTracks } from "@/timeline"; + +function insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, +}: { + mask: CustomMask; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { mask: CustomMask; insertedPointId: string | null } { + const result = insertPointOnCustomMaskSegment({ + params: mask.params, + segmentIndex, + canvasPoint, + bounds, + }); + if (!result) { + return { + mask, + insertedPointId: null, + }; + } + + return { + mask: { + ...mask, + params: result.params, + }, + insertedPointId: result.pointId, + }; +} + +function insertPointIntoElementMask({ + element, + maskId, + segmentIndex, + canvasPoint, + bounds, +}: { + element: MaskableElement; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): { + element: MaskableElement; + didInsertPoint: boolean; + insertedPointId: string | null; +} { + const currentMasks = element.masks ?? []; + let insertedPointId: string | null = null; + let didInsertPoint = false; + + const nextMasks = currentMasks.map((mask) => { + if (mask.id !== maskId || mask.type !== "custom") { + return mask; + } + + const result = insertPointIntoCustomMask({ + mask, + segmentIndex, + canvasPoint, + bounds, + }); + if (result.insertedPointId) { + insertedPointId = result.insertedPointId; + didInsertPoint = true; + } + return result.mask; + }); + + return { + element: didInsertPoint ? { ...element, masks: nextMasks } : element, + didInsertPoint, + insertedPointId, + }; +} + +export class InsertCustomMaskPointCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + private readonly segmentIndex: number; + private readonly canvasPoint: { x: number; y: number }; + private readonly bounds: ElementBounds; + + constructor({ + trackId, + elementId, + maskId, + segmentIndex, + canvasPoint, + bounds, + }: { + trackId: string; + elementId: string; + maskId: string; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + this.segmentIndex = segmentIndex; + this.canvasPoint = canvasPoint; + this.bounds = bounds; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let didInsertPoint = false; + let insertedPointId: string | null = null; + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => { + const result = insertPointIntoElementMask({ + element: element as MaskableElement, + maskId: this.maskId, + segmentIndex: this.segmentIndex, + canvasPoint: this.canvasPoint, + bounds: this.bounds, + }); + didInsertPoint ||= result.didInsertPoint; + insertedPointId ??= result.insertedPointId; + return result.element; + }, + }); + + if (!didInsertPoint || !insertedPointId) { + return undefined; + } + + editor.timeline.updateTracks(updatedTracks); + return { + selection: { + selectedMaskPoints: { + trackId: this.trackId, + elementId: this.elementId, + maskId: this.maskId, + pointIds: [insertedPointId], + }, + }, + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts new file mode 100644 index 00000000..b543bce1 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -0,0 +1,65 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { SceneTracks, MaskableElement } from "@/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: SceneTracks | 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(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + 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); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts new file mode 100644 index 00000000..fec9b923 --- /dev/null +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -0,0 +1,76 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; +import type { Mask } from "@/masks/types"; +import type { SceneTracks, MaskableElement } from "@/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: SceneTracks | 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(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + 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); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/move-elements.ts b/apps/web/src/commands/timeline/element/move-elements.ts new file mode 100644 index 00000000..3ed31d42 --- /dev/null +++ b/apps/web/src/commands/timeline/element/move-elements.ts @@ -0,0 +1,183 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; +import { + buildEmptyTrack, + validateElementTrackCompatibility, +} from "@/timeline/placement"; +import type { + PlannedElementMove, + PlannedTrackCreation, +} from "@/timeline/group-move"; +import { findTrackInSceneTracks } from "@/timeline/track-element-update"; + +export class MoveElementCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor({ + moves, + createTracks = [], + }: { + moves: PlannedElementMove[]; + createTracks?: PlannedTrackCreation[]; + }) { + super(); + this.moves = moves; + this.createTracks = createTracks; + } + + private readonly moves: PlannedElementMove[]; + private readonly createTracks: PlannedTrackCreation[]; + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + let tracksToUpdate = this.savedState; + for (const createTrack of [...this.createTracks].sort( + (firstTrack, secondTrack) => firstTrack.index - secondTrack.index, + )) { + tracksToUpdate = insertTrackAtDisplayIndex({ + tracks: tracksToUpdate, + track: buildEmptyTrack({ + id: createTrack.id, + type: createTrack.type, + }), + insertIndex: createTrack.index, + }); + } + + const movedElementsById = new Map<string, TimelineElement>(); + for (const move of this.moves) { + const sourceTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: move.sourceTrackId, + }); + const sourceElement = sourceTrack?.elements.find( + (trackElement) => trackElement.id === move.elementId, + ); + if (!sourceTrack || !sourceElement) { + throw new Error("Source track or element not found"); + } + + const targetTrack = findTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: move.targetTrackId, + }); + if (!targetTrack) { + throw new Error("Target track not found"); + } + + const validation = validateElementTrackCompatibility({ + element: sourceElement, + track: targetTrack, + }); + if (!validation.isValid) { + throw new Error(validation.errorMessage); + } + + movedElementsById.set(move.elementId, { + ...sourceElement, + startTime: move.newStartTime, + }); + } + + const movedElementIds = new Set(this.moves.map((move) => move.elementId)); + const movedElementsByTargetTrackId = new Map<string, TimelineElement[]>(); + for (const move of this.moves) { + const movedElement = movedElementsById.get(move.elementId); + if (!movedElement) { + continue; + } + + const nextTargetElements = + movedElementsByTargetTrackId.get(move.targetTrackId) ?? []; + nextTargetElements.push(movedElement); + movedElementsByTargetTrackId.set(move.targetTrackId, nextTargetElements); + } + + const updatedTracks = mapSceneTracks({ + tracks: tracksToUpdate, + update: (track) => ({ + ...track, + elements: [ + ...track.elements.filter( + (element) => !movedElementIds.has(element.id), + ), + ...(movedElementsByTargetTrackId.get(track.id) ?? []), + ], + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return createElementSelectionResult( + this.moves.map(({ elementId, targetTrackId }) => ({ + trackId: targetTrackId, + elementId, + })), + ); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} + +function mapSceneTracks({ + tracks, + update, +}: { + tracks: SceneTracks; + update: <TTrack extends TimelineTrack>(track: TTrack) => TTrack; +}): SceneTracks { + return { + overlay: tracks.overlay.map((track) => update(track)), + main: update(tracks.main), + audio: tracks.audio.map((track) => update(track)), + }; +} + +function insertTrackAtDisplayIndex({ + tracks, + track, + insertIndex, +}: { + tracks: SceneTracks; + track: TimelineTrack; + insertIndex: number; +}): SceneTracks { + if (track.type === "audio") { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + track, + ...tracks.audio.slice(audioInsertIndex), + ], + }; + } + + const overlayInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + track, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts new file mode 100644 index 00000000..11c317f4 --- /dev/null +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -0,0 +1,182 @@ +import { + Command, + createElementSelectionResult, + type CommandResult, +} from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { isRetimableElement } from "@/timeline"; +import { splitAnimationsAtTime } from "@/animation"; +import { getSourceSpanAtClipTime } from "@/retime"; + +export class SplitElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private rightSideElements: { trackId: string; elementId: string }[] = []; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly splitTime: number; + private readonly retainSide: "both" | "left" | "right"; + + constructor({ + elements, + splitTime, + retainSide = "both", + }: { + elements: { trackId: string; elementId: string }[]; + splitTime: number; + retainSide?: "both" | "left" | "right"; + }) { + super(); + this.elements = elements; + this.splitTime = splitTime; + this.retainSide = retainSide; + } + + getRightSideElements(): { trackId: string; elementId: string }[] { + return this.rightSideElements; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + this.rightSideElements = []; + + const splitTrack = < + TTrack extends { id: string; elements: TimelineElement[] }, + >( + track: TTrack, + ): TTrack => { + const elementsToSplit = this.elements.filter( + (target) => target.trackId === track.id, + ); + + if (elementsToSplit.length === 0) { + return track; + } + + const elements = track.elements.flatMap((element) => { + const shouldSplit = elementsToSplit.some( + (target) => target.elementId === element.id, + ); + + if (!shouldSplit) { + return [element]; + } + + const effectiveStart = element.startTime; + const effectiveEnd = element.startTime + element.duration; + + if ( + this.splitTime <= effectiveStart || + this.splitTime >= effectiveEnd + ) { + return [element]; + } + + 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, + shouldIncludeSplitBoundary: true, + }); + let splitResult: TimelineElement[]; + + if (this.retainSide === "left") { + splitResult = [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, + name: `${element.name} (left)`, + animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; + } else if (this.retainSide === "right") { + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + splitResult = [ + { + ...element, + id: newId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, + name: `${element.name} (right)`, + animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; + } else { + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + splitResult = [ + { + ...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 } : {}), + }, + ]; + } + + return splitResult; + }); + + return { ...track, elements } as TTrack; + }; + + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => splitTrack(track)), + main: splitTrack(this.savedState.main), + audio: this.savedState.audio.map((track) => splitTrack(track)), + }; + + editor.timeline.updateTracks(updatedTracks); + + if (this.rightSideElements.length > 0) { + return createElementSelectionResult(this.rightSideElements); + } + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts new file mode 100644 index 00000000..36d4cd87 --- /dev/null +++ b/apps/web/src/commands/timeline/element/toggle-source-audio-separation.ts @@ -0,0 +1,145 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import { + buildSeparatedAudioElement, + canExtractSourceAudio, + isSourceAudioSeparated, +} from "@/timeline/audio-separation"; +import { + applyPlacement, + resolveTrackPlacement, +} from "@/timeline/placement"; +import { updateElementInSceneTracks } from "@/timeline/track-element-update"; +import type { + SceneTracks, + TimelineElement, + VideoElement, +} from "@/timeline/types"; +import { generateUUID } from "@/utils/id"; + +export class ToggleSourceAudioSeparationCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor( + private readonly params: { + trackId: string; + elementId: string; + }, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const sourceTrack = [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ].find((track) => track.id === this.params.trackId); + if (!sourceTrack) { + return; + } + const sourceElement = sourceTrack.elements.find( + (element) => element.id === this.params.elementId, + ) as TimelineElement | undefined; + if (!sourceElement || sourceElement.type !== "video") { + return; + } + const videoElement: VideoElement = sourceElement; + + if (isSourceAudioSeparated({ element: videoElement })) { + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: this.savedState, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: true, + }), + ); + return; + } + + const mediaAsset = editor.media + .getAssets() + .find((asset) => asset.id === videoElement.mediaId); + if (!canExtractSourceAudio(videoElement, mediaAsset)) { + return; + } + if (videoElement.duration <= 0) { + return; + } + + const separatedAudioElement = { + ...buildSeparatedAudioElement({ + sourceElement: videoElement, + }), + id: generateUUID(), + }; + const placementResult = resolveTrackPlacement({ + tracks: this.savedState, + trackType: "audio", + timeSpans: [ + { + startTime: separatedAudioElement.startTime, + duration: separatedAudioElement.duration, + }, + ], + strategy: { type: "firstAvailable" }, + }); + if (!placementResult) { + return; + } + const appliedPlacement = applyPlacement({ + tracks: this.savedState, + placementResult, + elements: [separatedAudioElement], + }); + if (!appliedPlacement) { + return; + } + + editor.timeline.updateTracks( + updateSourceAudioEnabled({ + tracks: appliedPlacement.updatedTracks, + trackId: this.params.trackId, + elementId: this.params.elementId, + isSourceAudioEnabled: false, + }), + ); + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} + +function updateSourceAudioEnabled({ + tracks, + trackId, + elementId, + isSourceAudioEnabled, +}: { + tracks: SceneTracks; + trackId: string; + elementId: string; + isSourceAudioEnabled: boolean; +}): SceneTracks { + return updateElementInSceneTracks({ + tracks, + trackId, + elementId, + elementPredicate: (element): element is VideoElement => + element.type === "video", + update: (element) => ({ + ...element, + isSourceAudioEnabled, + }), + }); +} diff --git a/apps/web/src/commands/timeline/element/update-elements.ts b/apps/web/src/commands/timeline/element/update-elements.ts new file mode 100644 index 00000000..f03f2498 --- /dev/null +++ b/apps/web/src/commands/timeline/element/update-elements.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/timeline"; +import { applyElementUpdate } from "@/timeline/update-pipeline"; + +export class UpdateElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly updates: Array<{ + trackId: string; + elementId: string; + patch: Partial<TimelineElement>; + }>; + + constructor({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial<TimelineElement>; + }>; + }) { + super(); + this.updates = updates; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + let updatedTracks = this.savedState; + + for (const updateEntry of this.updates) { + const currentTrack = findTrackInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + }); + const currentElement = currentTrack?.elements.find( + (element) => element.id === updateEntry.elementId, + ); + if (!currentTrack || !currentElement) { + continue; + } + + const nextElement = applyElementUpdate({ + element: currentElement, + patch: updateEntry.patch, + context: { + tracks: updatedTracks, + trackId: updateEntry.trackId, + }, + }); + + updatedTracks = updateElementInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + elementId: updateEntry.elementId, + update: () => nextElement, + }); + } + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/index.ts b/apps/web/src/commands/timeline/index.ts new file mode 100644 index 00000000..cb285d94 --- /dev/null +++ b/apps/web/src/commands/timeline/index.ts @@ -0,0 +1,5 @@ +export * from "./track"; +export * from "./element"; +export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts new file mode 100644 index 00000000..6b58b687 --- /dev/null +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -0,0 +1,115 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks, TrackType } from "@/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { + buildEmptyTrack, + getDefaultInsertIndexForTrack, +} from "@/timeline/placement"; + +export class AddTrackCommand extends Command { + private trackId: string; + private savedState: SceneTracks | null = null; + + constructor( + private type: TrackType, + private index?: number, + ) { + super(); + this.trackId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const insertIndex = + this.index ?? + getDefaultInsertIndexForTrack({ + tracks: this.savedState, + trackType: this.type, + }); + + const updatedTracks = + this.type === "audio" + ? buildAudioTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + }) + : buildOverlayTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + trackType: this.type, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getTrackId(): string { + return this.trackId; + } +} + +function buildAudioTrackState({ + tracks, + insertIndex, + trackId, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; +}): SceneTracks { + const audioInsertIndex = Math.max(0, insertIndex - tracks.overlay.length - 1); + const newTrack = buildEmptyTrack({ + id: trackId, + type: "audio", + }); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + newTrack, + ...tracks.audio.slice(audioInsertIndex), + ], + }; +} + +function buildOverlayTrackState({ + tracks, + insertIndex, + trackId, + trackType, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; + trackType: Exclude<TrackType, "audio">; +}): SceneTracks { + const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length); + const newTrack = + trackType === "video" + ? buildEmptyTrack({ id: trackId, type: "video" }) + : trackType === "text" + ? buildEmptyTrack({ id: trackId, type: "text" }) + : trackType === "graphic" + ? buildEmptyTrack({ id: trackId, type: "graphic" }) + : buildEmptyTrack({ id: trackId, type: "effect" }); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + newTrack, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/commands/timeline/track/index.ts b/apps/web/src/commands/timeline/track/index.ts new file mode 100644 index 00000000..9d4bd9c3 --- /dev/null +++ b/apps/web/src/commands/timeline/track/index.ts @@ -0,0 +1,4 @@ +export { AddTrackCommand } from "./add-track"; +export { RemoveTrackCommand } from "./remove-track"; +export { ToggleTrackMuteCommand } from "./toggle-track-mute"; +export { ToggleTrackVisibilityCommand } from "./toggle-track-visibility"; diff --git a/apps/web/src/commands/timeline/track/remove-track.ts b/apps/web/src/commands/timeline/track/remove-track.ts new file mode 100644 index 00000000..13a9ff4e --- /dev/null +++ b/apps/web/src/commands/timeline/track/remove-track.ts @@ -0,0 +1,30 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks } from "@/timeline"; + +export class RemoveTrackCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + const updatedTracks: SceneTracks = { + ...this.savedState, + overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId), + audio: this.savedState.audio.filter((track) => track.id !== this.trackId), + }; + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/commands/timeline/track/toggle-track-mute.ts new file mode 100644 index 00000000..0d1f41cd --- /dev/null +++ b/apps/web/src/commands/timeline/track/toggle-track-mute.ts @@ -0,0 +1,41 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline"; + +export class ToggleTrackMuteCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => + canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/commands/timeline/track/toggle-track-visibility.ts new file mode 100644 index 00000000..f4160e3f --- /dev/null +++ b/apps/web/src/commands/timeline/track/toggle-track-visibility.ts @@ -0,0 +1,45 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; +import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/timeline"; + +export class ToggleTrackVisibilityCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => { + if (canTrackBeHidden(track)) { + return { ...track, hidden: !track.hidden }; + } + return track; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts new file mode 100644 index 00000000..5b6d871a --- /dev/null +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -0,0 +1,21 @@ +import { Command, type CommandResult } from "@/commands/base-command"; +import type { SceneTracks } from "@/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: SceneTracks, + private after: SceneTracks, + ) { + super(); + } + + execute(): CommandResult | undefined { + EditorCore.getInstance().timeline.updateTracks(this.after); + return undefined; + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} |
