From 1a8a1e889ab313e88fb0bbe6483c001ac751e2d8 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 3 Apr 2026 03:13:49 +0200 Subject: [PATCH 01/26] refactor: update command execute methods to return CommandResult | undefined --- .../src/lib/commands/media/add-media-asset.ts | 6 +- .../lib/commands/media/remove-media-asset.ts | 4 +- .../project/update-project-settings.ts | 4 +- .../src/lib/commands/scene/create-scene.ts | 81 ++--- .../src/lib/commands/scene/delete-scene.ts | 4 +- .../src/lib/commands/scene/move-bookmark.ts | 4 +- .../src/lib/commands/scene/remove-bookmark.ts | 4 +- .../src/lib/commands/scene/rename-scene.ts | 4 +- .../src/lib/commands/scene/toggle-bookmark.ts | 4 +- .../src/lib/commands/scene/update-bookmark.ts | 4 +- .../lib/commands/timeline/clipboard/paste.ts | 3 +- .../timeline/element/delete-elements.ts | 2 +- .../timeline/element/duplicate-elements.ts | 1 + .../timeline/element/effects/add-effect.ts | 149 ++++----- .../timeline/element/effects/remove-effect.ts | 131 ++++---- .../element/effects/reorder-effect.ts | 147 ++++----- .../timeline/element/effects/toggle-effect.ts | 135 ++++---- .../element/effects/update-effect-params.ts | 5 +- .../lib/commands/timeline/element/index.ts | 1 + .../timeline/element/insert-element.ts | 4 +- .../keyframes/remove-effect-param-keyframe.ts | 137 +++++---- .../element/keyframes/remove-keyframe.ts | 277 ++++++++--------- .../element/keyframes/retime-keyframe.ts | 149 ++++----- .../keyframes/upsert-effect-param-keyframe.ts | 169 +++++----- .../element/keyframes/upsert-keyframe.ts | 191 ++++++------ .../timeline/element/masks/remove-mask.ts | 129 ++++---- .../element/masks/toggle-mask-inverted.ts | 151 ++++----- .../timeline/element/move-elements.ts | 291 +++++++++--------- .../element/retime/update-element-retime.ts | 229 +++++++------- .../timeline/element/split-elements.ts | 1 + .../timeline/element/toggle-elements-muted.ts | 4 +- .../element/toggle-elements-visibility.ts | 5 +- .../element/toggle-source-audio-separation.ts | 11 +- .../element/update-element-duration.ts | 115 +++---- .../element/update-element-start-time.ts | 139 ++++----- .../timeline/element/update-element-trim.ts | 5 +- .../timeline/element/update-element.ts | 95 +++--- .../lib/commands/timeline/track/add-track.ts | 107 +++---- .../commands/timeline/track/remove-track.ts | 4 +- .../timeline/track/toggle-track-mute.ts | 4 +- .../timeline/track/toggle-track-visibility.ts | 4 +- .../lib/commands/timeline/tracks-snapshot.ts | 41 +-- 42 files changed, 1492 insertions(+), 1463 deletions(-) diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index 6f54ee72..c12d7ea1 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { toast } from "sonner"; import type { MediaAsset } from "@/lib/media/types"; @@ -23,7 +23,7 @@ export class AddMediaAssetCommand extends Command { this.assetId = generateUUID(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedAssets = [...editor.media.getAssets()]; @@ -80,6 +80,8 @@ export class AddMediaAssetCommand extends Command { }); } }); + + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts index 84b395ed..1a938de7 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { MediaAsset } from "@/lib/media/types"; import { storageService } from "@/services/storage/service"; @@ -18,7 +18,7 @@ export class RemoveMediaAssetCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const assets = editor.media.getAssets(); diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts index 8320095b..b4cbb125 100644 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ b/apps/web/src/lib/commands/project/update-project-settings.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TProject, TProjectSettings } from "@/lib/project/types"; @@ -10,7 +10,7 @@ export class UpdateProjectSettingsCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeProject = editor.project.getActive(); if (!activeProject) return; diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts index 7675570d..1cc72c0f 100644 --- a/apps/web/src/lib/commands/scene/create-scene.ts +++ b/apps/web/src/lib/commands/scene/create-scene.ts @@ -1,40 +1,41 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { buildDefaultScene } from "@/lib/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(): void { - 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 }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } - - getSceneId(): string { - return this.createdScene?.id ?? ""; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { buildDefaultScene } from "@/lib/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/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts index da8aac24..1c433687 100644 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ b/apps/web/src/lib/commands/scene/delete-scene.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TScene } from "@/lib/timeline"; import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; @@ -12,7 +12,7 @@ export class DeleteSceneCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const scenes = editor.scenes.getScenes(); const activeScene = editor.scenes.getActiveScene(); diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts index 7b5b5aac..f9b0664c 100644 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; @@ -14,7 +14,7 @@ export class MoveBookmarkCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); const activeProject = editor.project.getActive(); diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts index c5f8692c..cee41d36 100644 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; @@ -15,7 +15,7 @@ export class RemoveBookmarkCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); const activeProject = editor.project.getActive(); diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts index 554e3940..06d0414f 100644 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ b/apps/web/src/lib/commands/scene/rename-scene.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; @@ -14,7 +14,7 @@ export class RenameSceneCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const scenes = editor.scenes.getScenes(); diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts index 69fe3213..5df599ad 100644 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; @@ -12,7 +12,7 @@ export class ToggleBookmarkCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); const activeProject = editor.project.getActive(); diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts index 2e320b3d..f383ab5d 100644 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { Bookmark, TScene } from "@/lib/timeline"; import { updateSceneInArray } from "@/lib/scenes"; @@ -14,7 +14,7 @@ export class UpdateBookmarkCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); const activeScene = editor.scenes.getActiveScene(); const activeProject = editor.project.getActive(); diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 59e1af1c..9fa4b83b 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -33,7 +33,7 @@ export class PasteCommand extends Command { } execute(): CommandResult | undefined { - if (this.clipboardItems.length === 0) return; + if (this.clipboardItems.length === 0) return undefined; const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); @@ -123,6 +123,7 @@ export class PasteCommand extends Command { if (this.pastedElements.length > 0) { return { select: this.pastedElements }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 9177a3f8..1e1ad661 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -20,7 +20,7 @@ export class DeleteElementsCommand extends Command { this.rippleEnabled = rippleEnabled; } - execute(): CommandResult { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index ad31ae9c..35be3da2 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -91,6 +91,7 @@ export class DuplicateElementsCommand extends Command { select: this.duplicatedElements, }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts index 3d27b345..0c117f3b 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -1,74 +1,75 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; -import { buildDefaultEffectInstance } from "@/lib/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: TimelineTrack[] | 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getEffectId(): string | null { - return this.effectId; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import { buildDefaultEffectInstance } from "@/lib/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: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts index 88b445f3..f876a1d1 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -1,65 +1,66 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts index af211a29..e63430be 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -1,73 +1,74 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts index 2e55b676..0329f1a0 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -1,67 +1,68 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/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: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts index c3443c22..058706ec 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; import type { ParamValues } from "@/lib/params"; @@ -56,7 +56,7 @@ export class UpdateClipEffectParamsCommand extends Command { this.params = params; } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); @@ -75,6 +75,7 @@ export class UpdateClipEffectParamsCommand extends Command { }); editor.timeline.updateTracks(updatedTracks); + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 5ce48d55..60e12ddd 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -10,6 +10,7 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; 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/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 2119d219..fbef1613 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { CreateTimelineElement, @@ -42,7 +42,7 @@ export class InsertElementCommand extends Command { private element: CreateTimelineElement; private placement: InsertElementPlacement; - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts index aafa3c22..36f8a6b1 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -1,68 +1,69 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RemoveEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/lib/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index 56676aa5..b5c7550d 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,138 +1,139 @@ -import { EditorCore } from "@/core"; -import { - getChannel, - getChannelValueAtTime, - removeElementKeyframe, - resolveAnimationTarget, -} from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; - -function sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): AnimationValue | null { - const channel = getChannel({ - animations: element.animations, - propertyPath, - }); - const keyframe = channel?.keyframes.find( - (candidate) => candidate.id === keyframeId, - ); - if (!channel || !keyframe) { - return null; - } - - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return null; - } - const baseValue = target.getBaseValue(); - if (baseValue === null) { - return null; - } - - return getChannelValueAtTime({ - channel, - time: keyframe.time, - fallbackValue: baseValue, - }); -} - -function removeKeyframeAndPersist({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): TimelineElement { - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return element; - } - - const valueBefore = sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, - }); - - const nextAnimations = removeElementKeyframe({ - animations: element.animations, - propertyPath, - keyframeId, - }); - - const isChannelNowEmpty = - getChannel({ animations: nextAnimations, propertyPath }) === undefined; - const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; - - const baseElement = shouldPersistToBase - ? target.setBaseValue(valueBefore) - : element; - - return { ...baseElement, animations: nextAnimations }; -} - -export class RemoveKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => - removeKeyframeAndPersist({ - element, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { + getChannel, + getChannelValueAtTime, + removeElementKeyframe, + resolveAnimationTarget, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; + +function sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; +}): AnimationValue | null { + const channel = getChannel({ + animations: element.animations, + propertyPath, + }); + const keyframe = channel?.keyframes.find( + (candidate) => candidate.id === keyframeId, + ); + if (!channel || !keyframe) { + return null; + } + + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return null; + } + const baseValue = target.getBaseValue(); + if (baseValue === null) { + return null; + } + + return getChannelValueAtTime({ + channel, + time: keyframe.time, + fallbackValue: baseValue, + }); +} + +function removeKeyframeAndPersist({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; +}): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + + const valueBefore = sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, + }); + + const nextAnimations = removeElementKeyframe({ + animations: element.animations, + propertyPath, + keyframeId, + }); + + const isChannelNowEmpty = + getChannel({ animations: nextAnimations, propertyPath }) === undefined; + const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; + + const baseElement = shouldPersistToBase + ? target.setBaseValue(valueBefore) + : element; + + return { ...baseElement, animations: nextAnimations }; +} + +export class RemoveKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + removeKeyframeAndPersist({ + element, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + }), + }); + + 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/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts index 7818ed0d..89491823 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,74 +1,75 @@ -import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPath } from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RetimeKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { AnimationPath } from "@/lib/animation/types"; +import type { TimelineTrack } from "@/lib/timeline"; + +export class RetimeKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 8e541b72..edbf5198 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -1,84 +1,85 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class UpsertEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | 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; - private readonly interpolation: "linear" | "hold" | 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; - interpolation?: "linear" | "hold"; - 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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 animations = upsertEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { TimelineTrack } from "@/lib/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: TimelineTrack[] | 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; + private readonly interpolation: "linear" | "hold" | 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; + interpolation?: "linear" | "hold"; + 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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 animations = upsertEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + 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/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 4b697705..98589987 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,95 +1,96 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { - AnimationPath, - AnimationInterpolation, - AnimationValue, -} from "@/lib/animation/types"; - -export class UpsertKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: 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(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - 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, - valueKind: target.valueKind, - defaultInterpolation: target.defaultInterpolation, - numericRange: target.numericRange, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; +import type { + AnimationPath, + AnimationInterpolation, + AnimationValue, +} from "@/lib/animation/types"; + +export class UpsertKeyframeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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, + valueKind: target.valueKind, + defaultInterpolation: target.defaultInterpolation, + numericRange: target.numericRange, + }), + }; + }, + }); + + 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/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts index 71c8b413..b0daced7 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -1,64 +1,65 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; - -function removeMaskFromElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); - return { ...element, masks: filteredMasks }; -} - -export class RemoveMaskCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts index cfa02842..bc812398 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -1,75 +1,76 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; -import type { Mask } from "@/lib/masks/types"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; - -export function toggleMaskInvertedOnElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const toggleMask = (mask: TMask): TMask => ({ - ...mask, - params: { - ...mask.params, - inverted: !mask.params.inverted, - }, - }); - const updatedMasks = currentMasks.map((mask) => - mask.id !== maskId ? mask : toggleMask(mask), - ); - - return { ...element, masks: updatedMasks }; -} - -export class ToggleMaskInvertedCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { Mask } from "@/lib/masks/types"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = (mask: TMask): TMask => ({ + ...mask, + params: { + ...mask.params, + inverted: !mask.params.inverted, + }, + }); + const updatedMasks = currentMasks.map((mask) => + mask.id !== maskId ? mask : toggleMask(mask), + ); + + return { ...element, masks: updatedMasks }; +} + +export class ToggleMaskInvertedCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + toggleMaskInvertedOnElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 6d400964..e944ca81 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -1,145 +1,146 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - TimelineTrack, - TimelineElement, - TrackType, -} from "@/lib/timeline"; -import { - buildEmptyTrack, - validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/placement"; -import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; - -export class MoveElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly sourceTrackId: string; - private readonly targetTrackId: string; - private readonly elementId: string; - private readonly newStartTime: number; - private readonly createTrack: { type: TrackType; index: number } | undefined; - private readonly rippleEnabled: boolean; - - constructor({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, - rippleEnabled = false, - }: { - sourceTrackId: string; - targetTrackId: string; - elementId: string; - newStartTime: number; - createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; - }) { - super(); - this.sourceTrackId = sourceTrackId; - this.targetTrackId = targetTrackId; - this.elementId = elementId; - this.newStartTime = newStartTime; - this.createTrack = createTrack; - this.rippleEnabled = rippleEnabled; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const sourceTrack = this.savedState.find( - (track) => track.id === this.sourceTrackId, - ); - const element = sourceTrack?.elements.find( - (trackElement) => trackElement.id === this.elementId, - ); - - if (!sourceTrack || !element) { - throw new Error("Source track or element not found"); - } - - let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); - let tracksToUpdate = this.savedState; - if (!targetTrack && this.createTrack) { - const newTrack = buildEmptyTrack({ - id: this.targetTrackId, - type: this.createTrack.type, - }); - tracksToUpdate = [...this.savedState]; - tracksToUpdate.splice(this.createTrack.index, 0, newTrack); - targetTrack = newTrack; - } - if (!targetTrack) { - throw new Error("Target track not found"); - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - throw new Error(validation.errorMessage); - } - - const adjustedStartTime = enforceMainTrackStart({ - tracks: tracksToUpdate, - targetTrackId: this.targetTrackId, - requestedStartTime: this.newStartTime, - excludeElementId: this.elementId, - }); - - // keyframe times remain clip-local, so moving only changes element startTime. - const movedElement: TimelineElement = { - ...element, - startTime: adjustedStartTime, - }; - - const isSameTrack = this.sourceTrackId === this.targetTrackId; - - const updatedTracks = tracksToUpdate.map((track): TimelineTrack => { - if (isSameTrack && track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.map((trackElement) => - trackElement.id === this.elementId ? movedElement : trackElement, - ), - } as typeof track; - } - - if (track.id === this.sourceTrackId) { - const remainingElements = track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ); - const shiftedElements = this.rippleEnabled - ? rippleShiftElements({ - elements: remainingElements, - afterTime: element.startTime, - shiftAmount: element.duration, - }) - : remainingElements; - return { ...track, elements: shiftedElements } as typeof track; - } - - if (track.id === this.targetTrackId) { - return { - ...track, - elements: [...track.elements, movedElement], - } as typeof track; - } - - return track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + TimelineTrack, + TimelineElement, + TrackType, +} from "@/lib/timeline"; +import { + buildEmptyTrack, + validateElementTrackCompatibility, + enforceMainTrackStart, +} from "@/lib/timeline/placement"; +import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; + +export class MoveElementCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly sourceTrackId: string; + private readonly targetTrackId: string; + private readonly elementId: string; + private readonly newStartTime: number; + private readonly createTrack: { type: TrackType; index: number } | undefined; + private readonly rippleEnabled: boolean; + + constructor({ + sourceTrackId, + targetTrackId, + elementId, + newStartTime, + createTrack, + rippleEnabled = false, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + rippleEnabled?: boolean; + }) { + super(); + this.sourceTrackId = sourceTrackId; + this.targetTrackId = targetTrackId; + this.elementId = elementId; + this.newStartTime = newStartTime; + this.createTrack = createTrack; + this.rippleEnabled = rippleEnabled; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const sourceTrack = this.savedState.find( + (track) => track.id === this.sourceTrackId, + ); + const element = sourceTrack?.elements.find( + (trackElement) => trackElement.id === this.elementId, + ); + + if (!sourceTrack || !element) { + throw new Error("Source track or element not found"); + } + + let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); + let tracksToUpdate = this.savedState; + if (!targetTrack && this.createTrack) { + const newTrack = buildEmptyTrack({ + id: this.targetTrackId, + type: this.createTrack.type, + }); + tracksToUpdate = [...this.savedState]; + tracksToUpdate.splice(this.createTrack.index, 0, newTrack); + targetTrack = newTrack; + } + if (!targetTrack) { + throw new Error("Target track not found"); + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + + if (!validation.isValid) { + throw new Error(validation.errorMessage); + } + + const adjustedStartTime = enforceMainTrackStart({ + tracks: tracksToUpdate, + targetTrackId: this.targetTrackId, + requestedStartTime: this.newStartTime, + excludeElementId: this.elementId, + }); + + // keyframe times remain clip-local, so moving only changes element startTime. + const movedElement: TimelineElement = { + ...element, + startTime: adjustedStartTime, + }; + + const isSameTrack = this.sourceTrackId === this.targetTrackId; + + const updatedTracks = tracksToUpdate.map((track): TimelineTrack => { + if (isSameTrack && track.id === this.sourceTrackId) { + return { + ...track, + elements: track.elements.map((trackElement) => + trackElement.id === this.elementId ? movedElement : trackElement, + ), + } as typeof track; + } + + if (track.id === this.sourceTrackId) { + const remainingElements = track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ); + const shiftedElements = this.rippleEnabled + ? rippleShiftElements({ + elements: remainingElements, + afterTime: element.startTime, + shiftAmount: element.duration, + }) + : remainingElements; + return { ...track, elements: shiftedElements } as typeof track; + } + + if (track.id === this.targetTrackId) { + return { + ...track, + elements: [...track.elements, movedElement], + } as typeof track; + } + + return track; + }); + + 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/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts index f0574ce7..474396a1 100644 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts @@ -1,114 +1,115 @@ -import { EditorCore } from "@/core"; -import { clampRetimeRate } from "@/lib/retime/rate"; -import { clampAnimationsToDuration } from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; -import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; -import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; - -function getSourceDuration({ - trimStart, - trimEnd, - duration, - sourceDuration, - retime, -}: { - trimStart: number; - trimEnd: number; - duration: number; - sourceDuration?: number; - retime?: RetimeConfig; -}): number { - if (typeof sourceDuration === "number") { - return sourceDuration; - } - - return ( - trimStart + - getSourceSpanAtClipTime({ - clipTime: duration, - retime, - }) + - trimEnd - ); -} - -export class UpdateElementRetimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly retime: RetimeConfig | undefined; - - constructor({ - trackId, - elementId, - retime, - }: { - trackId: string; - elementId: string; - retime?: RetimeConfig; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.retime = retime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isRetimableElement, - update: (element) => { - if (!isRetimableElement(element)) { - return element; - } - - const nextRetime = this.retime - ? { - ...this.retime, - rate: clampRetimeRate({ rate: this.retime.rate }), - } - : undefined; - const sourceDuration = getSourceDuration({ - trimStart: element.trimStart, - trimEnd: element.trimEnd, - duration: element.duration, - sourceDuration: element.sourceDuration, - retime: element.retime, - }); - const visibleSourceSpan = Math.max( - 0, - sourceDuration - element.trimStart - element.trimEnd, - ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, - }); - - return { - ...element, - retime: nextRetime, - duration: nextDuration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: nextDuration, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { EditorCore } from "@/core"; +import { clampRetimeRate } from "@/lib/retime/rate"; +import { clampAnimationsToDuration } from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; +import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; +import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} + +export class UpdateElementRetimeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly retime: RetimeConfig | undefined; + + constructor({ + trackId, + elementId, + retime, + }: { + trackId: string; + elementId: string; + retime?: RetimeConfig; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.retime = retime; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isRetimableElement, + update: (element) => { + if (!isRetimableElement(element)) { + return element; + } + + const nextRetime = this.retime + ? { + ...this.retime, + rate: clampRetimeRate({ rate: this.retime.rate }), + } + : undefined; + const sourceDuration = getSourceDuration({ + trimStart: element.trimStart, + trimEnd: element.trimEnd, + duration: element.duration, + sourceDuration: element.sourceDuration, + retime: element.retime, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + ...element, + retime: nextRetime, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: nextDuration, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index e9742906..250a1ce6 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -175,6 +175,7 @@ export class SplitElementsCommand extends Command { select: this.rightSideElements, }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts index 30310806..75b77562 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; @@ -10,7 +10,7 @@ export class ToggleElementsMutedCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts index 690f61aa..271b0a00 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { canElementBeHidden } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; @@ -10,7 +10,7 @@ export class ToggleElementsVisibilityCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); @@ -36,6 +36,7 @@ export class ToggleElementsVisibilityCommand extends Command { }); editor.timeline.updateTracks(updatedTracks); + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts index d0907c5b..c14a5a42 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -1,5 +1,5 @@ import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { buildSeparatedAudioElement, canExtractSourceAudio, @@ -25,7 +25,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); @@ -43,6 +43,9 @@ export class ToggleSourceAudioSeparationCommand extends Command { if (!sourceElement) { return; } + if (sourceElement.type !== "video") { + return; + } if (canRecoverSourceAudio({ element: sourceElement })) { editor.timeline.updateTracks( @@ -59,9 +62,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { const mediaAsset = editor .media .getAssets() - .find((asset) => - sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false, - ); + .find((asset) => asset.id === sourceElement.mediaId); if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { return; } diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts index 4280dd4d..7ad62000 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -1,57 +1,58 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; - -export class UpdateElementDurationCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly duration: number; - - constructor({ - trackId, - elementId, - duration, - }: { - trackId: string; - elementId: string; - duration: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.duration = duration; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - if (track.id !== this.trackId) return track; - const newElements = track.elements.map((element) => - element.id === this.elementId - ? { - ...element, - duration: this.duration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: this.duration, - }), - } - : element, - ); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { clampAnimationsToDuration } from "@/lib/animation"; + +export class UpdateElementDurationCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly duration: number; + + constructor({ + trackId, + elementId, + duration, + }: { + trackId: string; + elementId: string; + duration: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.duration = duration; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = this.savedState.map((track) => { + if (track.id !== this.trackId) return track; + const newElements = track.elements.map((element) => + element.id === this.elementId + ? { + ...element, + duration: this.duration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: this.duration, + }), + } + : element, + ); + return { ...track, elements: newElements } as typeof track; + }); + + 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/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index ff81cff2..6232d539 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,69 +1,70 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; - -export class UpdateElementStartTimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - private readonly startTime: number; - - constructor({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }) { - super(); - this.elements = elements; - this.startTime = startTime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const currentTracks = this.savedState; - const updatedTracks = currentTracks.map((track) => { - const hasElementsToUpdate = this.elements.some( - (elementEntry) => elementEntry.trackId === track.id, - ); - - if (!hasElementsToUpdate) { - return track; - } - - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - (elementEntry) => - elementEntry.elementId === element.id && - elementEntry.trackId === track.id, - ); - if (!shouldUpdate) { - return element; - } - - const baseStartTime = Math.max(0, this.startTime); - const adjustedStartTime = enforceMainTrackStart({ - tracks: currentTracks, - targetTrackId: track.id, - requestedStartTime: baseStartTime, - excludeElementId: element.id, - }); - - return { ...element, startTime: adjustedStartTime }; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { enforceMainTrackStart } from "@/lib/timeline/placement"; + +export class UpdateElementStartTimeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly elements: { trackId: string; elementId: string }[]; + private readonly startTime: number; + + constructor({ + elements, + startTime, + }: { + elements: { trackId: string; elementId: string }[]; + startTime: number; + }) { + super(); + this.elements = elements; + this.startTime = startTime; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const currentTracks = this.savedState; + const updatedTracks = currentTracks.map((track) => { + const hasElementsToUpdate = this.elements.some( + (elementEntry) => elementEntry.trackId === track.id, + ); + + if (!hasElementsToUpdate) { + return track; + } + + const newElements = track.elements.map((element) => { + const shouldUpdate = this.elements.some( + (elementEntry) => + elementEntry.elementId === element.id && + elementEntry.trackId === track.id, + ); + if (!shouldUpdate) { + return element; + } + + const baseStartTime = Math.max(0, this.startTime); + const adjustedStartTime = enforceMainTrackStart({ + tracks: currentTracks, + targetTrackId: track.id, + requestedStartTime: baseStartTime, + excludeElementId: element.id, + }); + + return { ...element, startTime: adjustedStartTime }; + }); + return { ...track, elements: newElements } as typeof track; + }); + + 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/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index 13ee66dd..e3cb596b 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; @@ -38,7 +38,7 @@ export class UpdateElementTrimCommand extends Command { this.rippleEnabled = rippleEnabled; } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); @@ -104,6 +104,7 @@ export class UpdateElementTrimCommand extends Command { }); editor.timeline.updateTracks(updatedTracks); + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index 494fa1f6..45b2603f 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -1,47 +1,48 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { updateElementInTracks } from "@/lib/timeline"; - -export class UpdateElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly updates: Partial; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.updates = updates; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => ({ ...element, ...this.updates }) as TimelineElement, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { updateElementInTracks } from "@/lib/timeline"; + +export class UpdateElementCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly updates: Partial; + + constructor({ + trackId, + elementId, + updates, + }: { + trackId: string; + elementId: string; + updates: Partial; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.updates = updates; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => ({ ...element, ...this.updates }) as TimelineElement, + }); + + 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/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index 104c0462..29bdbb0a 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,53 +1,54 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getDefaultInsertIndexForTrack, -} from "@/lib/timeline/placement"; - -export class AddTrackCommand extends Command { - private trackId: string; - private savedState: TimelineTrack[] | null = null; - - constructor( - private type: TrackType, - private index?: number, - ) { - super(); - this.trackId = generateUUID(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const newTrack: TimelineTrack = buildEmptyTrack({ - id: this.trackId, - type: this.type, - }); - - const updatedTracks = [...(this.savedState || [])]; - const insertIndex = - this.index ?? - getDefaultInsertIndexForTrack({ - tracks: updatedTracks, - trackType: this.type, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getTrackId(): string { - return this.trackId; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { + buildEmptyTrack, + getDefaultInsertIndexForTrack, +} from "@/lib/timeline/placement"; + +export class AddTrackCommand extends Command { + private trackId: string; + private savedState: TimelineTrack[] | null = null; + + constructor( + private type: TrackType, + private index?: number, + ) { + super(); + this.trackId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const newTrack: TimelineTrack = buildEmptyTrack({ + id: this.trackId, + type: this.type, + }); + + const updatedTracks = [...(this.savedState || [])]; + const insertIndex = + this.index ?? + getDefaultInsertIndexForTrack({ + tracks: updatedTracks, + trackType: this.type, + }); + updatedTracks.splice(insertIndex, 0, newTrack); + + 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; + } +} diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index f35edbe4..c2ea7ef0 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { TimelineTrack } from "@/lib/timeline"; import { getMainTrack } from "@/lib/timeline/placement"; @@ -10,7 +10,7 @@ export class RemoveTrackCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); const targetTrack = this.savedState.find( diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 0094f1de..513fffc7 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTrackHaveAudio } from "@/lib/timeline"; @@ -10,7 +10,7 @@ export class ToggleTrackMuteCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts index 6d74e238..077c21db 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -1,4 +1,4 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTrackBeHidden } from "@/lib/timeline"; @@ -10,7 +10,7 @@ export class ToggleTrackVisibilityCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.timeline.getTracks(); diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index d397431c..2493f19b 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,20 +1,21 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; - -export class TracksSnapshotCommand extends Command { - constructor( - private before: TimelineTrack[], - private after: TimelineTrack[], - ) { - super(); - } - - execute(): void { - EditorCore.getInstance().timeline.updateTracks(this.after); - } - - undo(): void { - EditorCore.getInstance().timeline.updateTracks(this.before); - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineTrack } from "@/lib/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: TimelineTrack[], + private after: TimelineTrack[], + ) { + super(); + } + + execute(): CommandResult | undefined { + EditorCore.getInstance().timeline.updateTracks(this.after); + return undefined; + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} From 22725f71faae51e84e02dfd240fc30e10f180238 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 3 Apr 2026 03:37:41 +0200 Subject: [PATCH 02/26] chore: update opencut-wasm to 0.1.3 --- Cargo.lock | 2 +- apps/web/package.json | 2 +- bun.lock | 4 ++-- rust/wasm/Cargo.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a34d0ea..d2a3fd4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3777,7 +3777,7 @@ dependencies = [ [[package]] name = "opencut-wasm" -version = "0.1.2" +version = "0.1.3" dependencies = [ "gpu", "js-sys", diff --git a/apps/web/package.json b/apps/web/package.json index f765216c..0317d3b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.1.2", + "opencut-wasm": "^0.1.3", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/bun.lock b/bun.lock index ef674788..c3f1f850 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.1.2", + "opencut-wasm": "^0.1.3", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", @@ -1359,7 +1359,7 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], - "opencut-wasm": ["opencut-wasm@0.1.2", "", {}, "sha512-3OZ7JYwFNFijqQIFBgrqzyD+0V5yG4KPJtRPR76UcqM3Ai3y2GXdp7IlDL7NG3IoQ4C5yX2LzKl2pr4FbDvuFQ=="], + "opencut-wasm": ["opencut-wasm@0.1.3", "", {}, "sha512-3MlWL8J8NCRBfm/6LrdvW08rgIPqyhk5dCG3j/zokKDOf5mwULox9Vpqi2jgZ+dSKIWReHuUCZq2b0cGCFuVHQ=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index 06be926f..a5b0d9e0 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencut-wasm" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Shared video editor logic compiled to WebAssembly" repository = "https://github.com/opencut/opencut" From 78c6c497f10e477aab350e2c84beb29c3904aab9 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 08:53:10 +0200 Subject: [PATCH 03/26] chore: remove transcription envs --- .github/workflows/bun-ci.yml | 5 ----- apps/web/.env.example | 7 ------- apps/web/Dockerfile | 5 ----- apps/web/src/lib/env/web.ts | 5 ----- docker-compose.yml | 6 ------ turbo.json | 7 +------ 6 files changed, 1 insertion(+), 34 deletions(-) diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index 58198586..386b42bc 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -31,11 +31,6 @@ jobs: MARBLE_WORKSPACE_KEY: "placeholder" FREESOUND_CLIENT_ID: "placeholder" FREESOUND_API_KEY: "placeholder" - CLOUDFLARE_ACCOUNT_ID: "placeholder" - R2_ACCESS_KEY_ID: "placeholder" - R2_SECRET_ACCESS_KEY: "placeholder" - R2_BUCKET_NAME: "placeholder" - MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com" steps: - name: Checkout repository diff --git a/apps/web/.env.example b/apps/web/.env.example index 48d48b1d..b15779a5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here FREESOUND_CLIENT_ID=your_client_id_here FREESOUND_API_KEY=your_api_key_here - -CLOUDFLARE_ACCOUNT_ID=your_account_id_here -R2_ACCESS_KEY_ID=your_access_key_here -R2_SECRET_ACCESS_KEY=your_secret_key_here -R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket - -MODAL_TRANSCRIPTION_URL=your_modal_url_here \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 34b63e3c..a08d9b68 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token" ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000" ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY -ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder" -ENV R2_ACCESS_KEY_ID="build-placeholder" -ENV R2_SECRET_ACCESS_KEY="build-placeholder" -ENV R2_BUCKET_NAME="build-placeholder" -ENV MODAL_TRANSCRIPTION_URL="http://localhost:0" ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID ENV FREESOUND_API_KEY=$FREESOUND_API_KEY diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts index 591b36ad..aa051615 100644 --- a/apps/web/src/lib/env/web.ts +++ b/apps/web/src/lib/env/web.ts @@ -23,11 +23,6 @@ const webEnvSchema = z.object({ MARBLE_WORKSPACE_KEY: z.string(), FREESOUND_CLIENT_ID: z.string(), FREESOUND_API_KEY: z.string(), - CLOUDFLARE_ACCOUNT_ID: z.string(), - R2_ACCESS_KEY_ID: z.string(), - R2_SECRET_ACCESS_KEY: z.string(), - R2_BUCKET_NAME: z.string(), - MODAL_TRANSCRIPTION_URL: z.url(), }); export type WebEnv = z.infer; diff --git a/docker-compose.yml b/docker-compose.yml index a20c2654..0c4983b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,12 +70,6 @@ services: - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} - FREESOUND_API_KEY=${FREESOUND_API_KEY} - # Transcription (Optional - leave blank to disable auto-captions) - - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder} - - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder} - - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder} - - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription} - - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0} depends_on: db: condition: service_healthy diff --git a/turbo.json b/turbo.json index 244b2f95..f1431ec3 100644 --- a/turbo.json +++ b/turbo.json @@ -17,12 +17,7 @@ "UPSTASH_REDIS_REST_TOKEN", "MARBLE_WORKSPACE_KEY", "FREESOUND_CLIENT_ID", - "FREESOUND_API_KEY", - "CLOUDFLARE_ACCOUNT_ID", - "R2_ACCESS_KEY_ID", - "R2_SECRET_ACCESS_KEY", - "R2_BUCKET_NAME", - "MODAL_TRANSCRIPTION_URL" + "FREESOUND_API_KEY" ] }, "check-types": { From cb99925d7519a7a0e6b3c9d4918aa18f49192829 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 11:58:55 +0200 Subject: [PATCH 04/26] refactor: swap out manual tabs withthe shared tabs component in stickers --- .../editor/panels/assets/views/stickers.tsx | 49 +++++++------------ 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index 285b37d3..5b076466 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -8,6 +8,7 @@ import { DraggableItem } from "@/components/editor/panels/assets/draggable-item" import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useEditor } from "@/hooks/use-editor"; import { resolveStickerIntrinsicSize } from "@/lib/stickers"; import { @@ -70,41 +71,25 @@ export function StickersView() { /> -
-
-
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - - ); - })} -
-
+ { + setSelectedCategory({ category: value as StickerCategory }); + }} + variant="underline" + className="mt-2 flex min-h-0 flex-1 flex-col" + > + + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( + + {label} + + ))} +
-
+ ); } From 69da5bee0c50f331ec59a510d0cf0d45779bab31 Mon Sep 17 00:00:00 2001 From: Maze Date: Sat, 4 Apr 2026 11:59:04 +0200 Subject: [PATCH 05/26] style: tabs --- apps/web/src/components/ui/tabs.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index e436e876..4fb028c8 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef< Date: Sun, 5 Apr 2026 13:15:58 +0200 Subject: [PATCH 06/26] refactor: rewrite animation system to use bindings and scalar channels --- .../hooks/use-keyframed-param-property.ts | 298 ++-- .../panels/timeline/timeline-element.tsx | 5 +- .../panels/timeline/timeline-toolbar.tsx | 38 +- .../src/hooks/actions/use-editor-actions.ts | 7 +- .../timeline/element/use-keyframe-drag.ts | 10 +- apps/web/src/hooks/use-transform-handles.ts | 1248 +++++++-------- .../__tests__/binding-values.test.ts | 26 + .../__tests__/keyframe-query.test.ts | 139 ++ apps/web/src/lib/animation/bezier.ts | 88 ++ apps/web/src/lib/animation/binding-values.ts | 335 ++++ apps/web/src/lib/animation/color-channel.ts | 19 - .../src/lib/animation/effect-param-channel.ts | 80 +- .../lib/animation/graphic-param-channel.ts | 150 +- apps/web/src/lib/animation/index.ts | 152 +- apps/web/src/lib/animation/interpolation.ts | 541 ++++--- apps/web/src/lib/animation/keyframe-query.ts | 370 ++++- apps/web/src/lib/animation/keyframes.ts | 1358 +++++++++++------ apps/web/src/lib/animation/number-channel.ts | 20 - .../src/lib/animation/property-registry.ts | 746 ++++----- apps/web/src/lib/animation/resolve.ts | 309 ++-- apps/web/src/lib/animation/target-resolver.ts | 562 +++---- apps/web/src/lib/animation/types.ts | 279 ++-- apps/web/src/lib/animation/vector-channel.ts | 72 - .../element/keyframes/remove-keyframe.ts | 38 +- .../keyframes/upsert-effect-param-keyframe.ts | 33 +- .../element/keyframes/upsert-keyframe.ts | 4 +- .../element/toggle-source-audio-separation.ts | 4 +- apps/web/src/lib/masks/__tests__/snap.test.ts | 472 +++--- .../audio-separation/__tests__/index.test.ts | 60 +- .../lib/timeline/audio-separation/index.ts | 56 +- .../migrations/__tests__/v21-to-v22.test.ts | 241 +++ .../migrations/__tests__/v5-to-v6.test.ts | 186 +-- .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v21-to-v22.ts | 511 +++++++ .../services/storage/migrations/v21-to-v22.ts | 16 + 35 files changed, 5242 insertions(+), 3235 deletions(-) create mode 100644 apps/web/src/lib/animation/__tests__/binding-values.test.ts create mode 100644 apps/web/src/lib/animation/__tests__/keyframe-query.test.ts create mode 100644 apps/web/src/lib/animation/bezier.ts create mode 100644 apps/web/src/lib/animation/binding-values.ts delete mode 100644 apps/web/src/lib/animation/color-channel.ts delete mode 100644 apps/web/src/lib/animation/number-channel.ts delete mode 100644 apps/web/src/lib/animation/vector-channel.ts create mode 100644 apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts create mode 100644 apps/web/src/services/storage/migrations/v21-to-v22.ts diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 92b5d634..c044ff5f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,150 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + coerceAnimationValueForParam, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + coerceValue: (nextValue) => + coerceAnimationValueForParam({ + param, + value: nextValue, + }), + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 25218d78..1d3606e6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -292,10 +292,7 @@ export function TimelineElement({ const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && - canToggleSourceAudio({ - element, - mediaAsset, - }); + canToggleSourceAudio(element, mediaAsset); const sourceAudioLabel = element.type === "video" ? getSourceAudioActionLabel({ element }) diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index ecaf37e2..cb636d3c 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -50,6 +50,7 @@ import { import { HugeiconsIcon } from "@hugeicons/react"; import { OcRippleIcon } from "@/components/icons"; + export function TimelineToolbar({ zoomLevel, minZoom, @@ -115,10 +116,7 @@ function ToolbarLeftSection() { })(); const canToggleSelectedSourceAudio = !!selectedElement && - canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset: selectedMediaAsset, - }); + canToggleSourceAudio(selectedElement.element, selectedMediaAsset); const sourceAudioLabel = selectedElement?.element.type === "video" ? getSourceAudioActionLabel({ @@ -317,28 +315,34 @@ function ToolbarButton({ onClick, disabled, isActive, + buttonWrapper, }: { icon: React.ReactNode; tooltip: string; - onClick: ({ event }: { event: React.MouseEvent }) => void; + onClick?: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; isActive?: boolean; + buttonWrapper?: (button: React.ReactElement) => React.ReactElement; }) { + const button = ( + + ); + return ( - + {buttonWrapper ? buttonWrapper(button) : button} {tooltip} diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 87aa6b48..356d0e2b 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -294,12 +294,7 @@ export function useEditorActions() { null ); })(); - if ( - !canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset, - }) - ) { + if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { return; } diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts index bbe843f2..b66bbea8 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -6,6 +6,7 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { useEditor } from "@/hooks/use-editor"; +import { getKeyframeById } from "@/lib/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm"; import { timelineTimeToSnappedPixels } from "@/lib/timeline"; @@ -84,10 +85,11 @@ export function useKeyframeDrag({ deltaTime: number; }) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const channel = element.animations?.channels[keyframeRef.propertyPath]; - const keyframe = channel?.keyframes.find( - (keyframe) => keyframe.id === keyframeRef.keyframeId, - ); + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); if (!keyframe) return []; const nextTime = Math.max( 0, diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index af4bb69f..659aa598 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,619 +1,629 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/lib/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getElementLocalTime, - resolveTransformAtTime, - setChannel, -} from "@/lib/animation"; -import type { Transform } from "@/lib/rendering"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ - edge, -}: { - edge: Edge; -}): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} - -export function useTransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); - const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; - const mediaAssets = useEditor((e) => e.media.getAssets()); - const canvasSize = useEditor( - (e) => e.project.getActive().settings.canvasSize, - ); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; - - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); - - useEffect(() => { - if (!activeHandle) return; - - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); - - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - !!element.animations?.channels["transform.scaleX"] || - !!element.animations?.channels["transform.scaleY"]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = - !!element.animations?.channels[propertyPath]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, - [clearActiveHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithBounds, - hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - }; -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; +import type { Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type Edge = "right" | "left" | "bottom"; +type HandleType = Corner | Edge | "rotation"; + +function getPreferredEdge({ + edge, +}: { + edge: Edge; +}): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +interface EdgeScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + edge: Edge; + rotationRad: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandle, setActiveHandle] = useState(null); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + const edgeScaleStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const clearActiveHandleState = useCallback(() => { + scaleStateRef.current = null; + rotationStateRef.current = null; + edgeScaleStateRef.current = null; + setActiveHandle(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandle) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearActiveHandleState(); + releaseCapturedPointer(); + }, + }); + }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleY", + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations: element.animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : element.animations; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(corner); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds, viewport], + ); + + const handleEdgePointerDown = useCallback( + ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const rotationRad = (bounds.rotation * Math.PI) / 180; + + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath, + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath, + channel: undefined, + }) + : element.animations; + + edgeScaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(edge); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if ( + !scaleStateRef.current && + !rotationStateRef.current && + !edgeScaleStateRef.current + ) + return; + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + } = scaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = + Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / initialDistance; + + // Use actual element dimensions (base * current scale) so snap + // computes the correct edges when scaleX ≠ scaleY + const effectiveWidth = baseWidth * initialTransform.scaleX; + const effectiveHeight = baseHeight * initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale: snappedFactor, activeLines } = + isShiftHeldRef.current + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: clampScaleNonZero( + initialTransform.scaleX * snappedFactor, + ), + scaleY: clampScaleNonZero( + initialTransform.scaleY * snappedFactor, + ), + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if ( + edgeScaleStateRef.current && + (activeHandle === "right" || + activeHandle === "left" || + activeHandle === "bottom") + ) { + const { + trackId, + elementId, + initialTransform, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + } = edgeScaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const xProjection = + deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); + const yProjection = + -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); + const projection = + edge === "right" + ? xProjection + : edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + edge === "right" || edge === "left" + ? proposedScale + : initialTransform.scaleX; + const proposedScaleY = + edge === "bottom" ? proposedScale : initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = isShiftHeldRef.current + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: initialTransform.position, + baseWidth, + baseHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + preferredEdges: getPreferredEdge({ edge }), + }); + + const relevantSnap = + edge === "right" || edge === "left" ? xSnap : ySnap; + onSnapLinesChange?.(relevantSnap.activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: + edge === "right" || edge === "left" + ? xSnap.snappedScale + : initialTransform.scaleX, + scaleY: + edge === "bottom" + ? ySnap.snappedScale + : initialTransform.scaleY, + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const { snappedRotation } = isShiftHeldRef.current + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [ + activeHandle, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if ( + scaleStateRef.current || + rotationStateRef.current || + edgeScaleStateRef.current + ) { + editor.timeline.commitPreview(); + clearActiveHandleState(); + } + releaseCapturedPointer(); + }, + [clearActiveHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts new file mode 100644 index 00000000..c5a354e4 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/binding-values.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { + composeAnimationValue, + createAnimationBinding, +} from "@/lib/animation/binding-values"; + +describe("binding values", () => { + test("formats composed animated colors as hex", () => { + const binding = createAnimationBinding({ + path: "color", + kind: "color", + }); + + expect( + composeAnimationValue({ + binding, + componentValues: { + r: 1, + g: 0, + b: 0, + a: 1, + }, + }), + ).toBe("#ff0000"); + }); +}); diff --git a/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts new file mode 100644 index 00000000..319493c8 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, +} from "@/lib/animation/keyframe-query"; +import type { + ElementAnimations, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +function createScalarKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: number; +}): ScalarAnimationKey { + return { + id, + time, + value, + segmentToNext: "linear", + tangentMode: "flat", + }; +} + +function buildPositionAnimations({ + xKeys, + yKeys, +}: { + xKeys: ScalarAnimationKey[]; + yKeys: ScalarAnimationKey[]; +}): ElementAnimations { + return { + bindings: { + "transform.position": { + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }, + }, + channels: { + "transform.position:x": { + kind: "scalar", + keys: xKeys, + }, + "transform.position:y": { + kind: "scalar", + keys: yKeys, + }, + }, + }; +} + +describe("keyframe query", () => { + test("returns keyframes from any component channel", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([ + { id: "x-1", time: 1 }, + { id: "y-2", time: 2 }, + ]); + }); + + test("finds a keyframe at time on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 2, + }), + ).toMatchObject({ + id: "y-2", + time: 2, + }); + }); + + test("finds a keyframe by id on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeById({ + animations, + propertyPath: "transform.position", + keyframeId: "y-2", + }), + ).toMatchObject({ + id: "y-2", + time: 2, + value: { x: 10, y: 20 }, + }); + }); + + test("prefers the primary component when multiple components share a time", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([{ id: "x-1", time: 1 }]); + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 1, + }), + ).toMatchObject({ + id: "x-1", + time: 1, + }); + }); +}); diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts new file mode 100644 index 00000000..4dce027e --- /dev/null +++ b/apps/web/src/lib/animation/bezier.ts @@ -0,0 +1,88 @@ +import type { ScalarAnimationKey } from "@/lib/animation/types"; + +export function getBezierPoint({ + progress, + p0, + p1, + p2, + p3, +}: { + progress: number; + p0: number; + p1: number; + p2: number; + p3: number; +}) { + const mt = 1 - progress; + return ( + mt * mt * mt * p0 + + 3 * mt * mt * progress * p1 + + 3 * mt * progress * progress * p2 + + progress * progress * progress * p3 + ); +} + +export function getDefaultRightHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: span / 3, + dv: valueDelta / 3, + }; +} + +export function getDefaultLeftHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: -span / 3, + dv: -valueDelta / 3, + }; +} + +export function solveBezierProgressForTime({ + time, + leftKey, + rightKey, +}: { + time: number; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + let lower = 0; + let upper = 1; + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + for (let iteration = 0; iteration < 20; iteration++) { + const mid = (lower + upper) / 2; + const estimate = getBezierPoint({ + progress: mid, + p0: leftKey.time, + p1: leftKey.time + rightHandle.dt, + p2: rightKey.time + leftHandle.dt, + p3: rightKey.time, + }); + if (estimate < time) { + lower = mid; + } else { + upper = mid; + } + } + + return (lower + upper) / 2; +} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts new file mode 100644 index 00000000..6f7ce995 --- /dev/null +++ b/apps/web/src/lib/animation/binding-values.ts @@ -0,0 +1,335 @@ +import { converter, formatHex, formatHex8, parse } from "culori"; +import type { + AnimationBindingComponent, + AnimationBindingOfKind, + AnimationBindingInstance, + AnimationBindingKind, + ColorAnimationBinding, + DiscreteAnimationBinding, + NumberAnimationBinding, + AnimationPath, + AnimationValue, + DiscreteValue, + Vector2AnimationBinding, + VectorValue, +} from "@/lib/animation/types"; + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export type AnimationComponentValue = number | DiscreteValue; + +const toRgb = converter("rgb"); + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp01({ value }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isVectorValue(value: unknown): value is VectorValue { + return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; +} + +export function getBindingComponentKeys({ + kind, +}: { + kind: AnimationBindingKind; +}): string[] { + if (kind === "vector2") { + return ["x", "y"]; + } + + if (kind === "color") { + return ["r", "g", "b", "a"]; + } + + return ["value"]; +} + +export function buildBindingChannelId({ + path, + componentKey, +}: { + path: AnimationPath; + componentKey: string; +}): string { + return `${path}:${componentKey}`; +} + +function createBindingComponent({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents({ + components, +}: { + components: AnimationBindingComponent[]; +}): AnimationBindingComponent[] { + return components.map((component) => ({ ...component })); +} + +const animationBindingFactories = { + color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ + path, + kind: "color", + colorSpace: "srgb-linear", + components: [ + createBindingComponent({ path, key: "r" }), + createBindingComponent({ path, key: "g" }), + createBindingComponent({ path, key: "b" }), + createBindingComponent({ path, key: "a" }), + ], + }), + vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ + path, + kind: "vector2", + components: [ + createBindingComponent({ path, key: "x" }), + createBindingComponent({ path, key: "y" }), + ], + }), + number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ + path, + kind: "number", + components: [createBindingComponent({ path, key: "value" })], + }), + discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ + path, + kind: "discrete", + components: [createBindingComponent({ path, key: "value" })], + }), +} satisfies { + [K in AnimationBindingKind]: ({ + path, + }: { + path: AnimationPath; + }) => AnimationBindingOfKind; +}; + +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind; +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: AnimationBindingKind; +}): AnimationBindingInstance { + return animationBindingFactories[kind]({ path }); +} + +const animationBindingCloners = { + color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + vector2: ({ + binding, + }: { + binding: Vector2AnimationBinding; + }): Vector2AnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + number: ({ + binding, + }: { + binding: NumberAnimationBinding; + }): NumberAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + discrete: ({ + binding, + }: { + binding: DiscreteAnimationBinding; + }): DiscreteAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), +} satisfies { + [K in AnimationBindingKind]: ({ + binding, + }: { + binding: AnimationBindingOfKind; + }) => AnimationBindingOfKind; +}; + +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingOfKind; +}): AnimationBindingOfKind; +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingInstance; +}): AnimationBindingInstance { + switch (binding.kind) { + case "color": + return animationBindingCloners.color({ binding }); + case "vector2": + return animationBindingCloners.vector2({ binding }); + case "number": + return animationBindingCloners.number({ binding }); + case "discrete": + return animationBindingCloners.discrete({ binding }); + } +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp01({ value: rgb.alpha ?? 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp01({ value: color.a }), + } as const; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +export function decomposeAnimationValue({ + kind, + value, +}: { + kind: AnimationBindingKind; + value: AnimationValue; +}): Record | null { + if (kind === "number") { + return typeof value === "number" ? { value } : null; + } + + if (kind === "vector2") { + return isVectorValue(value) ? { x: value.x, y: value.y } : null; + } + + if (kind === "color") { + if (typeof value !== "string") { + return null; + } + const parsed = parseColorToLinearRgba({ color: value }); + if (!parsed) { + return null; + } + return { + r: parsed.r, + g: parsed.g, + b: parsed.b, + a: parsed.a, + }; + } + + return typeof value === "string" || typeof value === "boolean" + ? { value } + : null; +} + +export function composeAnimationValue({ + binding, + componentValues, +}: { + binding: AnimationBindingInstance; + componentValues: Record; +}): AnimationValue | null { + if (binding.kind === "number") { + const value = componentValues.value; + return typeof value === "number" ? value : null; + } + + if (binding.kind === "vector2") { + const x = componentValues.x; + const y = componentValues.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; + } + + if (binding.kind === "color") { + const r = componentValues.r; + const g = componentValues.g; + const b = componentValues.b; + const a = componentValues.a; + if ( + typeof r !== "number" || + typeof g !== "number" || + typeof b !== "number" || + typeof a !== "number" + ) { + return null; + } + return formatLinearRgba({ + color: { + r, + g, + b, + a, + }, + }); + } + + const value = componentValues.value; + return typeof value === "string" || typeof value === "boolean" ? value : null; +} diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts deleted file mode 100644 index c1c05f9a..00000000 --- a/apps/web/src/lib/animation/color-channel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - AnimationPropertyPath, - ColorAnimationChannel, - ElementAnimations, -} from "@/lib/animation/types"; - -export function getColorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): ColorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "color") { - return undefined; - } - return channel; -} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts index a614c14f..3080e6ff 100644 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types"; import type { ElementAnimations, EffectParamPath, - NumberAnimationChannel, } from "@/lib/animation/types"; -import { - getChannel, - removeKeyframe, - setChannel, - upsertKeyframe, -} from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; +import { removeElementKeyframe } from "./keyframes"; +import { resolveAnimationPathValueAtTime } from "./resolve"; export const EFFECT_PARAM_PATH_PREFIX = "effects."; export const EFFECT_PARAM_PATH_SUFFIX = ".params."; @@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({ for (const [paramKey, staticValue] of Object.entries(effect.params)) { const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - if (channel && channel.keyframes.length > 0) { - resolved[paramKey] = getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: staticValue, - }) as number | string | boolean; - } else { - resolved[paramKey] = staticValue; - } + resolved[paramKey] = animations?.bindings[path] + ? resolveAnimationPathValueAtTime({ + animations, + propertyPath: path, + localTime, + fallbackValue: staticValue, + }) + : staticValue; } return resolved; } -const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = { - valueKind: "number", - keyframes: [], -}; - -export function upsertEffectParamKeyframe({ - animations, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - effectId: string; - paramKey: string; - time: number; - value: number; - interpolation?: "linear" | "hold"; - keyframeId?: string; -}): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const targetChannel = - channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL; - const updatedChannel = upsertKeyframe({ - channel: targetChannel, - time, - value, - interpolation: interpolation ?? "linear", - keyframeId, - }); - - return ( - setChannel({ - animations, - propertyPath: path, - channel: updatedChannel, - }) ?? { channels: {} } - ); -} - export function removeEffectParamKeyframe({ animations, effectId, @@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({ paramKey: string; keyframeId: string; }): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const updatedChannel = removeKeyframe({ channel, keyframeId }); - return setChannel({ + return removeElementKeyframe({ animations, - propertyPath: path, - channel: updatedChannel, + propertyPath: buildEffectParamPath({ effectId, paramKey }), + keyframeId, }); } diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts index 22f78ba5..de595060 100644 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ b/apps/web/src/lib/animation/graphic-param-channel.ts @@ -1,77 +1,73 @@ -import type { - ElementAnimations, - GraphicParamPath, -} from "@/lib/animation/types"; -import type { ParamValues } from "@/lib/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/lib/graphics"; -import { getChannel } from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; - -export const GRAPHIC_PARAM_PATH_PREFIX = "params."; - -export function buildGraphicParamPath({ - paramKey, -}: { - paramKey: string; -}): GraphicParamPath { - return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; -} - -export function isGraphicParamPath( - propertyPath: string, -): propertyPath is GraphicParamPath { - return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); -} - -export function parseGraphicParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { paramKey: string } | null { - if (!isGraphicParamPath(propertyPath)) { - return null; - } - - const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); - return paramKey.length > 0 ? { paramKey } : null; -} - -export function resolveGraphicParamsAtTime({ - element, - localTime, -}: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; - localTime: number; -}): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; - - for (const param of definition.params) { - const path = buildGraphicParamPath({ paramKey: param.key }); - const channel = getChannel({ - animations: element.animations, - propertyPath: path, - }); - if (!channel || channel.keyframes.length === 0) { - continue; - } - - resolved[param.key] = getChannelValueAtTime({ - channel, - time: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, - }) as number | string | boolean; - } - - return resolved; -} +import type { + ElementAnimations, + GraphicParamPath, +} from "@/lib/animation/types"; +import type { ParamValues } from "@/lib/params"; +import { + getGraphicDefinition, + resolveGraphicParams, +} from "@/lib/graphics"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export const GRAPHIC_PARAM_PATH_PREFIX = "params."; + +export function buildGraphicParamPath({ + paramKey, +}: { + paramKey: string; +}): GraphicParamPath { + return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; +} + +export function isGraphicParamPath( + propertyPath: string, +): propertyPath is GraphicParamPath { + return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); +} + +export function parseGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { paramKey: string } | null { + if (!isGraphicParamPath(propertyPath)) { + return null; + } + + const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); + return paramKey.length > 0 ? { paramKey } : null; +} + +export function resolveGraphicParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const baseParams = resolveGraphicParams(definition, element.params); + const resolved: ParamValues = { ...baseParams }; + + for (const param of definition.params) { + const path = buildGraphicParamPath({ paramKey: param.key }); + if (!element.animations?.bindings[path]) { + continue; + } + + resolved[param.key] = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: path, + localTime: Math.max(0, localTime), + fallbackValue: baseParams[param.key] ?? param.default, + }); + } + + return resolved; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 819a59e5..9cb85d2a 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -1,71 +1,81 @@ -export { - getChannelValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setChannel, - splitAnimationsAtTime, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - type NumericRange, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - getKeyframeAtTime, - hasKeyframesForPath, -} from "./keyframe-query"; - -export { - buildGraphicParamPath, - isGraphicParamPath, - parseGraphicParamPath, - resolveGraphicParamsAtTime, -} from "./graphic-param-channel"; - -export { - isAnimationPath, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - -export { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - type GroupKeyframeRef, -} from "./property-groups"; - -export { - getVectorChannelForPath, - isVectorValue, -} from "./vector-channel"; +export { + getChannelValueAtTime, + getDiscreteChannelValueAtTime, + getScalarChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, + upsertPathKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveAnimationPathValueAtTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + type AnimationPropertyDefinition, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; + +export { + buildGraphicParamPath, + isGraphicParamPath, + parseGraphicParamPath, + resolveGraphicParamsAtTime, +} from "./graphic-param-channel"; + +export { + buildEffectParamPath, + isEffectParamPath, + parseEffectParamPath, + removeEffectParamKeyframe, + resolveEffectParamsAtTime, +} from "./effect-param-channel"; + +export { + isAnimationPath, + coerceAnimationValueForParam, + resolveAnimationTarget, + getParamValueKind, + getParamDefaultInterpolation, + type AnimationPathDescriptor, +} from "./target-resolver"; + +export { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + type GroupKeyframeRef, +} from "./property-groups"; + +export { + isVectorValue, +} from "./binding-values"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index c5b9ea80..9609441c 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -1,15 +1,20 @@ import type { AnimationChannel, + AnimationInterpolation, AnimationValue, - ColorAnimationChannel, - DiscreteValue, DiscreteAnimationChannel, - NumberAnimationChannel, - VectorAnimationChannel, - VectorValue, + DiscreteValue, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; function byTimeAscending({ leftTime, @@ -40,69 +45,6 @@ function clamp01({ value }: { value: number }): number { return Math.max(0, Math.min(1, value)); } -function parseHexChannel({ hex }: { hex: string }): number | null { - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? null : value; -} - -function parseHexColor({ - color, -}: { - color: string; -}): { red: number; green: number; blue: number; alpha: number } | null { - const trimmed = color.trim(); - if (!trimmed.startsWith("#")) { - return null; - } - - const rawHex = trimmed.slice(1); - if (rawHex.length === 3 || rawHex.length === 4) { - const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split(""); - const red = parseHexChannel({ hex: `${redHex}${redHex}` }); - const green = parseHexChannel({ hex: `${greenHex}${greenHex}` }); - const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` }); - const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - if (rawHex.length === 6 || rawHex.length === 8) { - const red = parseHexChannel({ hex: rawHex.slice(0, 2) }); - const green = parseHexChannel({ hex: rawHex.slice(2, 4) }); - const blue = parseHexChannel({ hex: rawHex.slice(4, 6) }); - const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff"; - const alpha = parseHexChannel({ hex: alphaHex }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - return null; -} - -function formatRgbaColor({ - red, - green, - blue, - alpha, -}: { - red: number; - green: number; - blue: number; - alpha: number; -}): string { - const roundedRed = Math.round(red); - const roundedGreen = Math.round(green); - const roundedBlue = Math.round(blue); - const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000; - return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`; -} - function lerpNumber({ leftValue, rightValue, @@ -115,43 +57,99 @@ function lerpNumber({ return leftValue + (rightValue - leftValue) * progress; } -function interpolateColor({ - leftColor, - rightColor, - progress, +function normalizeRightHandle({ + handle, + leftKey, + rightKey, }: { - leftColor: string; - rightColor: string; - progress: number; -}): string { - const leftParsed = parseHexColor({ color: leftColor }); - const rightParsed = parseHexColor({ color: rightColor }); - if (!leftParsed || !rightParsed) { - return progress >= 1 ? rightColor : leftColor; + handle: ScalarAnimationKey["rightHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; } - return formatRgbaColor({ - red: lerpNumber({ - leftValue: leftParsed.red, - rightValue: rightParsed.red, - progress, - }), - green: lerpNumber({ - leftValue: leftParsed.green, - rightValue: rightParsed.green, - progress, - }), - blue: lerpNumber({ - leftValue: leftParsed.blue, - rightValue: rightParsed.blue, - progress, - }), - alpha: lerpNumber({ - leftValue: leftParsed.alpha, - rightValue: rightParsed.alpha, - progress, - }), + const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time); + return { + dt: Math.min(span, Math.max(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeLeftHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["leftHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(TIME_EPSILON_SECONDS, rightKey.time - leftKey.time); + return { + dt: Math.max(-span, Math.min(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeScalarKey({ + key, +}: { + key: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + ...key, + tangentMode: key.tangentMode ?? "flat", + segmentToNext: key.segmentToNext ?? "linear", + }; +} + +function normalizeScalarChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel { + const sortedKeys = [...channel.keys] + .map((key) => normalizeScalarKey({ key })) + .sort((leftKey, rightKey) => + byTimeAscending({ + leftTime: leftKey.time, + rightTime: rightKey.time, + }), + ); + const nextKeys = sortedKeys.map((key, index) => { + const previousKey = sortedKeys[index - 1]; + const nextKey = sortedKeys[index + 1]; + return { + ...key, + leftHandle: + previousKey != null + ? normalizeLeftHandle({ + handle: key.leftHandle, + leftKey: previousKey, + rightKey: key, + }) + : undefined, + rightHandle: + nextKey != null + ? normalizeRightHandle({ + handle: key.rightHandle, + leftKey: key, + rightKey: nextKey, + }) + : undefined, + }; }); + + return { + ...channel, + keys: nextKeys, + }; } export function normalizeChannel({ @@ -159,9 +157,15 @@ export function normalizeChannel({ }: { channel: TChannel; }): TChannel { + if (channel.kind === "scalar") { + return normalizeScalarChannel({ + channel, + }) as TChannel; + } + return { ...channel, - keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => byTimeAscending({ leftTime: leftKeyframe.time, rightTime: rightKeyframe.time, @@ -170,171 +174,152 @@ export function normalizeChannel({ } as TChannel; } -function evaluateChannelValueAtTime< - TKeyframe extends { time: number; value: TValue }, - TValue, ->({ - keyframes, +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, time, - fallbackValue, - getInterpolatedValue, }: { - keyframes: TKeyframe[] | undefined; + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; time: number; - fallbackValue: TValue; - getInterpolatedValue: ({ - leftKeyframe, - rightKeyframe, - progress, - }: { - leftKeyframe: TKeyframe; - rightKeyframe: TKeyframe; - progress: number; - }) => TValue; -}): TValue { - if (!keyframes || keyframes.length === 0) { - return fallbackValue; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; } - const firstKeyframe = keyframes[0]; - const lastKeyframe = keyframes[keyframes.length - 1]; - if (!firstKeyframe || !lastKeyframe) { - return fallbackValue; + const span = neighborKey.time - edgeKey.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return edgeKey.value; } - if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { - return firstKeyframe.value; - } - - if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) { - return lastKeyframe.value; - } - - for ( - let keyframeIndex = 0; - keyframeIndex < keyframes.length - 1; - keyframeIndex++ - ) { - const leftKeyframe = keyframes[keyframeIndex]; - const rightKeyframe = keyframes[keyframeIndex + 1]; - - if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const isBetweenPair = isWithinTimePair({ - time, - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }); - if (!isBetweenPair) { - continue; - } - - const span = rightKeyframe.time - leftKeyframe.time; - if (Math.abs(span) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const progress = clamp01({ - value: (time - leftKeyframe.time) / span, - }); - - return getInterpolatedValue({ - leftKeyframe, - rightKeyframe, - progress, - }); - } - - return lastKeyframe.value; + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); } -export function getNumberChannelValueAtTime({ +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ channel, time, fallbackValue, }: { - channel: NumberAnimationChannel | undefined; + channel: ScalarAnimationChannel | undefined; time: number; fallbackValue: number; }): number { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { + return fallbackValue; + } + + if (time <= firstKey.time + TIME_EPSILON_SECONDS) { + if (time < firstKey.time - TIME_EPSILON_SECONDS) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.before ?? "hold", + edgeKey: firstKey, + neighborKey: normalizedChannel.keys[1], + time, + }); + } + + return firstKey.value; + } + + if (time >= lastKey.time - TIME_EPSILON_SECONDS) { + if (time > lastKey.time + TIME_EPSILON_SECONDS) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.after ?? "hold", + edgeKey: lastKey, + neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], + time, + }); + } + + return lastKey.value; + } + + for ( + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ + ) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (Math.abs(time - rightKey.time) <= TIME_EPSILON_SECONDS) { + return rightKey.value; + } + + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { + continue; + } + + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return rightKey.value; + } + + const progress = clamp01({ + value: (time - leftKey.time) / span, + }); + if (leftKey.segmentToNext === "linear") { return lerpNumber({ - leftValue: leftKeyframe.value, - rightValue: rightKeyframe.value, + leftValue: leftKey.value, + rightValue: rightKey.value, progress, }); - }, - }); + } + + const curveProgress = solveBezierProgressForTime({ + time, + leftKey, + rightKey, + }); + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + return getBezierPoint({ + progress: curveProgress, + p0: leftKey.value, + p1: leftKey.value + rightHandle.dv, + p2: rightKey.value + leftHandle.dv, + p3: rightKey.value, + }); + } + + return lastKey.value; } -export function getColorValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: ColorAnimationChannel | undefined; - time: number; - fallbackValue: string; -}): string { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return interpolateColor({ - leftColor: leftKeyframe.value, - rightColor: rightKeyframe.value, - progress, - }); - }, - }); -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return { - x: - leftKeyframe.value.x + - (rightKeyframe.value.x - leftKeyframe.value.x) * progress, - y: - leftKeyframe.value.y + - (rightKeyframe.value.y - leftKeyframe.value.y) * progress, - }; - }, - }); -} - -function getDiscreteValueAtTime({ +export function getDiscreteChannelValueAtTime({ channel, time, fallbackValue, @@ -343,12 +328,21 @@ function getDiscreteValueAtTime({ time: number; fallbackValue: DiscreteValue; }): DiscreteValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, }); + let currentValue = fallbackValue; + for (const key of normalizedChannel.keys) { + if (time + TIME_EPSILON_SECONDS < key.time) { + break; + } + currentValue = key.value; + } + return currentValue; } export function getChannelValueAtTime({ @@ -360,50 +354,25 @@ export function getChannelValueAtTime({ time: number; fallbackValue: AnimationValue; }): AnimationValue { - if (!channel || channel.keyframes.length === 0) { + if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.valueKind === "number") { - if (typeof fallbackValue !== "number") { - return fallbackValue; - } - - return getNumberChannelValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "color") { - if (typeof fallbackValue !== "string") { - return fallbackValue; - } - - return getColorValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(fallbackValue)) { - return fallbackValue; - } - return getVectorChannelValueAtTime({ - channel, - time, - fallbackValue: fallbackValue as VectorValue, - }); + if (channel.kind === "scalar") { + return typeof fallbackValue === "number" + ? getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, + }) + : fallbackValue; } if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { return fallbackValue; } - return getDiscreteValueAtTime({ + return getDiscreteChannelValueAtTime({ channel, time, fallbackValue, diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 62c8a171..304a6698 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,72 +1,298 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import type { - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { isAnimationPath } from "./target-resolver"; - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if ( - !channel || - channel.keyframes.length === 0 || - !isAnimationPath(propertyPath) - ) { - return []; - } - - return channel.keyframes.map((keyframe) => ({ - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - })); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const channel = animations?.channels[propertyPath]; - return Boolean(channel && channel.keyframes.length > 0); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.keyframes.length === 0) return null; - const keyframe = channel.keyframes.find( - (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, - ); - if (!keyframe) return null; - return { - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - }; -} +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationBindingInstance, + AnimationChannel, + AnimationPath, + ElementAnimations, + ElementKeyframe, +} from "@/lib/animation/types"; +import { + type AnimationComponentValue, + composeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, +} from "./interpolation"; +import { isAnimationPath } from "./target-resolver"; + +function getBindingFallbackValue({ + channel, +}: { + channel: ElementAnimations["channels"][string]; +}) { + if (!channel || channel.keys.length === 0) { + return channel?.kind === "discrete" ? false : 0; + } + + return channel.keys[0].value; +} + +interface BindingKeyframeMatch { + componentIndex: number; + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +} + +function getBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + return binding.components.flatMap((component, componentIndex) => { + const channel = animations.channels[component.channelId]; + if (!channel || channel.keys.length === 0) { + return []; + } + + return channel.keys.map((keyframe) => ({ + componentIndex, + channel, + keyframe, + })); + }); +} + +function getUniqueBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + const sortedMatches = getBindingKeyframeMatches({ + animations, + binding, + }).sort( + (leftMatch, rightMatch) => + leftMatch.keyframe.time - rightMatch.keyframe.time || + leftMatch.componentIndex - rightMatch.componentIndex, + ); + const uniqueMatches: BindingKeyframeMatch[] = []; + + for (const match of sortedMatches) { + const previousMatch = uniqueMatches[uniqueMatches.length - 1]; + if ( + !previousMatch || + Math.abs(previousMatch.keyframe.time - match.keyframe.time) > + TIME_EPSILON_SECONDS + ) { + uniqueMatches.push(match); + continue; + } + + if ( + previousMatch.componentIndex !== 0 && + match.componentIndex === 0 + ) { + uniqueMatches[uniqueMatches.length - 1] = match; + } + } + + return uniqueMatches; +} + +function getPreferredBindingKeyframeMatch({ + matches, +}: { + matches: BindingKeyframeMatch[]; +}): BindingKeyframeMatch | null { + return ( + matches.find((match) => match.componentIndex === 0) ?? + matches[0] ?? + null + ); +} + +function getComposedBindingValueAtTime({ + animations, + binding, + time, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + time: number; +}) { + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time, + fallbackValue: getBindingFallbackValue({ channel }), + }), + ]; + }), + ) as Record; + + return composeAnimationValue({ + binding, + componentValues, + }); +} + +function getKeyframeInterpolation({ + channel, + keyframe, +}: { + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +}) { + return channel.kind === "scalar" && "segmentToNext" in keyframe + ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) + : "hold"; +} + +function toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + propertyPath: AnimationPath; + keyframeMatch: BindingKeyframeMatch; +}): ElementKeyframe | null { + const value = getComposedBindingValueAtTime({ + animations, + binding, + time: keyframeMatch.keyframe.time, + }); + if (value === null) { + return null; + } + + return { + propertyPath, + id: keyframeMatch.keyframe.id, + time: keyframeMatch.keyframe.time, + value, + interpolation: getKeyframeInterpolation({ + channel: keyframeMatch.channel, + keyframe: keyframeMatch.keyframe, + }), + }; +} + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.bindings).flatMap( + ([propertyPath, binding]) => { + if (!binding || !isAnimationPath(propertyPath)) { + return []; + } + + return getUniqueBindingKeyframeMatches({ + animations, + binding, + }).flatMap((keyframeMatch) => { + const keyframe = toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); + if (!keyframe) { + return []; + } + + return [keyframe]; + }); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): boolean { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return false; + } + + return binding.components.some((component) => + Boolean(animations?.channels[component.channelId]?.keys.length), + ); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => + Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, + ), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} + +export function getKeyframeById({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.id === keyframeId), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 13cdf238..8c17f8a6 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -1,28 +1,39 @@ import type { + AnimationBindingInstance, + AnimationBindingKind, AnimationChannel, AnimationInterpolation, - AnimationKeyframe, AnimationPath, AnimationPropertyPath, AnimationValue, - AnimationValueKind, - ColorAnimationChannel, DiscreteAnimationChannel, + DiscreteAnimationKey, ElementAnimations, - NumberAnimationChannel, - VectorAnimationChannel, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { generateUUID } from "@/utils/id"; -import { snapToStep } from "@/utils/math"; -import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; +import { + cloneAnimationBinding, + createAnimationBinding, + decomposeAnimationValue, +} from "./binding-values"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; import { coerceAnimationValueForProperty, - getDefaultInterpolationForProperty, getAnimationPropertyDefinition, - isAnimationPropertyPath, - type NumericRange, } from "./property-registry"; function isNearlySameTime({ @@ -35,32 +46,354 @@ function isNearlySameTime({ return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; } -function toAnimation({ - channelEntries, +function hasChannelKeys({ + channel, }: { - channelEntries: Array<[string, AnimationChannel]>; + channel: AnimationChannel | undefined; +}): boolean { + return Boolean(channel && channel.keys.length > 0); +} + +function toAnimation({ + animations, +}: { + animations: ElementAnimations; }): ElementAnimations | undefined { - if (channelEntries.length === 0) { + const nextBindings = Object.fromEntries( + Object.entries(animations.bindings).filter(([, binding]) => binding), + ); + const nextChannels = Object.fromEntries( + Object.entries(animations.channels).filter(([, channel]) => + hasChannelKeys({ channel }), + ), + ); + if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { return undefined; } return { - channels: Object.fromEntries(channelEntries), + bindings: nextBindings, + channels: nextChannels, }; } -function toChannel({ - keyframes, - valueKind, +function cloneAnimationsState({ + animations, }: { - keyframes: AnimationKeyframe[]; - valueKind: AnimationValueKind; + animations: ElementAnimations | undefined; +}): ElementAnimations { + return { + bindings: { ...(animations?.bindings ?? {}) }, + channels: { ...(animations?.channels ?? {}) }, + }; +} + +function getBindingChannelKind({ + kind, +}: { + kind: AnimationBindingKind; +}): AnimationChannel["kind"] { + return kind === "discrete" ? "discrete" : "scalar"; +} + +function getPrimaryComponent({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return binding.components[0] ?? null; +} + +function getPrimaryChannelId({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return getPrimaryComponent({ binding })?.channelId ?? null; +} + +function getScalarSegmentType({ + interpolation, +}: { + interpolation: AnimationInterpolation; +}): ScalarSegmentType { + if (interpolation === "hold") { + return "step"; + } + return interpolation === "bezier" ? "bezier" : "linear"; +} + +function getInterpolationForBinding({ + kind, + interpolation, +}: { + kind: AnimationBindingKind; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (kind === "discrete") { + return "hold"; + } + + if ( + interpolation === "linear" || + interpolation === "hold" || + interpolation === "bezier" + ) { + return interpolation; + } + + return "linear"; +} + +function createEmptyChannelForBindingKind({ + kind, +}: { + kind: AnimationBindingKind; }): AnimationChannel { + if (kind === "discrete") { + return { + kind: "discrete", + keys: [], + } satisfies DiscreteAnimationChannel; + } + + return { + kind: "scalar", + keys: [], + } satisfies ScalarAnimationChannel; +} + +function createScalarKey({ + id, + time, + value, + interpolation, + previousKey, +}: { + id: string; + time: number; + value: number; + interpolation: AnimationInterpolation; + previousKey?: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + id, + time, + value, + leftHandle: previousKey?.leftHandle, + rightHandle: previousKey?.rightHandle, + segmentToNext: + previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), + tangentMode: previousKey?.tangentMode ?? "flat", + }; +} + +function createDiscreteKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: string | boolean; +}): DiscreteAnimationKey { + return { + id, + time, + value, + }; +} + +function getBinding({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationBindingInstance | undefined { + return animations?.bindings[propertyPath]; +} + +function getChannelById({ + animations, + channelId, +}: { + animations: ElementAnimations | undefined; + channelId: string; +}): AnimationChannel | undefined { + return animations?.channels[channelId]; +} + +function getTargetKeyMetadata({ + channel, + time, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + keyframeId?: string; +}) { + const normalizedChannel = + channel != null ? normalizeChannel({ channel }) : undefined; + const keys = normalizedChannel?.keys ?? []; + if (keyframeId) { + const keyById = keys.find((key) => key.id === keyframeId); + if (keyById) { + return { + id: keyById.id, + time, + }; + } + } + + const keyAtTime = keys.find((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (keyAtTime) { + return { + id: keyAtTime.id, + time: keyAtTime.time, + }; + } + + return { + id: keyframeId ?? generateUUID(), + time, + }; +} + +function upsertDiscreteChannelKey({ + channel, + time, + value, + keyframeId, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + value: string | boolean; + keyframeId?: string; +}): DiscreteAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "discrete", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createDiscreteKey({ + id: keys[existingIndex].id, + time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createDiscreteKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + + keys.push( + createDiscreteKey({ + id: keyframeId ?? generateUUID(), + time, + value, + }), + ); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); +} + +function upsertScalarChannelKey({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + value: number; + interpolation: AnimationInterpolation; + keyframeId?: string; +}): ScalarAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "scalar", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createScalarKey({ + id: keys[existingIndex].id, + time, + value, + interpolation, + previousKey: { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createScalarKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + interpolation, + previousKey: { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + + keys.push( + createScalarKey({ + id: keyframeId ?? generateUUID(), + time, + value, + interpolation, + }), + ); return normalizeChannel({ channel: { - valueKind, - keyframes, - } as AnimationChannel, + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, }); } @@ -71,171 +404,10 @@ export function getChannel({ animations: ElementAnimations | undefined; propertyPath: string; }): AnimationChannel | undefined { - return animations?.channels[propertyPath]; -} - -function getInterpolationForChannel({ - channel, - interpolation, -}: { - channel: AnimationChannel; - interpolation: AnimationInterpolation | undefined; -}): AnimationInterpolation { - if (channel.valueKind === "discrete") { - return "hold"; - } - - if (interpolation === "linear" || interpolation === "hold") { - return interpolation; - } - - return "linear"; -} - -function buildKeyframe({ - channel, - id, - time, - value, - interpolation, -}: { - channel: AnimationChannel; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -}): AnimationKeyframe { - if (channel.valueKind === "number") { - if (typeof value !== "number") { - throw new Error("Number channel keyframes require numeric values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "color") { - if (typeof value !== "string") { - throw new Error("Color channel keyframes require string values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(value)) { - throw new Error("Vector channel keyframes require {x, y} values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (typeof value !== "string" && typeof value !== "boolean") { - throw new Error( - "Discrete channel keyframes require boolean or string values", - ); - } - - return { - id, - time, - value, - interpolation: "hold", - }; -} - -function createEmptyChannelForValueKind({ - valueKind, -}: { - valueKind: AnimationValueKind; -}): AnimationChannel { - if (valueKind === "number") { - return { - valueKind: "number", - keyframes: [], - } satisfies NumberAnimationChannel; - } - - if (valueKind === "color") { - return { - valueKind: "color", - keyframes: [], - } satisfies ColorAnimationChannel; - } - - if (valueKind === "vector") { - return { - valueKind: "vector", - keyframes: [], - } satisfies VectorAnimationChannel; - } - - return { - valueKind: "discrete", - keyframes: [], - } satisfies DiscreteAnimationChannel; -} - -function clampNumericRange({ - value, - numericRange, -}: { - value: number; - numericRange: NumericRange | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceAnimationValueForPath({ - value, - valueKind, - numericRange, -}: { - value: AnimationValue; - valueKind: AnimationValueKind; - numericRange?: NumericRange; -}): AnimationValue | null { - if (valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return clampNumericRange({ value, numericRange }); - } - - if (valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - return typeof value === "string" || typeof value === "boolean" ? value : null; + const binding = getBinding({ animations, propertyPath }); + const primaryChannelId = + binding != null ? getPrimaryChannelId({ binding }) : null; + return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; } export function upsertPathKeyframe({ @@ -245,9 +417,9 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - valueKind, + kind, defaultInterpolation, - numericRange, + coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; @@ -255,39 +427,80 @@ export function upsertPathKeyframe({ value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; - valueKind: AnimationValueKind; + kind: AnimationBindingKind; defaultInterpolation: AnimationInterpolation; - numericRange?: NumericRange; + coerceValue: (value: AnimationValue) => AnimationValue | null; }): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForPath({ - value, - valueKind, - numericRange, - }); + const coercedValue = coerceValue(value); if (coercedValue === null) { return animations; } - const channel = getChannel({ animations, propertyPath }); - const targetChannel = - channel && channel.valueKind === valueKind - ? channel - : createEmptyChannelForValueKind({ valueKind }); - const updatedChannel = upsertKeyframe({ - channel: targetChannel, + const nextAnimations = cloneAnimationsState({ animations }); + const existingBinding = getBinding({ + animations, + propertyPath, + }); + const binding = + existingBinding && existingBinding.kind === kind + ? cloneAnimationBinding({ binding: existingBinding }) + : createAnimationBinding({ path: propertyPath, kind }); + const primaryChannel = getChannel({ + animations, + propertyPath, + }); + const targetKey = getTargetKeyMetadata({ + channel: primaryChannel, time, - value: coercedValue, - interpolation: interpolation ?? defaultInterpolation, keyframeId, }); + const componentValues = decomposeAnimationValue({ + kind, + value: coercedValue, + }); + if (!componentValues) { + return animations; + } - return ( - setChannel({ + const nextInterpolation = getInterpolationForBinding({ + kind, + interpolation: interpolation ?? defaultInterpolation, + }); + nextAnimations.bindings[propertyPath] = binding; + for (const component of binding.components) { + const nextValue = componentValues[component.key]; + if (nextValue == null) { + continue; + } + + const currentChannel = getChannelById({ animations, - propertyPath, - channel: updatedChannel, - }) ?? { channels: {} } - ); + channelId: component.channelId, + }); + const targetChannel = + currentChannel?.kind === getBindingChannelKind({ kind }) + ? currentChannel + : createEmptyChannelForBindingKind({ kind }); + nextAnimations.channels[component.channelId] = + targetChannel.kind === "discrete" + ? upsertDiscreteChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as string | boolean, + keyframeId: targetKey.id, + }) + : upsertScalarChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as number, + interpolation: nextInterpolation, + keyframeId: targetKey.id, + }); + } + + return toAnimation({ + animations: nextAnimations, + }); } export function upsertElementKeyframe({ @@ -321,15 +534,16 @@ export function upsertElementKeyframe({ value: coercedValue, interpolation, keyframeId, - valueKind: propertyDefinition.valueKind, - defaultInterpolation: getDefaultInterpolationForProperty({ - propertyPath, - }), - numericRange: propertyDefinition.numericRange, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: (nextValue) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), }); } - export function upsertKeyframe({ channel, time, @@ -347,61 +561,29 @@ export function upsertKeyframe({ return undefined; } - const currentKeyframes = channel.keyframes; - const nextKeyframes = [...currentKeyframes]; - const nextInterpolation = getInterpolationForChannel({ - channel, - interpolation, - }); - if (keyframeId) { - const keyframeByIdIndex = nextKeyframes.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeByIdIndex >= 0) { - nextKeyframes[keyframeByIdIndex] = buildKeyframe({ - channel, - id: nextKeyframes[keyframeByIdIndex].id, - time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); + if (channel.kind === "discrete") { + if (typeof value !== "string" && typeof value !== "boolean") { + return channel; } - } - const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: time }), - ); - if (keyframeAtTimeIndex >= 0) { - nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({ + return upsertDiscreteChannelKey({ channel, - id: nextKeyframes[keyframeAtTimeIndex].id, - time: nextKeyframes[keyframeAtTimeIndex].time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); - } - - nextKeyframes.push( - buildKeyframe({ - channel, - id: keyframeId ?? generateUUID(), time, value, - interpolation: nextInterpolation, - }), - ); + keyframeId, + }); + } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + if (typeof value !== "number") { + return channel; + } + + return upsertScalarChannelKey({ + channel, + time, + value, + interpolation: interpolation ?? "linear", + keyframeId, }); } @@ -416,16 +598,16 @@ export function removeKeyframe({ return undefined; } - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.id !== keyframeId, - ); - if (nextKeyframes.length === 0) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { return undefined; } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -442,22 +624,24 @@ export function retimeKeyframe({ return undefined; } - const keyframeByIdIndex = channel.keyframes.findIndex( + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); if (keyframeByIdIndex < 0) { return channel; } - const nextKeyframes = [...channel.keyframes]; - nextKeyframes[keyframeByIdIndex] = { - ...nextKeyframes[keyframeByIdIndex], + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], time, }; - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -470,19 +654,38 @@ export function setChannel({ propertyPath: string; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const currentChannels = animations?.channels ?? {}; - - const nextChannelEntries = Object.entries(currentChannels) - .filter(([path]) => path !== propertyPath) - .filter(([, ch]) => ch && ch.keyframes.length > 0) - .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); - - if (channel && channel.keyframes.length > 0) { - nextChannelEntries.push([propertyPath, channel]); + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; } + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + if (!channel || !hasChannelKeys({ channel })) { + for (const component of binding.components) { + delete nextAnimations.channels[component.channelId]; + } + delete nextAnimations.bindings[propertyPath]; + return toAnimation({ + animations: nextAnimations, + }); + } + + if (binding.components.length !== 1) { + throw new Error( + `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, + ); + } + + nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({ + channel, + }); return toAnimation({ - channelEntries: nextChannelEntries, + animations: nextAnimations, }); } @@ -497,31 +700,57 @@ export function cloneAnimations({ return undefined; } - const clonedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel || channel.keyframes.length === 0) { - return []; + const nextAnimations = cloneAnimationsState({ animations }); + nextAnimations.bindings = Object.fromEntries( + Object.entries(animations.bindings).map(([path, binding]) => [ + path, + binding ? cloneAnimationBinding({ binding }) : binding, + ]), + ); + nextAnimations.channels = {}; + + for (const binding of Object.values(nextAnimations.bindings)) { + if (!binding) { + continue; + } + + const primaryChannel = getChannelById({ + animations, + channelId: getPrimaryChannelId({ binding }) ?? "", + }); + const keyIdMap = new Map(); + if (primaryChannel) { + for (const key of primaryChannel.keys) { + keyIdMap.set( + key.id, + shouldRegenerateKeyframeIds ? generateUUID() : key.id, + ); + } + } + + for (const component of binding.components) { + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (!currentChannel) { + continue; } - const clonedKeyframes = channel.keyframes.map((keyframe) => ({ - ...keyframe, - id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, - })); - - return [ - [ - propertyPath, - toChannel({ - keyframes: clonedKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], - ]; - }, - ); + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel: { + ...currentChannel, + keys: currentChannel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + } as AnimationChannel, + }); + } + } return toAnimation({ - channelEntries: clonedEntries, + animations: nextAnimations, }); } @@ -532,38 +761,310 @@ export function clampAnimationsToDuration({ animations: ElementAnimations | undefined; duration: number; }): ElementAnimations | undefined { - if (!animations) { + if (!animations || duration <= 0) { return undefined; } - const clampedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel) { - return []; - } + return splitAnimationsAtTime({ + animations, + splitTime: duration, + shouldIncludeSplitBoundary: true, + }).leftAnimations; +} - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.time >= 0 && keyframe.time <= duration, - ); - if (nextKeyframes.length === 0) { - return []; - } +function lerpPoint({ + left, + right, + progress, +}: { + left: { x: number; y: number }; + right: { x: number; y: number }; + progress: number; +}) { + return { + x: left.x + (right.x - left.x) * progress, + y: left.y + (right.y - left.y) * progress, + }; +} - return [ - [ - propertyPath, - toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], +function splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: DiscreteAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + if (shouldIncludeSplitBoundary) { + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keys[0].value, + }); + if (!hasBoundaryOnLeft) { + leftKeys = [ + ...leftKeys, + createDiscreteKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue as string | boolean, + }), ]; - }, - ); + } + if (!hasBoundaryOnRight) { + rightKeys = [ + createDiscreteKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue as string | boolean, + }), + ...rightKeys, + ]; + } + } - return toAnimation({ - channelEntries: clampedEntries, - }); + return { + leftChannel: leftKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + : undefined, + }; +} + +function splitScalarChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: ScalarAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; + } + + for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if ( + !( + splitTime > leftKey.time + TIME_EPSILON_SECONDS && + splitTime < rightKey.time - TIME_EPSILON_SECONDS + ) + ) { + continue; + } + + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: leftKey.value, + }) as number; + + if (leftKey.segmentToNext === "bezier") { + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + const progress = solveBezierProgressForTime({ + time: splitTime, + leftKey, + rightKey, + }); + const p0 = { x: leftKey.time, y: leftKey.value }; + const p1 = { + x: leftKey.time + rightHandle.dt, + y: leftKey.value + rightHandle.dv, + }; + const p2 = { + x: rightKey.time + leftHandle.dt, + y: rightKey.value + leftHandle.dv, + }; + const p3 = { x: rightKey.time, y: rightKey.value }; + const q0 = lerpPoint({ left: p0, right: p1, progress }); + const q1 = lerpPoint({ left: p1, right: p2, progress }); + const q2 = lerpPoint({ left: p2, right: p3, progress }); + const r0 = lerpPoint({ left: q0, right: q1, progress }); + const r1 = lerpPoint({ left: q1, right: q2, progress }); + const splitPoint = lerpPoint({ left: r0, right: r1, progress }); + leftKeys = [ + ...normalizedChannel.keys.filter((key) => key.time < splitTime), + { + ...leftKey, + rightHandle: { + dt: q0.x - p0.x, + dv: q0.y - p0.y, + }, + }, + { + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + leftHandle: { + dt: r0.x - splitPoint.x, + dv: r0.y - splitPoint.y, + }, + segmentToNext: leftKey.segmentToNext, + tangentMode: leftKey.tangentMode, + }, + ]; + rightKeys = [ + { + id: rightBoundaryId, + time: 0, + value: boundaryValue, + rightHandle: { + dt: r1.x - splitPoint.x, + dv: r1.y - splitPoint.y, + }, + segmentToNext: "bezier", + tangentMode: leftKey.tangentMode, + }, + { + ...rightKey, + time: rightKey.time - splitTime, + leftHandle: { + dt: q2.x - p3.x, + dv: q2.y - p3.y, + }, + }, + ...normalizedChannel.keys + .filter((key) => key.time > rightKey.time) + .map((key) => ({ + ...key, + time: key.time - splitTime, + })), + ]; + } else { + leftKeys = [ + ...leftKeys, + createScalarKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + interpolation: "linear", + }), + ]; + rightKeys = [ + createScalarKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue, + interpolation: getScalarSegmentInterpolation({ + segment: leftKey.segmentToNext, + }), + }), + ...rightKeys, + ]; + } + + return { + leftChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + rightChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + }; + } + + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; } export function splitAnimationsAtTime({ @@ -582,101 +1083,63 @@ export function splitAnimationsAtTime({ return { leftAnimations: undefined, rightAnimations: undefined }; } - const leftChannels: Array<[string, AnimationChannel]> = []; - const rightChannels: Array<[string, AnimationChannel]> = []; + const leftAnimations = cloneAnimationsState({ animations: undefined }); + const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, channel] of Object.entries(animations.channels)) { - if (!channel || channel.keyframes.length === 0) { + for (const [propertyPath, binding] of Object.entries(animations.bindings)) { + if (!binding) { continue; } - const normalizedChannel = normalizeChannel({ channel }); - let leftKeyframes = normalizedChannel.keyframes.filter( - (keyframe) => keyframe.time <= splitTime, - ); - let rightKeyframes = normalizedChannel.keyframes - .filter((keyframe) => keyframe.time >= splitTime) - .map((keyframe) => ({ - ...keyframe, - time: keyframe.time - splitTime, - })); + const leftBinding = cloneAnimationBinding({ binding }); + const rightBinding = cloneAnimationBinding({ binding }); + const leftBoundaryId = generateUUID(); + const rightBoundaryId = generateUUID(); + let hasLeftKeys = false; + let hasRightKeys = false; - const hasBoundaryOnLeft = leftKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }), - ); - if ( - shouldIncludeSplitBoundary && - (!hasBoundaryOnLeft || !hasBoundaryOnRight) - ) { - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: normalizedChannel.keyframes[0].value, + for (const component of binding.components) { + const channel = getChannelById({ + animations, + channelId: component.channelId, }); - const knownPropertyPath = isAnimationPropertyPath(propertyPath) - ? propertyPath - : null; - const boundaryInterpolation = knownPropertyPath - ? getDefaultInterpolationForProperty({ - propertyPath: knownPropertyPath, - }) - : normalizedChannel.valueKind === "discrete" - ? "hold" - : "linear"; - - if (!hasBoundaryOnLeft) { - leftKeyframes = [ - ...leftKeyframes, - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: splitTime, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ]; + const splitResult = + channel?.kind === "discrete" + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: channel as ScalarAnimationChannel | undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); + if (splitResult.leftChannel) { + leftAnimations.channels[component.channelId] = splitResult.leftChannel; + hasLeftKeys = true; } - - if (!hasBoundaryOnRight) { - rightKeyframes = [ - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: 0, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ...rightKeyframes, - ]; + if (splitResult.rightChannel) { + rightAnimations.channels[component.channelId] = splitResult.rightChannel; + hasRightKeys = true; } } - const leftChannel = leftKeyframes.length - ? toChannel({ - keyframes: leftKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - const rightChannel = rightKeyframes.length - ? toChannel({ - keyframes: rightKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - if (leftChannel) { - leftChannels.push([propertyPath, leftChannel]); + if (hasLeftKeys) { + leftAnimations.bindings[propertyPath] = leftBinding; } - if (rightChannel) { - rightChannels.push([propertyPath, rightChannel]); + if (hasRightKeys) { + rightAnimations.bindings[propertyPath] = rightBinding; } } return { - leftAnimations: toAnimation({ channelEntries: leftChannels }), - rightAnimations: toAnimation({ channelEntries: rightChannels }), + leftAnimations: toAnimation({ animations: leftAnimations }), + rightAnimations: toAnimation({ animations: rightAnimations }), }; } @@ -689,15 +1152,31 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = removeKeyframe({ - channel, - keyframeId, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = removeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + }); + } + const hasRemainingKeys = binding.components.some((component) => + hasChannelKeys({ + channel: nextAnimations.channels[component.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + for (const component of binding.components) { + delete nextAnimations.channels[component.channelId]; + } + } + return toAnimation({ + animations: nextAnimations, }); } @@ -712,15 +1191,20 @@ export function retimeElementKeyframe({ keyframeId: string; time: number; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = retimeKeyframe({ - channel, - keyframeId, - time, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = retimeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + time, + }); + } + return toAnimation({ + animations: nextAnimations, }); } diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts deleted file mode 100644 index 3e193131..00000000 --- a/apps/web/src/lib/animation/number-channel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - NumberAnimationChannel, -} from "@/lib/animation/types"; - -export function getNumberChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): NumberAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "number") { - return undefined; - } - - return channel; -} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 6d20e324..ac15805e 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -1,369 +1,377 @@ -import type { - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - AnimationValueKind, - DiscreteValue, - VectorValue, -} from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; -import type { TimelineElement } from "@/lib/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/constants/text-constants"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/lib/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export type NumericRange = NumericSpec; - -export interface AnimationPropertyDefinition { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.position": { - valueKind: "vector", - defaultInterpolation: "linear", - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: value as VectorValue, - }, - } - : element, - }, - "transform.scaleX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }, - "transform.scaleY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }, - "transform.rotate": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }, - opacity: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }, - volume: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }, - color: { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }, - "background.paddingY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }, - "background.offsetX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }, - "background.offsetY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }, - "background.cornerRadius": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }, -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); - if (coercedValue === null) { - return element; - } - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - - if (propertyDefinition.valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ - value, - numericRange: propertyDefinition.numericRange, - }); - } - - if (propertyDefinition.valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (propertyDefinition.valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - if (typeof value === "string" || typeof value === "boolean") { - return value as DiscreteValue; - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + VectorValue, +} from "@/lib/animation/types"; +import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; +import type { TimelineElement } from "@/lib/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/constants/text-constants"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/lib/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { snapToStep } from "@/utils/math"; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.position": { + kind: "vector2", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position : null, + coerceValue: ({ value }) => (isVectorValue(value) ? value : null), + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: value as VectorValue, + }, + } + : element, + }, + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 5c356f28..3de97026 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,135 +1,174 @@ -import type { - AnimationPropertyPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { Transform } from "@/lib/rendering"; -import { - getColorValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, -} from "./interpolation"; -import { getColorChannelForPath } from "./color-channel"; -import { getNumberChannelForPath } from "./number-channel"; -import { getVectorChannelForPath } from "./vector-channel"; - -export function getElementLocalTime({ - timelineTime, - elementStartTime, - elementDuration, -}: { - timelineTime: number; - elementStartTime: number; - elementDuration: number; -}): number { - const localTime = timelineTime - elementStartTime; - if (localTime <= 0) { - return 0; - } - - if (localTime >= elementDuration) { - return elementDuration; - } - - return localTime; -} - -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: getVectorChannelValueAtTime({ - channel: getVectorChannelForPath({ - animations, - propertyPath: "transform.position", - }), - time: safeLocalTime, - fallbackValue: baseTransform.position, - }), - scaleX: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleX", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleY", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.rotate", - }), - time: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "opacity", - }), - time: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): string { - return getColorValueAtTime({ - channel: getColorChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} +import type { + AnimationPropertyPath, + ElementAnimations, +} from "@/lib/animation/types"; +import type { Transform } from "@/lib/rendering"; +import { + type AnimationComponentValue, + composeAnimationValue, + decomposeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, +} from "./interpolation"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.position", + localTime: safeLocalTime, + fallbackValue: baseTransform.position, + }), + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +export function resolveAnimationPathValueAtTime< + T extends number | string | boolean | Transform["position"], +>({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; + localTime: number; + fallbackValue: T; +}): T { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return fallbackValue; + } + + const fallbackComponents = decomposeAnimationValue({ + kind: binding.kind, + value: fallbackValue, + }); + if (!fallbackComponents) { + return fallbackValue; + } + + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations?.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: + fallbackComponents[component.key] ?? + (channel?.kind === "discrete" ? false : 0), + }), + ]; + }), + ) as Record; + return (composeAnimationValue({ + binding, + componentValues, + }) ?? fallbackValue) as T; +} diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts index 6675d443..06c9e140 100644 --- a/apps/web/src/lib/animation/target-resolver.ts +++ b/apps/web/src/lib/animation/target-resolver.ts @@ -1,277 +1,285 @@ -import type { - AnimationInterpolation, - AnimationPath, - AnimationValue, - AnimationValueKind, -} from "@/lib/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/lib/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/lib/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/lib/params"; -import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; -import { getGraphicDefinition } from "@/lib/graphics"; -import type { TimelineElement } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - -export interface AnimationPathDescriptor { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - getBaseValue(): AnimationValue | null; - setBaseValue(value: AnimationValue): TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationValueKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): NumericSpec | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - min: param.min, - max: param.max, - step: param.step, - }; -} - -function coerceParamValue({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - valueKind: propertyDefinition.valueKind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRange: propertyDefinition.numericRange, - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: (value) => { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath: path, - value, - }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, +} from "@/lib/animation/types"; +import { + parseEffectParamPath, + isEffectParamPath, +} from "@/lib/animation/effect-param-channel"; +import { + isGraphicParamPath, + parseGraphicParamPath, +} from "@/lib/animation/graphic-param-channel"; +import type { ParamDefinition } from "@/lib/params"; +import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; +import { getGraphicDefinition } from "@/lib/graphics"; +import type { TimelineElement } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapToStep } from "@/utils/math"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + isAnimationPropertyPath, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; +import { parseColorToLinearRgba } from "./binding-values"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue(value: AnimationValue): AnimationValue | null; + getBaseValue(): AnimationValue | null; + setBaseValue(value: AnimationValue): TimelineElement; +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): AnimationBindingKind { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): AnimationInterpolation { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + value: { + min: param.min, + max: param.max, + step: param.step, + }, + }; +} + +export function coerceAnimationValueForParam({ + param, + value, +}: { + param: ParamDefinition; + value: AnimationValue; +}): number | string | boolean | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: (value) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: (value) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: (value) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: (value) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: (value) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index ac2e0e78..1d07e81d 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,119 +1,160 @@ -export const ANIMATION_PROPERTY_PATHS = [ - "transform.position", - "transform.scaleX", - "transform.scaleY", - "transform.rotate", - "opacity", - "volume", - "color", - "background.color", - "background.paddingX", - "background.paddingY", - "background.offsetX", - "background.offsetY", - "background.cornerRadius", -] as const; - -export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; -export type GraphicParamPath = `params.${string}`; -export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; - -export const ANIMATION_PROPERTY_GROUPS = { - "transform.scale": ["transform.scaleX", "transform.scaleY"], -} as const satisfies Record>; - -export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; - -export type VectorValue = { x: number; y: number }; - -export type AnimationValueKind = "number" | "color" | "discrete" | "vector"; -export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; - -export type ContinuousKeyframeInterpolation = "linear" | "hold"; -export type DiscreteKeyframeInterpolation = "hold"; -export type AnimationInterpolation = - | ContinuousKeyframeInterpolation - | DiscreteKeyframeInterpolation; - -interface BaseAnimationKeyframe< - TValue extends AnimationValue, - TInterpolation extends AnimationInterpolation, -> { - id: string; - time: number; // relative to element start time - value: TValue; - interpolation: TInterpolation; -} - -export interface NumberKeyframe - extends BaseAnimationKeyframe {} - -export interface ColorKeyframe - extends BaseAnimationKeyframe {} - -export interface DiscreteKeyframe - extends BaseAnimationKeyframe {} - -export interface VectorKeyframe - extends BaseAnimationKeyframe {} - -export type AnimationKeyframe = - | NumberKeyframe - | ColorKeyframe - | DiscreteKeyframe - | VectorKeyframe; - -interface BaseAnimationChannel< - TValueKind extends AnimationValueKind, - TKeyframe extends AnimationKeyframe, -> { - valueKind: TValueKind; - keyframes: TKeyframe[]; -} - -export interface NumberAnimationChannel - extends BaseAnimationChannel<"number", NumberKeyframe> {} - -export interface ColorAnimationChannel - extends BaseAnimationChannel<"color", ColorKeyframe> {} - -export interface DiscreteAnimationChannel - extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} - -export interface VectorAnimationChannel - extends BaseAnimationChannel<"vector", VectorKeyframe> {} - -export type AnimationChannel = - | NumberAnimationChannel - | ColorAnimationChannel - | DiscreteAnimationChannel - | VectorAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface ElementAnimations { - channels: ElementAnimationChannelMap; -} - -export interface ElementKeyframe { - propertyPath: AnimationPath; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -} - -export interface SelectedKeyframeRef { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; -} +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; +export type GraphicParamPath = `params.${string}`; +export type EffectParamPath = `effects.${string}.params.${string}`; +export type AnimationPath = + | AnimationPropertyPath + | GraphicParamPath + | EffectParamPath; + +export const ANIMATION_PROPERTY_GROUPS = { + "transform.scale": ["transform.scaleX", "transform.scaleY"], +} as const satisfies Record>; + +export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; + +export type VectorValue = { x: number; y: number }; +export type DiscreteValue = boolean | string; +export type AnimationValue = number | string | boolean | VectorValue; + +export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | ContinuousKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; +export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; +export type ScalarSegmentType = "step" | "linear" | "bezier"; +export type TangentMode = "auto" | "aligned" | "broken" | "flat"; +export type ChannelExtrapolationMode = "hold" | "linear"; + +export interface CurveHandle { + dt: number; + dv: number; +} + +interface BaseAnimationKeyframe { + id: string; + time: number; // relative to element start time + value: TValue; +} + +export interface ScalarAnimationKey extends BaseAnimationKeyframe { + leftHandle?: CurveHandle; + rightHandle?: CurveHandle; + segmentToNext: ScalarSegmentType; + tangentMode: TangentMode; +} + +export interface DiscreteAnimationKey + extends BaseAnimationKeyframe {} + +export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; + +export interface ScalarAnimationChannel { + kind: "scalar"; + keys: ScalarAnimationKey[]; + extrapolation?: { + before: ChannelExtrapolationMode; + after: ChannelExtrapolationMode; + }; +} + +export interface DiscreteAnimationChannel { + kind: "discrete"; + keys: DiscreteAnimationKey[]; +} + +export type AnimationChannel = + | ScalarAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface AnimationBindingComponent { + key: TKey; + channelId: string; +} + +interface BaseAnimationBinding< + TKind extends AnimationBindingKind, + TComponentKey extends string, +> { + path: AnimationPath; + kind: TKind; + components: AnimationBindingComponent[]; +} + +export interface NumberAnimationBinding + extends BaseAnimationBinding<"number", "value"> {} + +export interface Vector2AnimationBinding + extends BaseAnimationBinding<"vector2", "x" | "y"> {} + +export interface ColorAnimationBinding + extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { + colorSpace: "srgb-linear"; +} + +export interface DiscreteAnimationBinding + extends BaseAnimationBinding<"discrete", "value"> {} + +export type AnimationBindingInstance = + | NumberAnimationBinding + | Vector2AnimationBinding + | ColorAnimationBinding + | DiscreteAnimationBinding; + +export interface AnimationBindingByKind { + number: NumberAnimationBinding; + vector2: Vector2AnimationBinding; + color: ColorAnimationBinding; + discrete: DiscreteAnimationBinding; +} + +export type AnimationBindingOfKind = + AnimationBindingByKind[TKind]; + +export type ElementAnimationBindingMap = Record< + string, + AnimationBindingInstance | undefined +>; + +export interface ElementAnimations { + bindings: ElementAnimationBindingMap; + channels: ElementAnimationChannelMap; +} + +export interface ElementKeyframe { + propertyPath: AnimationPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; +} diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts deleted file mode 100644 index f7f65049..00000000 --- a/apps/web/src/lib/animation/vector-channel.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - VectorAnimationChannel, - VectorValue, -} from "@/lib/animation/types"; - -export function isVectorValue(value: unknown): value is VectorValue { - return ( - typeof value === "object" && - value !== null && - "x" in value && - "y" in value && - typeof (value as VectorValue).x === "number" && - typeof (value as VectorValue).y === "number" - ); -} - -export function getVectorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): VectorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "vector") { - return undefined; - } - return channel; -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - if (!channel || channel.keyframes.length === 0) { - return fallbackValue; - } - - const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time); - const first = keyframes[0]; - const last = keyframes[keyframes.length - 1]; - - if (!first || !last) return fallbackValue; - if (time <= first.time) return first.value; - if (time >= last.time) return last.value; - - for (let i = 0; i < keyframes.length - 1; i++) { - const left = keyframes[i]; - const right = keyframes[i + 1]; - if (time < left.time || time > right.time) continue; - - if (left.interpolation === "hold") return left.value; - - const span = right.time - left.time; - if (span === 0) return right.value; - - const t = (time - left.time) / span; - return { - x: left.value.x + (right.value.x - left.value.x) * t, - y: left.value.y + (right.value.y - left.value.y) * t, - }; - } - - return last.value; -} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index b5c7550d..71b2ac21 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,8 +1,9 @@ import { EditorCore } from "@/core"; import { - getChannel, - getChannelValueAtTime, + hasKeyframesForPath, + getKeyframeById, removeElementKeyframe, + resolveAnimationPathValueAtTime, resolveAnimationTarget, } from "@/lib/animation"; import { Command, type CommandResult } from "@/lib/commands/base-command"; @@ -19,17 +20,6 @@ function sampleValueBeforeRemoval({ propertyPath: AnimationPath; keyframeId: string; }): AnimationValue | null { - const channel = getChannel({ - animations: element.animations, - propertyPath, - }); - const keyframe = channel?.keyframes.find( - (candidate) => candidate.id === keyframeId, - ); - if (!channel || !keyframe) { - return null; - } - const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { return null; @@ -39,9 +29,19 @@ function sampleValueBeforeRemoval({ return null; } - return getChannelValueAtTime({ - channel, - time: keyframe.time, + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath, + keyframeId, + }); + if (!keyframe) { + return null; + } + + return resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath, + localTime: keyframe.time, fallbackValue: baseValue, }); } @@ -72,8 +72,10 @@ function removeKeyframeAndPersist({ keyframeId, }); - const isChannelNowEmpty = - getChannel({ animations: nextAnimations, propertyPath }) === undefined; + const isChannelNowEmpty = !hasKeyframesForPath({ + animations: nextAnimations, + propertyPath, + }); const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; const baseElement = shouldPersistToBase diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index edbf5198..d0f01104 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -1,8 +1,13 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { + buildEffectParamPath, + resolveAnimationTarget, + upsertPathKeyframe, +} from "@/lib/animation"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { AnimationInterpolation } from "@/lib/animation/types"; import type { TimelineTrack } from "@/lib/timeline"; export class UpsertEffectParamKeyframeCommand extends Command { @@ -12,8 +17,8 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly effectId: string; private readonly paramKey: string; private readonly time: number; - private readonly value: number; - private readonly interpolation: "linear" | "hold" | undefined; + private readonly value: number | string | boolean; + private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; constructor({ @@ -31,8 +36,8 @@ export class UpsertEffectParamKeyframeCommand extends Command { effectId: string; paramKey: string; time: number; - value: number; - interpolation?: "linear" | "hold"; + value: number | string | boolean; + interpolation?: AnimationInterpolation; keyframeId?: string; }) { super(); @@ -57,14 +62,28 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementPredicate: isVisualElement, update: (element) => { const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - const animations = upsertEffectParamKeyframe({ - animations: element.animations, + 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 }; }, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 98589987..bd56749a 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -73,9 +73,9 @@ export class UpsertKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - valueKind: target.valueKind, + kind: target.kind, defaultInterpolation: target.defaultInterpolation, - numericRange: target.numericRange, + coerceValue: target.coerceValue, }), }; }, diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts index c14a5a42..fc195cba 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -47,7 +47,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { return; } - if (canRecoverSourceAudio({ element: sourceElement })) { + if (canRecoverSourceAudio(sourceElement)) { editor.timeline.updateTracks( updateSourceAudioEnabled({ tracks: this.savedState, @@ -63,7 +63,7 @@ export class ToggleSourceAudioSeparationCommand extends Command { .media .getAssets() .find((asset) => asset.id === sourceElement.mediaId); - if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { + if (!canExtractSourceAudio(sourceElement, mediaAsset)) { return; } if (sourceElement.duration <= 0) { diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts index 41093c88..72b2ca80 100644 --- a/apps/web/src/lib/masks/__tests__/snap.test.ts +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -1,235 +1,237 @@ -import { describe, expect, test } from "bun:test"; -import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; -import { getMaskSnapGeometry } from "@/lib/masks/geometry"; -import { snapMaskInteraction } from "@/lib/masks/snap"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; - -const bounds: ElementBounds = { - cx: 200, - cy: 150, - width: 200, - height: 100, - rotation: 0, -}; - -const canvasSize = { - width: 400, - height: 300, -}; - -const snapThreshold = { - x: 8, - y: 8, -}; - -function buildSplitParams( - overrides: Partial = {}, -): SplitMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - rotation: 0, - ...overrides, - }; -} - -function buildRectangleParams( - overrides: Partial = {}, -): RectangleMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - width: 0.4, - height: 0.2, - rotation: 0, - scale: 1, - ...overrides, - }; -} - -function sortSegment( - segment: [{ x: number; y: number }, { x: number; y: number }], -): [{ x: number; y: number }, { x: number; y: number }] { - return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ - { x: number; y: number }, - { x: number; y: number }, - ]; -} - -describe("mask geometry", () => { - test("resolves split mask center from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildSplitParams({ - centerX: 0.25, - centerY: -0.5, - rotation: 45, - }), - bounds, - }), - ).toEqual({ - position: { x: 50, y: -50 }, - size: { width: 0, height: 0 }, - rotation: 45, - }); - }); - - test("resolves box mask center and size from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildRectangleParams({ - centerX: -0.25, - centerY: 0.5, - width: 0.5, - height: 0.6, - rotation: 30, - }), - bounds, - }), - ).toEqual({ - position: { x: -50, y: 50 }, - size: { width: 100, height: 60 }, - rotation: 30, - }); - }); - - test("returns a vertical split stroke segment for rotation 0", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams(), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 0"); - } - expect(sortSegment(segment)).toEqual([ - { x: bounds.width / 2, y: 0 }, - { x: bounds.width / 2, y: bounds.height }, - ]); - }); - - test("returns a horizontal split stroke segment for rotation 90", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams({ rotation: 90 }), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 90"); - } - expect(sortSegment(segment)).toEqual([ - { x: 0, y: bounds.height / 2 }, - { x: bounds.width, y: bounds.height / 2 }, - ]); - }); -}); - -describe("mask snapping", () => { - test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - proposedParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBe(0); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 0 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - centerX: 0.29, - centerY: 0.03, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBeCloseTo(0.3); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 100 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - rotation: 88, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.rotation).toBe(90); - expect(result.activeLines).toEqual([]); - }); - - test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.98, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); - - test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.99, - height: 0.495, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.params.height).toBe(0.5); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; +import { getMaskSnapGeometry } from "@/lib/masks/geometry"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function sortSegment( + segment: [{ x: number; y: number }, { x: number; y: number }], +): [{ x: number; y: number }, { x: number; y: number }] { + return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ + { x: number; y: number }, + { x: number; y: number }, + ]; +} + +describe("mask geometry", () => { + test("resolves split mask center from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildSplitParams({ + centerX: 0.25, + centerY: -0.5, + rotation: 45, + }), + bounds, + }), + ).toEqual({ + position: { x: 50, y: -50 }, + size: { width: 0, height: 0 }, + rotation: 45, + }); + }); + + test("resolves box mask center and size from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildRectangleParams({ + centerX: -0.25, + centerY: 0.5, + width: 0.5, + height: 0.6, + rotation: 30, + }), + bounds, + }), + ).toEqual({ + position: { x: -50, y: 50 }, + size: { width: 100, height: 60 }, + rotation: 30, + }); + }); + + test("returns a vertical split stroke segment for rotation 0", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams(), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 0"); + } + expect(sortSegment(segment)).toEqual([ + { x: bounds.width / 2, y: 0 }, + { x: bounds.width / 2, y: bounds.height }, + ]); + }); + + test("returns a horizontal split stroke segment for rotation 90", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams({ rotation: 90 }), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 90"); + } + expect(sortSegment(segment)).toEqual([ + { x: 0, y: bounds.height / 2 }, + { x: bounds.width, y: bounds.height / 2 }, + ]); + }); +}); + +describe("mask snapping", () => { + test("snaps split mask movement using the shared position pipeline", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + proposedParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBe(0); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps box mask movement against element center and edges", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + centerX: 0.29, + centerY: 0.03, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBeCloseTo(0.3); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 100 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps mask rotation through the shared rotation path", () => { + const result = snapMaskInteraction({ + handleId: "rotation", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + rotation: 88, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.rotation).toBe(90); + expect(result.activeLines).toEqual([]); + }); + + test("snaps edge resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.98, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps corner resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "bottom-right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.99, + height: 0.495, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.params.height).toBe(0.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); +}); diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts index 173b6402..ca54f4cd 100644 --- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { upsertElementKeyframe } from "@/lib/animation"; +import type { MediaAsset } from "@/lib/media/types"; import type { AudioElement, VideoElement } from "@/lib/timeline"; import { buildSeparatedAudioElement, @@ -25,32 +27,7 @@ describe("audio separation", () => { volume: -6, muted: true, retime: { rate: 1.25, maintainPitch: true }, - animations: { - channels: { - volume: { - valueKind: "number", - keyframes: [ - { - id: "volume-keyframe", - time: 2, - value: -12, - interpolation: "linear", - }, - ], - }, - opacity: { - valueKind: "number", - keyframes: [ - { - id: "opacity-keyframe", - time: 1, - value: 0.5, - interpolation: "linear", - }, - ], - }, - }, - }, + animations: buildAnimations(), }); const separatedAudioElement = buildSeparatedAudioElement({ @@ -71,21 +48,22 @@ describe("audio separation", () => { muted: true, retime: { rate: 1.25, maintainPitch: true }, }); - expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([ "volume", ]); + expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + "volume:value", + ]); expect( - separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id, + separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id, ).not.toBe("volume-keyframe"); }); test("skips source audio collection when the source clip is separated", () => { - const mediaAsset = { + const mediaAsset: MediaAsset = { id: "media-1", type: "video", name: "Clip", - size: 1, - lastModified: 1, file: new File(["video"], "clip.mp4", { type: "video/mp4" }), url: "blob:clip", hasAudio: true, @@ -145,3 +123,23 @@ function buildVideoElement( ...overrides, }; } + +function buildAnimations() { + const withVolume = upsertElementKeyframe({ + animations: undefined, + propertyPath: "volume", + time: 2, + value: -12, + interpolation: "linear", + keyframeId: "volume-keyframe", + }); + + return upsertElementKeyframe({ + animations: withVolume, + propertyPath: "opacity", + time: 1, + value: 0.5, + interpolation: "linear", + keyframeId: "opacity-keyframe", + }); +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index b92cb79b..fc0ead2d 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -1,4 +1,4 @@ -import { cloneAnimations, getChannel } from "@/lib/animation"; +import { cloneAnimations } from "@/lib/animation"; import type { ElementAnimations } from "@/lib/animation/types"; import type { MediaAsset } from "@/lib/media/types"; import { DEFAULTS } from "@/lib/timeline/defaults"; @@ -25,13 +25,10 @@ export function isSourceAudioSeparated({ return !isSourceAudioEnabled({ element }); } -export function canExtractSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { +export function canExtractSourceAudio( + element: TimelineElement, + mediaAsset: MediaAsset | null | undefined, +): element is VideoElement { return ( element.type === "video" && isSourceAudioEnabled({ element }) && @@ -40,25 +37,17 @@ export function canExtractSourceAudio({ ); } -export function canRecoverSourceAudio({ - element, -}: { - element: TimelineElement; -}): element is VideoElement { +export function canRecoverSourceAudio( + element: TimelineElement, +): element is VideoElement { return element.type === "video" && isSourceAudioSeparated({ element }); } -export function canToggleSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { - return ( - canRecoverSourceAudio({ element }) || - canExtractSourceAudio({ element, mediaAsset }) - ); +export function canToggleSourceAudio( + element: TimelineElement, + mediaAsset: MediaAsset | null | undefined, +): element is VideoElement { + return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset); } export function doesElementHaveEnabledAudio({ @@ -117,16 +106,27 @@ function cloneVolumeAnimations({ }: { animations: ElementAnimations | undefined; }): ElementAnimations | undefined { - const volumeChannel = getChannel({ animations, propertyPath: "volume" }); - if (!volumeChannel) { + const volumeBinding = animations?.bindings.volume; + if (!volumeBinding) { + return undefined; + } + + const subsetChannels = Object.fromEntries( + volumeBinding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + return channel ? [[component.channelId, channel] as const] : []; + }), + ); + if (Object.keys(subsetChannels).length === 0) { return undefined; } return cloneAnimations({ animations: { - channels: { - volume: volumeChannel, + bindings: { + volume: volumeBinding, }, + channels: subsetChannels, }, shouldRegenerateKeyframeIds: true, }); diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts new file mode 100644 index 00000000..18b815b0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; + +describe("V21 to V22 Migration", () => { + test("migrates legacy animation channels to bindings and component channels", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v21-animations", + version: 21, + scenes: [ + { + id: "scene-1", + tracks: [ + { + id: "track-1", + elements: [ + { + id: "element-1", + type: "text", + animations: { + channels: { + opacity: { + valueKind: "number", + keyframes: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + interpolation: "linear", + }, + ], + }, + "transform.position": { + valueKind: "vector", + keyframes: [ + { + id: "position-1", + time: 2, + value: { x: 10, y: 20 }, + interpolation: "hold", + }, + ], + }, + color: { + valueKind: "color", + keyframes: [ + { + id: "color-1", + time: 3, + value: "#ff0000", + interpolation: "linear", + }, + ], + }, + "effects.effect-1.params.enabled": { + valueKind: "discrete", + keyframes: [ + { + id: "enabled-1", + time: 4, + value: true, + interpolation: "hold", + }, + ], + }, + }, + }, + }, + ], + }, + ], + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(22); + + const scenes = result.project.scenes as Array>; + const tracks = scenes[0].tracks as Array>; + const elements = tracks[0].elements as Array>; + const animations = elements[0].animations as Record; + const bindings = animations.bindings as Record>; + const channels = animations.channels as Record>; + + expect(bindings.opacity).toEqual({ + path: "opacity", + kind: "number", + components: [{ key: "value", channelId: "opacity:value" }], + }); + expect(bindings["transform.position"]).toEqual({ + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }); + expect(bindings.color).toEqual({ + path: "color", + kind: "color", + colorSpace: "srgb-linear", + components: [ + { key: "r", channelId: "color:r" }, + { key: "g", channelId: "color:g" }, + { key: "b", channelId: "color:b" }, + { key: "a", channelId: "color:a" }, + ], + }); + expect(bindings["effects.effect-1.params.enabled"]).toEqual({ + path: "effects.effect-1.params.enabled", + kind: "discrete", + components: [ + { + key: "value", + channelId: "effects.effect-1.params.enabled:value", + }, + ], + }); + + expect(channels["opacity:value"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:x"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 10, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:y"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 20, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:r"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:g"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:b"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:a"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["effects.effect-1.params.enabled:value"]).toEqual({ + kind: "discrete", + keys: [ + { + id: "enabled-1", + time: 4, + value: true, + }, + ], + }); + }); + + test("skips projects already on v22", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v22", + version: 22, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v22"); + }); + + test("skips projects not on v21", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v20", + version: 20, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v21"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index 66e87713..b05d1837 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,92 +1,94 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; -import { v5Project } from "./fixtures"; - -describe("V5 to V6 Migration", () => { - test("converts number bookmarks to Bookmark objects", async () => { - const result = transformProjectV5ToV6({ - project: v5Project as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(6); - - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 2.0 }, - { time: 5.5 }, - { time: 12.0 }, - ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); - }); - - test("skips projects that are already v6", () => { - const result = transformProjectV5ToV6({ - project: { - ...v5Project, - version: 6, - scenes: [ - { - ...(v5Project as { scenes: unknown[] }).scenes[0], - bookmarks: [{ time: 2 }, { time: 5 }], - }, - ], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v6"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV5ToV6({ - project: { - version: 5, - scenes: [], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); - - test("preserves existing Bookmark objects with note, color, duration", () => { - const projectWithRichBookmarks = { - ...v5Project, - version: 5, - scenes: [ - { - ...(v5Project as { scenes: Array> }) - .scenes[0], - bookmarks: [ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ], - }, - ], - }; - - const result = transformProjectV5ToV6({ - project: projectWithRichBookmarks as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; +import { v5Project } from "./fixtures"; + +describe("V5 to V6 Migration", () => { + test("converts number bookmarks to Bookmark objects", async () => { + const result = transformProjectV5ToV6({ + project: v5Project as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(6); + + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 2.0 }, + { time: 5.5 }, + { time: 12.0 }, + ]); + + const introScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[1]; + expect(introScene.bookmarks).toEqual([]); + }); + + test("skips projects that are already v6", () => { + const firstScene = (v5Project as { scenes: Array> }) + .scenes[0]; + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...firstScene, + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v6"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV5ToV6({ + project: { + version: 5, + scenes: [], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing Bookmark objects with note, color, duration", () => { + const projectWithRichBookmarks = { + ...v5Project, + version: 5, + scenes: [ + { + ...(v5Project as { scenes: Array> }) + .scenes[0], + bookmarks: [ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ], + }, + ], + }; + + const result = transformProjectV5ToV6({ + project: projectWithRichBookmarks as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ]); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index ea596435..ef41673e 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18"; import { V18toV19Migration } from "./v18-to-v19"; import { V19toV20Migration } from "./v19-to-v20"; import { V20toV21Migration } from "./v20-to-v21"; +import { V21toV22Migration } from "./v21-to-v22"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 21; +export const CURRENT_PROJECT_VERSION = 22; export const migrations = [ new V0toV1Migration(), @@ -47,4 +48,5 @@ export const migrations = [ new V18toV19Migration(), new V19toV20Migration(), new V20toV21Migration(), + new V21toV22Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts new file mode 100644 index 00000000..15fd9edf --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts @@ -0,0 +1,511 @@ +import { parseColorToLinearRgba } from "@/lib/animation/binding-values"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const COLOR_COMPONENT_KEYS = ["r", "g", "b", "a"] as const; +type LegacyInterpolation = "linear" | "hold"; + +interface LegacyScalarKeyframe { + id: string; + time: number; + value: number; + interpolation: LegacyInterpolation; +} + +interface LegacyDiscreteKeyframe { + id: string; + time: number; + value: string | boolean; +} + +interface LegacyVectorValue { + x: number; + y: number; +} + +interface LegacyVectorKeyframe { + id: string; + time: number; + value: LegacyVectorValue; + interpolation: LegacyInterpolation; +} + +interface MigratedAnimationChannel { + binding: ProjectRecord; + channels: Record; +} + +export function transformProjectV21ToV22({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 22) { + return { project, skipped: true, reason: "already v22" }; + } + if (version !== 21) { + return { project, skipped: true, reason: "not v21" }; + } + + return { + project: { + ...migrateProjectAnimations({ project }), + version: 22, + }, + skipped: false, + }; +} + +function migrateProjectAnimations({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenes = project.scenes; + if (!Array.isArray(scenes)) { + return project; + } + + return { + ...project, + scenes: scenes.map((scene) => migrateSceneAnimations({ scene })), + }; +} + +function migrateSceneAnimations({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracks = scene.tracks; + if (!Array.isArray(tracks)) { + return scene; + } + + return { + ...scene, + tracks: tracks.map((track) => migrateTrackAnimations({ track })), + }; +} + +function migrateTrackAnimations({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const elements = track.elements; + if (!Array.isArray(elements)) { + return track; + } + + return { + ...track, + elements: elements.map((element) => migrateElementAnimations({ element })), + }; +} + +function migrateElementAnimations({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const animations = element.animations; + if (!isRecord(animations)) { + return element; + } + + if (isRecord(animations.bindings)) { + return element; + } + + const migratedAnimations = migrateLegacyAnimations({ animations }); + if (!migratedAnimations) { + const { animations: _unusedAnimations, ...elementWithoutAnimations } = element; + return elementWithoutAnimations; + } + + return { + ...element, + animations: migratedAnimations, + }; +} + +function migrateLegacyAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord | null { + const legacyChannels = animations.channels; + if (!isRecord(legacyChannels)) { + return null; + } + + const nextBindings: Record = {}; + const nextChannels: Record = {}; + + for (const [propertyPath, channel] of Object.entries(legacyChannels)) { + const migratedChannel = migrateLegacyChannel({ + propertyPath, + channel, + }); + if (!migratedChannel) { + continue; + } + + nextBindings[propertyPath] = migratedChannel.binding; + Object.assign(nextChannels, migratedChannel.channels); + } + + if (Object.keys(nextBindings).length === 0) { + return null; + } + + return { + bindings: nextBindings, + channels: nextChannels, + }; +} + +function migrateLegacyChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: unknown; +}): MigratedAnimationChannel | null { + if (!isRecord(channel)) { + return null; + } + + switch (channel.valueKind) { + case "number": + return migrateNumberChannel({ propertyPath, channel }); + case "discrete": + return migrateDiscreteChannel({ propertyPath, channel }); + case "vector": + return migrateVectorChannel({ propertyPath, channel }); + case "color": + return migrateColorChannel({ propertyPath, channel }); + default: + return null; + } +} + +function migrateNumberChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is number => + typeof value === "number" && Number.isFinite(value), + }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "number", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => toScalarKeyframe({ keyframe })), + }, + }, + }; +} + +function migrateDiscreteChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyDiscreteKeyframes({ channel }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "discrete", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "discrete", + keys: legacyKeys.map((keyframe) => ({ + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + })), + }, + }, + }; +} + +function migrateVectorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: isLegacyVectorValue, + }); + if (legacyKeys.length === 0) { + return null; + } + + const xChannelId = buildChannelId({ propertyPath, componentKey: "x" }); + const yChannelId = buildChannelId({ propertyPath, componentKey: "y" }); + + return { + binding: { + path: propertyPath, + kind: "vector2", + components: [ + { key: "x", channelId: xChannelId }, + { key: "y", channelId: yChannelId }, + ], + }, + channels: { + [xChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.x, + }, + }), + ), + }, + [yChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.y, + }, + }), + ), + }, + }, + }; +} + +function migrateColorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is string => typeof value === "string", + }); + if (legacyKeys.length === 0) { + return null; + } + + const colorKeys = legacyKeys.flatMap((keyframe) => { + const linearRgba = parseColorToLinearRgba({ color: keyframe.value }); + if (!linearRgba) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + interpolation: keyframe.interpolation, + values: linearRgba, + }, + ]; + }); + if (colorKeys.length === 0) { + return null; + } + + const channels = Object.fromEntries( + COLOR_COMPONENT_KEYS.map((componentKey) => [ + buildChannelId({ propertyPath, componentKey }), + { + kind: "scalar", + keys: colorKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + id: keyframe.id, + time: keyframe.time, + value: keyframe.values[componentKey], + interpolation: keyframe.interpolation, + }, + }), + ), + }, + ]), + ); + + return { + binding: { + path: propertyPath, + kind: "color", + colorSpace: "srgb-linear", + components: COLOR_COMPONENT_KEYS.map((componentKey) => ({ + key: componentKey, + channelId: buildChannelId({ propertyPath, componentKey }), + })), + }, + channels, + }; +} + +function getLegacyScalarKeyframes({ + channel, + isValidValue, +}: { + channel: ProjectRecord; + isValidValue: (value: unknown) => value is TValue; +}): Array<{ + id: string; + time: number; + value: TValue; + interpolation: LegacyInterpolation; +}> { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + !isValidValue(keyframe.value) + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: + keyframe.interpolation === "hold" ? "hold" : "linear", + }, + ]; + }); +} + +function getLegacyDiscreteKeyframes({ + channel, +}: { + channel: ProjectRecord; +}): LegacyDiscreteKeyframe[] { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + (typeof keyframe.value !== "string" && typeof keyframe.value !== "boolean") + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + }, + ]; + }); +} + +function isLegacyVectorValue(value: unknown): value is LegacyVectorValue { + return ( + isRecord(value) && + typeof value.x === "number" && + Number.isFinite(value.x) && + typeof value.y === "number" && + Number.isFinite(value.y) + ); +} + +function toScalarKeyframe({ + keyframe, +}: { + keyframe: LegacyScalarKeyframe; +}): ProjectRecord { + return { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + segmentToNext: + keyframe.interpolation === "hold" ? "step" : "linear", + tangentMode: "flat", + }; +} + +function buildChannelId({ + propertyPath, + componentKey, +}: { + propertyPath: string; + componentKey: string; +}) { + return `${propertyPath}:${componentKey}`; +} diff --git a/apps/web/src/services/storage/migrations/v21-to-v22.ts b/apps/web/src/services/storage/migrations/v21-to-v22.ts new file mode 100644 index 00000000..212109a5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v21-to-v22.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV21ToV22 } from "./transformers/v21-to-v22"; + +export class V21toV22Migration extends StorageMigration { + from = 21; + to = 22; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV21ToV22({ project }); + } +} From 099746633183cd061fc89a63aeb9fcfb909f6db2 Mon Sep 17 00:00:00 2001 From: Maze Date: Sun, 5 Apr 2026 13:37:29 +0200 Subject: [PATCH 07/26] fix: subscribe to playback time so keyframe button updates on frame change Made-with: Cursor --- .../editor/panels/properties/hooks/use-element-playhead.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 95f338e1..a900cb13 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -9,8 +9,7 @@ export function useElementPlayhead({ startTime: number; duration: number; }) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); + const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); const localTime = getElementLocalTime({ timelineTime: playheadTime, elementStartTime: startTime, From 20235d0724a70e6787b6cbdbfa054b1c5d8f62e9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 5 Apr 2026 19:04:39 +0200 Subject: [PATCH 08/26] chore: remove comment --- apps/web/drizzle.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 494fd35d..a4b933c6 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -2,7 +2,6 @@ import type { Config } from "drizzle-kit"; import * as dotenv from "dotenv"; import { webEnv } from "@/lib/env/web"; -// Load the right env file based on environment if (webEnv.NODE_ENV === "production") { dotenv.config({ path: ".env.production" }); } else { From 44d8c4c8764d49a8e8d0ae713432c07f07cb7597 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 00:02:23 +0200 Subject: [PATCH 09/26] refactor: unify element updates into pipeline and centralize ripple --- .../hooks/use-keyframed-number-property.ts | 2 +- .../hooks/use-keyframed-vector-property.ts | 4 +- .../panels/properties/tabs/blending-tab.tsx | 8 +- .../panels/properties/tabs/graphic-tab.tsx | 4 +- .../panels/properties/tabs/masks-tab.tsx | 2 +- .../panels/properties/tabs/text-tab.tsx | 10 +- .../components/providers/editor-provider.tsx | 8 + apps/web/src/core/managers/commands.ts | 33 ++- .../web/src/core/managers/timeline-manager.ts | 203 +++++++------ .../src/hooks/actions/use-editor-actions.ts | 2 - .../element/use-element-interaction.ts | 5 - .../timeline/element/use-element-resize.ts | 5 - .../timeline/element/delete-elements.ts | 57 ++-- .../lib/commands/timeline/element/index.ts | 8 +- .../timeline/element/move-elements.ts | 14 +- .../commands/timeline/element/retime/index.ts | 1 - .../element/retime/update-element-retime.ts | 115 -------- .../timeline/element/split-elements.ts | 19 +- .../timeline/element/toggle-elements-muted.ts | 57 ---- .../element/toggle-elements-visibility.ts | 48 ---- .../element/update-element-duration.ts | 58 ---- .../element/update-element-start-time.ts | 70 ----- .../timeline/element/update-element-trim.ts | 116 -------- .../timeline/element/update-element.ts | 48 ---- .../timeline/element/update-elements.ts | 71 +++++ apps/web/src/lib/ripple/apply.ts | 65 +++++ apps/web/src/lib/ripple/diff.ts | 272 ++++++++++++++++++ apps/web/src/lib/ripple/index.ts | 4 + apps/web/src/lib/ripple/shift.ts | 17 ++ .../audio-separation/__tests__/index.test.ts | 44 ++- .../lib/timeline/audio-separation/index.ts | 8 +- apps/web/src/lib/timeline/index.ts | 1 - apps/web/src/lib/timeline/ripple-utils.ts | 17 -- apps/web/src/lib/timeline/update-pipeline.ts | 193 +++++++++++++ bun.lock | 6 +- package.json | 2 +- 36 files changed, 841 insertions(+), 756 deletions(-) delete mode 100644 apps/web/src/lib/commands/timeline/element/retime/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-duration.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-start-time.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-trim.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-elements.ts create mode 100644 apps/web/src/lib/ripple/apply.ts create mode 100644 apps/web/src/lib/ripple/diff.ts create mode 100644 apps/web/src/lib/ripple/index.ts create mode 100644 apps/web/src/lib/ripple/shift.ts delete mode 100644 apps/web/src/lib/timeline/ripple-utils.ts create mode 100644 apps/web/src/lib/timeline/update-pipeline.ts diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index ddd3d2e4..0c10d443 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -144,7 +144,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value: nextValue }), + patch: buildBaseUpdates({ value: nextValue }), }, ], }); diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts index a342859c..7ee1f221 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({ } editor.timeline.updateElements({ updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, ], }); }; @@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({ } editor.timeline.updateElements({ updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, ], }); }; diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index a69acebf..f8e5397d 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -41,7 +41,7 @@ type BlendingElement = { animations?: ElementAnimations; }; -const BLEND_MODE_GROUPS = [ +const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ [{ value: "normal", label: "Normal" }], [ { value: "darken", label: "Darken" }, @@ -99,7 +99,7 @@ export function BlendingTab({ ], }); - const commitBlendMode = (value: string) => { + const commitBlendMode = (value: BlendMode) => { if (editor.timeline.isPreviewActive()) { editor.timeline.commitPreview(); } else { @@ -108,7 +108,7 @@ export function BlendingTab({ { trackId, elementId: element.id, - updates: { blendMode: value as BlendMode }, + patch: { blendMode: value }, }, ], }); @@ -209,7 +209,7 @@ export function BlendingTab({ key={option.value} value={option.value} onPointerEnter={() => - previewBlendMode({ value: option.value as BlendMode }) + previewBlendMode({ value: option.value }) } > {option.label} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx index 4593050e..c61c9cd7 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -123,7 +123,7 @@ function StrokeSection({ { trackId, elementId: element.id, - updates: { params: { ...element.params, strokeWidth: 0 } }, + patch: { params: { ...element.params, strokeWidth: 0 } }, }, ], }); @@ -133,7 +133,7 @@ function StrokeSection({ { trackId, elementId: element.id, - updates: { + patch: { params: { ...element.params, strokeWidth: lastStrokeWidth.current, diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index d416dc28..f48a50cb 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -165,7 +165,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { { trackId, elementId: element.id, - updates: { + patch: { masks: [ buildDefaultMaskInstance({ maskType, diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index 11412c04..5fffbcec 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -165,7 +165,7 @@ function TypographySection({ { trackId, elementId: element.id, - updates: { fontFamily: value }, + patch: { fontFamily: value }, }, ], }) @@ -188,7 +188,7 @@ function TypographySection({ { trackId, elementId: element.id, - updates: { + patch: { fontSize: DEFAULTS.text.element.fontSize, }, }, @@ -291,7 +291,7 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { letterSpacing: DEFAULTS.text.letterSpacing }, + patch: { letterSpacing: DEFAULTS.text.letterSpacing }, }, ], }) @@ -317,7 +317,7 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { lineHeight: DEFAULTS.text.lineHeight }, + patch: { lineHeight: DEFAULTS.text.lineHeight }, }, ], }) @@ -512,7 +512,7 @@ function BackgroundSection({ { trackId, elementId: element.id, - updates: { + patch: { background: { ...element.background, enabled, diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 20476a13..12b64853 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -7,6 +7,7 @@ import { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import { useKeybindingsListener } from "@/hooks/use-keybindings"; import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { useTimelineStore } from "@/stores/timeline-store"; import { useEditorActions } from "@/hooks/actions/use-editor-actions"; import { loadFontAtlas } from "@/lib/fonts/google-fonts"; import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer"; @@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { function EditorRuntimeBindings() { const editor = useEditor(); + const rippleEditingEnabled = useTimelineStore( + (state) => state.rippleEditingEnabled, + ); + + useEffect(() => { + editor.command.isRippleEnabled = rippleEditingEnabled; + }, [editor, rippleEditingEnabled]); useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index 918fc33e..a3665852 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -1,6 +1,7 @@ import type { EditorCore } from "@/core"; import type { Command, CommandResult } from "@/lib/commands"; -import type { ElementRef } from "@/lib/timeline/types"; +import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple"; +import type { TimelineTrack, ElementRef } from "@/lib/timeline/types"; interface CommandHistoryEntry { command: Command; @@ -9,6 +10,7 @@ interface CommandHistoryEntry { } export class CommandManager { + public isRippleEnabled = false; private history: CommandHistoryEntry[] = []; private redoStack: CommandHistoryEntry[] = []; private reactors: Array<() => void> = []; @@ -16,8 +18,10 @@ export class CommandManager { constructor(private editor: EditorCore) {} execute({ command }: { command: Command }): Command { + const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null; const previousSelection = this.getSelectionSnapshot(); const result = command.execute(); + this.applyRippleIfEnabled({ beforeTracks }); const selectionOverride = this.applySelectionOverride(result); this.runReactors(); this.history.push({ @@ -67,8 +71,10 @@ export class CommandManager { return; } + const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null; const previousSelection = this.getSelectionSnapshot(); const result = entry.command.redo(); + this.applyRippleIfEnabled({ beforeTracks }); const selectionOverride = this.applySelectionOverride(result); this.runReactors(); @@ -113,4 +119,29 @@ export class CommandManager { reactor(); } } + + private applyRippleIfEnabled({ + beforeTracks, + }: { + beforeTracks: TimelineTrack[] | null; + }): void { + if (!this.isRippleEnabled || !beforeTracks) { + return; + } + + const afterTracks = this.editor.timeline.getTracks(); + const adjustments = computeRippleAdjustments({ + beforeTracks, + afterTracks, + }); + if (adjustments.length === 0) { + return; + } + + const tracksWithRipple = applyRippleAdjustments({ + tracks: afterTracks, + adjustments, + }); + this.editor.timeline.updateTracks(tracksWithRipple); + } } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 6f262e57..a6f2462a 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -7,32 +7,31 @@ import type { ClipboardItem, RetimeConfig, } from "@/lib/timeline"; +import { calculateTotalDuration } from "@/lib/timeline"; +import { + canElementBeHidden, + canElementHaveAudio, +} from "@/lib/timeline/element-utils"; import type { AnimationPath, AnimationInterpolation, AnimationValue, } from "@/lib/animation/types"; -import { calculateTotalDuration } from "@/lib/timeline"; import { getLastFrameTime } from "opencut-wasm"; +import { BatchCommand } from "@/lib/commands"; import { AddTrackCommand, RemoveTrackCommand, ToggleTrackMuteCommand, ToggleTrackVisibilityCommand, InsertElementCommand, - UpdateElementTrimCommand, - UpdateElementDurationCommand, DeleteElementsCommand, DuplicateElementsCommand, - ToggleElementsVisibilityCommand, - ToggleElementsMutedCommand, - UpdateElementCommand, + UpdateElementsCommand, SplitElementsCommand, PasteCommand, - UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, - UpdateElementRetimeCommand, UpsertKeyframeCommand, RemoveKeyframeCommand, RetimeKeyframeCommand, @@ -47,7 +46,6 @@ import { RemoveEffectParamKeyframeCommand, ToggleSourceAudioSeparationCommand, } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; export class TimelineManager { @@ -80,7 +78,6 @@ export class TimelineManager { startTime, duration, pushHistory = true, - rippleEnabled = false, }: { elementId: string; trimStart: number; @@ -88,44 +85,33 @@ export class TimelineManager { startTime?: number; duration?: number; pushHistory?: boolean; - rippleEnabled?: boolean; }): void { - const command = new UpdateElementTrimCommand({ - elementId, + const trackId = this.findTrackIdForElement({ elementId }); + if (!trackId) { + return; + } + + const nextUpdates: Partial = { trimStart, trimEnd, - startTime, - duration, - rippleEnabled, - }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); + }; + if (startTime !== undefined) { + nextUpdates.startTime = startTime; + } + if (duration !== undefined) { + nextUpdates.duration = duration; } - } - updateElementDuration({ - trackId, - elementId, - duration, - pushHistory = true, - }: { - trackId: string; - elementId: string; - duration: number; - pushHistory?: boolean; - }): void { - const command = new UpdateElementDurationCommand({ - trackId, - elementId, - duration, + this.updateElements({ + updates: [ + { + trackId, + elementId, + patch: nextUpdates, + }, + ], + pushHistory, }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); - } } updateElementRetime({ @@ -139,30 +125,18 @@ export class TimelineManager { retime?: RetimeConfig; pushHistory?: boolean; }): void { - const command = new UpdateElementRetimeCommand({ - trackId, - elementId, - retime, + this.updateElements({ + updates: [ + { + trackId, + elementId, + patch: { + retime, + }, + }, + ], + pushHistory, }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); - } - } - - updateElementStartTime({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }): void { - const command = new UpdateElementStartTimeCommand({ - elements, - startTime, - }); - this.editor.command.execute({ command }); } moveElement({ @@ -171,14 +145,12 @@ export class TimelineManager { elementId, newStartTime, createTrack, - rippleEnabled = false, }: { sourceTrackId: string; targetTrackId: string; elementId: string; newStartTime: number; createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; }): void { const command = new MoveElementCommand({ sourceTrackId, @@ -186,7 +158,6 @@ export class TimelineManager { elementId, newStartTime, createTrack, - rippleEnabled, }); this.editor.command.execute({ command }); } @@ -205,18 +176,15 @@ export class TimelineManager { elements, splitTime, retainSide = "both", - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; - rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ elements, splitTime, retainSide, - rippleEnabled, }); this.editor.command.execute({ command }); return command.getRightSideElements(); @@ -273,12 +241,10 @@ export class TimelineManager { deleteElements({ elements, - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; - rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand({ elements, rippleEnabled }); + const command = new DeleteElementsCommand({ elements }); this.editor.command.execute({ command }); } @@ -303,20 +269,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial; + patch: Partial; }>; pushHistory?: boolean; }): void { - const commands = updates.map( - ({ trackId, elementId, updates: elementUpdates }) => - new UpdateElementCommand({ - trackId, - elementId, - updates: elementUpdates, - }), - ); - const command = - commands.length === 1 ? commands[0] : new BatchCommand(commands); + if (updates.length === 0) { + return; + } + + const command = new UpdateElementsCommand({ + updates, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -679,8 +642,27 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new ToggleElementsVisibilityCommand(elements); - this.editor.command.execute({ command }); + const shouldHide = elements.some(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + return element && canElementBeHidden(element) && !element.hidden; + }); + + const nextUpdates = elements.flatMap(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + if (!element || !canElementBeHidden(element)) { + return []; + } + + return [ + { + trackId, + elementId, + patch: { hidden: shouldHide }, + }, + ]; + }); + + this.updateElements({ updates: nextUpdates }); } toggleElementsMuted({ @@ -688,8 +670,27 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new ToggleElementsMutedCommand(elements); - this.editor.command.execute({ command }); + const shouldMute = elements.some(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + return element && canElementHaveAudio(element) && !element.muted; + }); + + const nextUpdates = elements.flatMap(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + if (!element || !canElementHaveAudio(element)) { + return []; + } + + return [ + { + trackId, + elementId, + patch: { muted: shouldMute }, + }, + ]; + }); + + this.updateElements({ updates: nextUpdates }); } getTracks(): TimelineTrack[] { @@ -712,6 +713,30 @@ export class TimelineManager { }); } + private getElementByRef({ + trackId, + elementId, + }: { + trackId: string; + elementId: string; + }): TimelineElement | undefined { + return this.getTrackById({ trackId })?.elements.find( + (element) => element.id === elementId, + ); + } + + private findTrackIdForElement({ + elementId, + }: { + elementId: string; + }): string | null { + return ( + this.getTracks().find((track) => + track.elements.some((element) => element.id === elementId), + )?.id ?? null + ); + } + updateTracks(newTracks: TimelineTrack[]): void { this.previewOverlay.clear(); this.previewTracks = null; diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 87aa6b48..be2995dd 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -212,7 +212,6 @@ export function useEditorActions() { elements: elementsToSplit, splitTime: currentTime, retainSide: "right", - rippleEnabled: rippleEditingEnabled, }); if (rippleEditingEnabled && rightSideElements.length > 0) { @@ -263,7 +262,6 @@ export function useEditorActions() { } editor.timeline.deleteElements({ elements: selectedElements, - rippleEnabled: rippleEditingEnabled, }); }, undefined, diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 193379ed..fcc821fe 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -8,7 +8,6 @@ import { } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { useTimelineStore } from "@/stores/timeline-store"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; @@ -161,7 +160,6 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); - const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const isShiftHeldRef = useShiftKey(); const tracks = editor.timeline.getTracks(); const { @@ -467,7 +465,6 @@ export function useElementInteraction({ elementId: dragState.elementId, newStartTime: snappedTime, createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, - rippleEnabled: rippleEditingEnabled, }); selectElement({ trackId: newTrackId, elementId: dragState.elementId }); } else { @@ -478,7 +475,6 @@ export function useElementInteraction({ targetTrackId: targetTrack.id, elementId: dragState.elementId, newStartTime: snappedTime, - rippleEnabled: rippleEditingEnabled, }); if (targetTrack.id !== dragState.trackId) { selectElement({ @@ -509,7 +505,6 @@ export function useElementInteraction({ tracksContainerRef, tracksScrollRef, headerRef, - rippleEditingEnabled, selectElement, ]); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 890820d7..3dd98bea 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -45,9 +45,6 @@ export function useTimelineElementResize({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - const rippleEditingEnabled = useTimelineStore( - (state) => state.rippleEditingEnabled, - ); const [resizing, setResizing] = useState(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -458,7 +455,6 @@ export function useTimelineElementResize({ trimEnd: finalTrimEnd, startTime: startTimeChanged ? finalStartTime : undefined, duration: durationChanged ? finalDuration : undefined, - rippleEnabled: rippleEditingEnabled, }); } @@ -471,7 +467,6 @@ export function useTimelineElementResize({ element.id, onResizeStateChange, onSnapPointChange, - rippleEditingEnabled, ]); useEffect(() => { diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 1e1ad661..c8cc4494 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,23 +1,18 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { rippleShiftElements } from "@/lib/timeline"; export class DeleteElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly elements: { trackId: string; elementId: string }[]; - private readonly rippleEnabled: boolean; constructor({ elements, - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; - rippleEnabled?: boolean; }) { super(); this.elements = elements; - this.rippleEnabled = rippleEnabled; } execute(): CommandResult | undefined { @@ -25,45 +20,25 @@ export class DeleteElementsCommand extends Command { this.savedState = editor.timeline.getTracks(); const updatedTracks = this.savedState.map((track) => { - const elementsToDeleteOnTrack = this.elements.filter( - (target) => target.trackId === track.id, - ); - const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; + const elementsToDeleteOnTrack = this.elements.filter( + (target) => target.trackId === track.id, + ); - if (!hasElementsToDelete) { - return track; - } + if (elementsToDeleteOnTrack.length === 0) { + return track; + } - const deletedElementInfos = elementsToDeleteOnTrack - .map((target) => - track.elements.find((element) => element.id === target.elementId), - ) - .filter((element): element is NonNullable => element !== undefined) - .map((element) => ({ startTime: element.startTime, duration: element.duration })); + const elements = track.elements.filter( + (element) => + !this.elements.some( + (target) => + target.trackId === track.id && + target.elementId === element.id, + ), + ); - let elements = track.elements.filter( - (element) => - !this.elements.some( - (target) => - target.trackId === track.id && target.elementId === element.id, - ), - ); - - if (this.rippleEnabled && deletedElementInfos.length > 0) { - const sortedByStartDesc = [...deletedElementInfos].sort( - (a, b) => b.startTime - a.startTime, - ); - for (const { startTime, duration } of sortedByStartDesc) { - elements = rippleShiftElements({ - elements, - afterTime: startTime, - shiftAmount: duration, - }); - } - } - - return { ...track, elements } as typeof track; - }); + return { ...track, elements } as typeof track; + }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 60e12ddd..eabdc385 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -1,17 +1,11 @@ export { InsertElementCommand } from "./insert-element"; export { DeleteElementsCommand } from "./delete-elements"; export { DuplicateElementsCommand } from "./duplicate-elements"; -export { UpdateElementTrimCommand } from "./update-element-trim"; -export { UpdateElementDurationCommand } from "./update-element-duration"; -export { UpdateElementStartTimeCommand } from "./update-element-start-time"; export { SplitElementsCommand } from "./split-elements"; -export { UpdateElementCommand } from "./update-element"; -export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; -export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; +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"; -export * from "./retime"; diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index e944ca81..cf3afa6d 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -10,7 +10,6 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/placement"; -import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; export class MoveElementCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -19,7 +18,6 @@ export class MoveElementCommand extends Command { private readonly elementId: string; private readonly newStartTime: number; private readonly createTrack: { type: TrackType; index: number } | undefined; - private readonly rippleEnabled: boolean; constructor({ sourceTrackId, @@ -27,14 +25,12 @@ export class MoveElementCommand extends Command { elementId, newStartTime, createTrack, - rippleEnabled = false, }: { sourceTrackId: string; targetTrackId: string; elementId: string; newStartTime: number; createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; }) { super(); this.sourceTrackId = sourceTrackId; @@ -42,7 +38,6 @@ export class MoveElementCommand extends Command { this.elementId = elementId; this.newStartTime = newStartTime; this.createTrack = createTrack; - this.rippleEnabled = rippleEnabled; } execute(): CommandResult | undefined { @@ -113,14 +108,7 @@ export class MoveElementCommand extends Command { const remainingElements = track.elements.filter( (trackElement) => trackElement.id !== this.elementId, ); - const shiftedElements = this.rippleEnabled - ? rippleShiftElements({ - elements: remainingElements, - afterTime: element.startTime, - shiftAmount: element.duration, - }) - : remainingElements; - return { ...track, elements: shiftedElements } as typeof track; + return { ...track, elements: remainingElements } as typeof track; } if (track.id === this.targetTrackId) { diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts deleted file mode 100644 index e9c24c44..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpdateElementRetimeCommand } from "./update-element-retime"; diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts deleted file mode 100644 index 474396a1..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { EditorCore } from "@/core"; -import { clampRetimeRate } from "@/lib/retime/rate"; -import { clampAnimationsToDuration } from "@/lib/animation"; -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; -import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; -import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; - -function getSourceDuration({ - trimStart, - trimEnd, - duration, - sourceDuration, - retime, -}: { - trimStart: number; - trimEnd: number; - duration: number; - sourceDuration?: number; - retime?: RetimeConfig; -}): number { - if (typeof sourceDuration === "number") { - return sourceDuration; - } - - return ( - trimStart + - getSourceSpanAtClipTime({ - clipTime: duration, - retime, - }) + - trimEnd - ); -} - -export class UpdateElementRetimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly retime: RetimeConfig | undefined; - - constructor({ - trackId, - elementId, - retime, - }: { - trackId: string; - elementId: string; - retime?: RetimeConfig; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.retime = retime; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isRetimableElement, - update: (element) => { - if (!isRetimableElement(element)) { - return element; - } - - const nextRetime = this.retime - ? { - ...this.retime, - rate: clampRetimeRate({ rate: this.retime.rate }), - } - : undefined; - const sourceDuration = getSourceDuration({ - trimStart: element.trimStart, - trimEnd: element.trimEnd, - duration: element.duration, - sourceDuration: element.sourceDuration, - retime: element.retime, - }); - const visibleSourceSpan = Math.max( - 0, - sourceDuration - element.trimStart - element.trimEnd, - ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, - }); - - return { - ...element, - retime: nextRetime, - duration: nextDuration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: nextDuration, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - return undefined; - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 250a1ce6..79722b4c 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -2,7 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import type { TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; import { splitAnimationsAtTime } from "@/lib/animation"; import { getSourceSpanAtClipTime } from "@/lib/retime"; @@ -12,24 +12,20 @@ export class SplitElementsCommand extends Command { private readonly elements: { trackId: string; elementId: string }[]; private readonly splitTime: number; private readonly retainSide: "both" | "left" | "right"; - private readonly rippleEnabled: boolean; constructor({ elements, splitTime, retainSide = "both", - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; - rippleEnabled?: boolean; }) { super(); this.elements = elements; this.splitTime = splitTime; this.retainSide = retainSide; - this.rippleEnabled = rippleEnabled; } getRightSideElements(): { trackId: string; elementId: string }[] { @@ -50,8 +46,6 @@ export class SplitElementsCommand extends Command { return track; } - let leftVisibleDurationForRipple: number | null = null; - let elements = track.elements.flatMap((element) => { const shouldSplit = elementsToSplit.some( (target) => target.elementId === element.id, @@ -106,9 +100,6 @@ export class SplitElementsCommand extends Command { } if (this.retainSide === "right") { - if (this.rippleEnabled && elementsToSplit.length === 1) { - leftVisibleDurationForRipple = leftVisibleDuration; - } const newId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -157,14 +148,6 @@ export class SplitElementsCommand extends Command { ]; }); - if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { - elements = rippleShiftElements({ - elements, - afterTime: this.splitTime, - shiftAmount: leftVisibleDurationForRipple, - }); - } - return { ...track, elements } as typeof track; }); diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts deleted file mode 100644 index 75b77562..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementHaveAudio } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsMutedCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const mutableElements = this.elements.filter(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element); - }); - - if (mutableElements.length === 0) { - return; - } - - const shouldMute = mutableElements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element) && !element.muted; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = mutableElements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementHaveAudio(element) && - element.muted !== shouldMute - ? { ...element, muted: shouldMute } - : element; - }); - return { ...track, elements: newElements } as typeof 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/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts deleted file mode 100644 index 271b0a00..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementBeHidden } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const shouldHide = this.elements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementBeHidden(element) && !element.hidden; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementBeHidden(element) && - element.hidden !== shouldHide - ? { ...element, hidden: shouldHide } - : element; - }); - return { ...track, elements: newElements } as typeof track; - }); - - 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/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts deleted file mode 100644 index 7ad62000..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; - -export class UpdateElementDurationCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly duration: number; - - constructor({ - trackId, - elementId, - duration, - }: { - trackId: string; - elementId: string; - duration: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.duration = duration; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - if (track.id !== this.trackId) return track; - const newElements = track.elements.map((element) => - element.id === this.elementId - ? { - ...element, - duration: this.duration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: this.duration, - }), - } - : element, - ); - return { ...track, elements: newElements } as typeof track; - }); - - 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/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts deleted file mode 100644 index 6232d539..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; - -export class UpdateElementStartTimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - private readonly startTime: number; - - constructor({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }) { - super(); - this.elements = elements; - this.startTime = startTime; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const currentTracks = this.savedState; - const updatedTracks = currentTracks.map((track) => { - const hasElementsToUpdate = this.elements.some( - (elementEntry) => elementEntry.trackId === track.id, - ); - - if (!hasElementsToUpdate) { - return track; - } - - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - (elementEntry) => - elementEntry.elementId === element.id && - elementEntry.trackId === track.id, - ); - if (!shouldUpdate) { - return element; - } - - const baseStartTime = Math.max(0, this.startTime); - const adjustedStartTime = enforceMainTrackStart({ - tracks: currentTracks, - targetTrackId: track.id, - requestedStartTime: baseStartTime, - excludeElementId: element.id, - }); - - return { ...element, startTime: adjustedStartTime }; - }); - return { ...track, elements: newElements } as typeof track; - }); - - 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/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts deleted file mode 100644 index e3cb596b..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; -import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; - -export class UpdateElementTrimCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elementId: string; - private readonly trimStart: number; - private readonly trimEnd: number; - private readonly startTime: number | undefined; - private readonly duration: number | undefined; - private readonly rippleEnabled: boolean; - - constructor({ - elementId, - trimStart, - trimEnd, - startTime, - duration, - rippleEnabled = false, - }: { - elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; - rippleEnabled?: boolean; - }) { - super(); - this.elementId = elementId; - this.trimStart = trimStart; - this.trimEnd = trimEnd; - this.startTime = startTime; - this.duration = duration; - this.rippleEnabled = rippleEnabled; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - const targetElement = track.elements.find( - (element) => element.id === this.elementId, - ); - if (!targetElement) return track; - - const nextDuration = this.duration ?? targetElement.duration; - const requestedStartTime = this.startTime ?? targetElement.startTime; - const nextStartTime = enforceMainTrackStart({ - tracks: this.savedState ?? [], - targetTrackId: track.id, - requestedStartTime, - excludeElementId: this.elementId, - }); - - const oldEndTime = targetElement.startTime + targetElement.duration; - const newEndTime = nextStartTime + nextDuration; - const shiftAmount = oldEndTime - newEndTime; - - const updatedElement = { - ...targetElement, - trimStart: this.trimStart, - trimEnd: this.trimEnd, - startTime: nextStartTime, - duration: nextDuration, - ...(isRetimableElement(targetElement) - ? { retime: targetElement.retime } - : {}), - animations: clampAnimationsToDuration({ - animations: targetElement.animations, - duration: nextDuration, - }), - }; - - if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { - const shiftedOthers = rippleShiftElements({ - elements: track.elements.filter( - (element) => element.id !== this.elementId, - ), - afterTime: oldEndTime, - shiftAmount, - }); - return { - ...track, - elements: track.elements.map((element) => - element.id === this.elementId - ? updatedElement - : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? - element), - ), - } as typeof track; - } - - return { - ...track, - elements: track.elements.map((element) => - element.id === this.elementId ? updatedElement : element, - ), - } as typeof track; - }); - - 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/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts deleted file mode 100644 index 45b2603f..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { updateElementInTracks } from "@/lib/timeline"; - -export class UpdateElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly updates: Partial; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.updates = updates; - } - - execute(): CommandResult | undefined { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => ({ ...element, ...this.updates }) as TimelineElement, - }); - - 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/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts new file mode 100644 index 00000000..abeec842 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-elements.ts @@ -0,0 +1,71 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { updateElementInTracks } from "@/lib/timeline"; +import { applyElementUpdate } from "@/lib/timeline/update-pipeline"; + +export class UpdateElementsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + + constructor({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + }) { + super(); + this.updates = updates; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + let updatedTracks = this.savedState; + + for (const updateEntry of this.updates) { + const currentTrack = updatedTracks.find( + (track) => track.id === 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 = updateElementInTracks({ + 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/lib/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts new file mode 100644 index 00000000..098a0247 --- /dev/null +++ b/apps/web/src/lib/ripple/apply.ts @@ -0,0 +1,65 @@ +import type { TimelineTrack } from "@/lib/timeline/types"; +import { rippleShiftElements } from "./shift"; + +export interface RippleAdjustment { + trackId: string; + afterTime: number; + shiftAmount: number; +} + +export function applyRippleAdjustments({ + tracks, + adjustments, +}: { + tracks: TimelineTrack[]; + adjustments: RippleAdjustment[]; +}): TimelineTrack[] { + if (adjustments.length === 0) { + return tracks; + } + + const adjustmentsByTrack = new Map(); + for (const adjustment of adjustments) { + const trackAdjustments = adjustmentsByTrack.get(adjustment.trackId) ?? []; + trackAdjustments.push(adjustment); + adjustmentsByTrack.set(adjustment.trackId, trackAdjustments); + } + + return tracks.map((track) => + applyTrackRippleAdjustments({ + track, + adjustments: adjustmentsByTrack.get(track.id) ?? [], + }), + ); +} + +function applyTrackRippleAdjustments< + TElement extends TimelineTrack["elements"][number], + TTrack extends TimelineTrack & { elements: TElement[] }, +>({ + track, + adjustments, +}: { + track: TTrack; + adjustments: RippleAdjustment[]; +}): TTrack { + if (adjustments.length === 0) { + return track; + } + + const sortedAdjustments = [...adjustments].sort( + (firstAdjustment, secondAdjustment) => + secondAdjustment.afterTime - firstAdjustment.afterTime, + ); + + let elements: TElement[] = track.elements; + for (const adjustment of sortedAdjustments) { + elements = rippleShiftElements({ + elements, + afterTime: adjustment.afterTime, + shiftAmount: adjustment.shiftAmount, + }); + } + + return { ...track, elements }; +} diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts new file mode 100644 index 00000000..8a9730e1 --- /dev/null +++ b/apps/web/src/lib/ripple/diff.ts @@ -0,0 +1,272 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types"; +import type { RippleAdjustment } from "./apply"; + +interface Interval { + startTime: number; + endTime: number; +} + +interface ElementSpan extends Interval { + id: string; +} + +export function computeRippleAdjustments({ + beforeTracks, + afterTracks, +}: { + beforeTracks: TimelineTrack[]; + afterTracks: TimelineTrack[]; +}): RippleAdjustment[] { + const afterTracksById = new Map(afterTracks.map((track) => [track.id, track])); + const allAfterElementIds = new Set( + afterTracks.flatMap((track) => track.elements.map((element) => element.id)), + ); + + return beforeTracks.flatMap((beforeTrack): RippleAdjustment[] => + computeTrackRippleAdjustments({ + trackId: beforeTrack.id, + beforeElements: beforeTrack.elements, + afterElements: afterTracksById.get(beforeTrack.id)?.elements ?? [], + allAfterElementIds, + }), + ); +} + +function computeTrackRippleAdjustments({ + trackId, + beforeElements, + afterElements, + allAfterElementIds, +}: { + trackId: string; + beforeElements: TimelineElement[]; + afterElements: TimelineElement[]; + allAfterElementIds: Set; +}): RippleAdjustment[] { + const beforeElementsById = buildElementSpanMap({ elements: beforeElements }); + const afterElementsById = buildElementSpanMap({ elements: afterElements }); + const { vacatedIntervals, joinedIntervals } = collectTrackIntervals({ + beforeElementsById, + afterElementsById, + allAfterElementIds, + }); + const freedIntervals = subtractIntervalSets({ + sourceIntervals: vacatedIntervals, + overlappingIntervals: joinedIntervals, + }); + + return buildAdjustments({ trackId, intervals: freedIntervals }); +} + +function buildElementSpanMap({ + elements, +}: { + elements: TimelineElement[]; +}): Map { + return new Map( + elements.map((element) => [ + element.id, + { + id: element.id, + startTime: element.startTime, + endTime: element.startTime + element.duration, + }, + ]), + ); +} + +function collectTrackIntervals({ + beforeElementsById, + afterElementsById, + allAfterElementIds, +}: { + beforeElementsById: Map; + afterElementsById: Map; + allAfterElementIds: Set; +}): { + vacatedIntervals: Interval[]; + joinedIntervals: Interval[]; +} { + const vacatedIntervals: Interval[] = []; + const joinedIntervals: Interval[] = []; + + for (const beforeElement of beforeElementsById.values()) { + const afterElement = afterElementsById.get(beforeElement.id); + if (!afterElement) { + const wasMovedToAnotherTrack = allAfterElementIds.has(beforeElement.id); + if (!wasMovedToAnotherTrack) { + pushInterval({ + intervals: vacatedIntervals, + startTime: beforeElement.startTime, + endTime: beforeElement.endTime, + }); + } + continue; + } + + if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) { + pushInterval({ + intervals: vacatedIntervals, + startTime: afterElement.endTime, + endTime: beforeElement.endTime, + }); + } + } + + for (const afterElement of afterElementsById.values()) { + if (beforeElementsById.has(afterElement.id)) { + continue; + } + + pushInterval({ + intervals: joinedIntervals, + startTime: afterElement.startTime, + endTime: afterElement.endTime, + }); + } + + return { + vacatedIntervals: normalizeIntervals({ intervals: vacatedIntervals }), + joinedIntervals: normalizeIntervals({ intervals: joinedIntervals }), + }; +} + +function buildAdjustments({ + trackId, + intervals, +}: { + trackId: string; + intervals: Interval[]; +}): RippleAdjustment[] { + return intervals.flatMap((interval): RippleAdjustment[] => { + const shiftAmount = interval.endTime - interval.startTime; + if (shiftAmount <= TIME_EPSILON_SECONDS) { + return []; + } + + return [ + { + trackId, + afterTime: interval.endTime, + shiftAmount, + }, + ]; + }); +} + +function subtractIntervalSets({ + sourceIntervals, + overlappingIntervals, +}: { + sourceIntervals: Interval[]; + overlappingIntervals: Interval[]; +}): Interval[] { + const normalizedSourceIntervals = normalizeIntervals({ + intervals: sourceIntervals, + }); + const normalizedOverlappingIntervals = normalizeIntervals({ + intervals: overlappingIntervals, + }); + + return normalizedSourceIntervals.flatMap((sourceInterval) => + subtractSingleInterval({ + sourceInterval, + overlappingIntervals: normalizedOverlappingIntervals, + }), + ); +} + +function normalizeIntervals({ + intervals, +}: { + intervals: Interval[]; +}): Interval[] { + const validIntervals: Interval[] = []; + for (const interval of intervals) { + pushInterval({ + intervals: validIntervals, + startTime: interval.startTime, + endTime: interval.endTime, + }); + } + + const sortedIntervals = validIntervals.sort( + (leftInterval, rightInterval) => + leftInterval.startTime - rightInterval.startTime, + ); + + if (sortedIntervals.length === 0) { + return []; + } + + const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }]; + for (const interval of sortedIntervals.slice(1)) { + const previousInterval = mergedIntervals[mergedIntervals.length - 1]; + if (interval.startTime <= previousInterval.endTime + TIME_EPSILON_SECONDS) { + previousInterval.endTime = Math.max( + previousInterval.endTime, + interval.endTime, + ); + continue; + } + + mergedIntervals.push({ ...interval }); + } + + return mergedIntervals; +} + +function subtractSingleInterval({ + sourceInterval, + overlappingIntervals, +}: { + sourceInterval: Interval; + overlappingIntervals: Interval[]; +}): Interval[] { + let remainingIntervals: Interval[] = [{ ...sourceInterval }]; + + for (const overlappingInterval of overlappingIntervals) { + remainingIntervals = remainingIntervals.flatMap((remainingInterval) => { + if ( + overlappingInterval.endTime <= + remainingInterval.startTime + TIME_EPSILON_SECONDS || + overlappingInterval.startTime >= + remainingInterval.endTime - TIME_EPSILON_SECONDS + ) { + return [remainingInterval]; + } + + const nextIntervals: Interval[] = []; + pushInterval({ + intervals: nextIntervals, + startTime: remainingInterval.startTime, + endTime: overlappingInterval.startTime, + }); + pushInterval({ + intervals: nextIntervals, + startTime: overlappingInterval.endTime, + endTime: remainingInterval.endTime, + }); + return nextIntervals; + }); + + if (remainingIntervals.length === 0) { + return []; + } + } + + return remainingIntervals; +} + +function pushInterval({ + intervals, + startTime, + endTime, +}: { intervals: Interval[]; startTime: number; endTime: number }): void { + if (endTime - startTime <= TIME_EPSILON_SECONDS) { + return; + } + + intervals.push({ startTime, endTime }); +} diff --git a/apps/web/src/lib/ripple/index.ts b/apps/web/src/lib/ripple/index.ts new file mode 100644 index 00000000..5a14de64 --- /dev/null +++ b/apps/web/src/lib/ripple/index.ts @@ -0,0 +1,4 @@ +export type { RippleAdjustment } from "./apply"; +export { applyRippleAdjustments } from "./apply"; +export { computeRippleAdjustments } from "./diff"; +export { rippleShiftElements } from "./shift"; diff --git a/apps/web/src/lib/ripple/shift.ts b/apps/web/src/lib/ripple/shift.ts new file mode 100644 index 00000000..03ab4a67 --- /dev/null +++ b/apps/web/src/lib/ripple/shift.ts @@ -0,0 +1,17 @@ +import type { TimelineElement } from "@/lib/timeline/types"; + +export function rippleShiftElements({ + elements, + afterTime, + shiftAmount, +}: { + elements: TElement[]; + afterTime: number; + shiftAmount: number; +}): TElement[] { + return elements.map((element) => + element.startTime >= afterTime + ? ({ ...element, startTime: element.startTime - shiftAmount } as TElement) + : element, + ); +} diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts index 173b6402..1946f1e0 100644 --- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; +import type { UploadAudioElement, VideoElement } from "@/lib/timeline"; import { buildSeparatedAudioElement, doesElementHaveEnabledAudio, @@ -80,31 +80,11 @@ describe("audio separation", () => { }); test("skips source audio collection when the source clip is separated", () => { - const mediaAsset = { - id: "media-1", - type: "video", - name: "Clip", - size: 1, - lastModified: 1, - file: new File(["video"], "clip.mp4", { type: "video/mp4" }), - url: "blob:clip", - hasAudio: true, - }; + const mediaAsset = { hasAudio: true }; const videoElement = buildVideoElement({ isSourceAudioEnabled: false, }); - const audioElement = { - id: "audio-1", - type: "audio", - sourceType: "upload", - mediaId: "audio-media-1", - name: "Detached audio", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - volume: 0, - } as AudioElement; + const audioElement = buildAudioElement(); expect( doesElementHaveEnabledAudio({ @@ -145,3 +125,21 @@ function buildVideoElement( ...overrides, }; } + +function buildAudioElement( + overrides: Partial = {}, +): UploadAudioElement { + return { + id: "audio-1", + type: "audio", + sourceType: "upload", + mediaId: "audio-media-1", + name: "Detached audio", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + volume: 0, + ...overrides, + } satisfies UploadAudioElement; +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index b92cb79b..093edfbc 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -9,6 +9,8 @@ import type { VideoElement, } from "../types"; +type MediaAudioState = Pick; + export function isSourceAudioEnabled({ element, }: { @@ -30,7 +32,7 @@ export function canExtractSourceAudio({ mediaAsset, }: { element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; + mediaAsset: MediaAudioState | null | undefined; }): element is VideoElement { return ( element.type === "video" && @@ -53,7 +55,7 @@ export function canToggleSourceAudio({ mediaAsset, }: { element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; + mediaAsset: MediaAudioState | null | undefined; }): element is VideoElement { return ( canRecoverSourceAudio({ element }) || @@ -66,7 +68,7 @@ export function doesElementHaveEnabledAudio({ mediaAsset, }: { element: AudioElement | VideoElement; - mediaAsset?: MediaAsset | null; + mediaAsset?: MediaAudioState | null; }): boolean { if (element.type === "audio") { return true; diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 469b916e..194cd84b 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -8,7 +8,6 @@ export * from "./element-utils"; export * from "./audio-separation"; export * from "./zoom-utils"; export * from "./ruler-utils"; -export * from "./ripple-utils"; export * from "./pixel-utils"; export function calculateTotalDuration({ diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts deleted file mode 100644 index 5e17e9a3..00000000 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TimelineElement } from "@/lib/timeline"; - -export function rippleShiftElements({ - elements, - afterTime, - shiftAmount, -}: { - elements: TimelineElement[]; - afterTime: number; - shiftAmount: number; -}): TimelineElement[] { - return elements.map((element) => - element.startTime >= afterTime - ? { ...element, startTime: element.startTime - shiftAmount } - : element, - ); -} diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts new file mode 100644 index 00000000..cef99abc --- /dev/null +++ b/apps/web/src/lib/timeline/update-pipeline.ts @@ -0,0 +1,193 @@ +import { clampAnimationsToDuration } from "@/lib/animation"; +import { + clampRetimeRate, + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; +import { enforceMainTrackStart } from "@/lib/timeline/placement"; +import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; + +type ElementUpdateField = keyof TimelineElement; + +export interface ElementUpdateContext { + tracks: TimelineTrack[]; + trackId: string; +} + +interface ElementUpdateRuleResult { + element: TimelineElement; + changedFields?: ElementUpdateField[]; +} + +interface ElementUpdateRuleParams { + element: TimelineElement; + originalElement: TimelineElement; + patch: Partial; + context: ElementUpdateContext; +} + +interface ElementUpdateRule { + triggers: ElementUpdateField[]; + apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult; +} + +const deriveRules: ElementUpdateRule[] = [ + { + triggers: ["retime"], + apply: ({ element, originalElement, patch }) => { + if (!("retime" in patch) || !isRetimableElement(element)) { + return { element }; + } + + const nextRetime = patch.retime + ? { + ...patch.retime, + rate: clampRetimeRate({ rate: patch.retime.rate }), + } + : undefined; + + const sourceDuration = getSourceDuration({ + trimStart: originalElement.trimStart, + trimEnd: originalElement.trimEnd, + duration: originalElement.duration, + sourceDuration: isRetimableElement(originalElement) + ? originalElement.sourceDuration + : undefined, + retime: isRetimableElement(originalElement) + ? originalElement.retime + : undefined, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + element: { + ...element, + retime: nextRetime, + duration: nextDuration, + }, + changedFields: ["retime", "duration"], + }; + }, + }, +]; + +const enforceRules: ElementUpdateRule[] = [ + { + triggers: ["duration"], + apply: ({ element }) => ({ + element: { + ...element, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: element.duration, + }), + }, + }), + }, + { + triggers: ["startTime"], + apply: ({ element, context }) => ({ + element: { + ...element, + startTime: enforceMainTrackStart({ + tracks: context.tracks, + targetTrackId: context.trackId, + requestedStartTime: Math.max(0, element.startTime), + excludeElementId: element.id, + }), + }, + }), + }, +]; + +export function applyElementUpdate({ + element, + patch, + context, +}: { + element: TimelineElement; + patch: Partial; + context: ElementUpdateContext; +}): TimelineElement { + let nextElement = { ...element, ...patch } as TimelineElement; + const changedFields = new Set( + Object.keys(patch) as ElementUpdateField[], + ); + + for (const rule of deriveRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + const result = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }); + nextElement = result.element; + for (const field of result.changedFields ?? []) { + changedFields.add(field); + } + } + + for (const rule of enforceRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + nextElement = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }).element; + } + + return nextElement; +} + +function shouldApplyRule({ + rule, + changedFields, +}: { + rule: ElementUpdateRule; + changedFields: Set; +}): boolean { + return rule.triggers.some((trigger) => changedFields.has(trigger)); +} + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} diff --git a/bun.lock b/bun.lock index c3f1f850..d8b0e0ff 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "devDependencies": { "turbo": "^2.8.20", - "typescript": "5.8.3", + "typescript": "^6.0.2", }, }, "apps/web": { @@ -1601,7 +1601,7 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -1723,6 +1723,8 @@ "@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], diff --git a/package.json b/package.json index 2b77a0d3..22504650 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "turbo": "^2.8.20", - "typescript": "5.8.3" + "typescript": "^6.0.2" }, "trustedDependencies": [ "@tailwindcss/oxide" From e62227914a539dbc7d7c03922173ffd9d45b585a Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 00:24:21 +0200 Subject: [PATCH 10/26] docs: document rust/apps architecture in AGENTS.md --- AGENTS.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aef17c2e..cd59a85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,19 @@ # Agents.md -## Apps +## Architecture -- Web -- Desktop +An ongoing migration is moving all business logic into `rust/`. Each app under `apps/` is a UI shell — it owns rendering, interaction, and platform-specific concerns, but never owns logic. The UI framework for any given app is a replaceable detail. -## Rust +### `rust/` -Shared code between apps live in `rust/`, not in `packages/` +The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports. + +### `apps/` + +Each app is a frontend that calls into Rust. Logic is never duplicated between apps — only UI is, because each platform may use an entirely different framework and language to build it. + +- `web/` — Next.js +- `desktop/` — GPUI ## Web From c56d6f8188cc1c0e72d75611269a8bda7b79e727 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 00:57:54 +0200 Subject: [PATCH 11/26] fix: preserve negative start times during timeline drag --- .../element/use-element-interaction.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index fcc821fe..71ae4704 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -297,11 +297,12 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps }); + const adjustedTime = + mouseTime - pendingDragRef.current.clickOffsetTime; + const snappedTime = snapTimeToFrame({ + time: adjustedTime, + fps: activeProject.settings.fps, + }); startDrag({ ...pendingDragRef.current, initialCurrentTime: snappedTime, @@ -342,7 +343,7 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const adjustedTime = mouseTime - dragState.clickOffsetTime; const fps = activeProject.settings.fps; const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps }); @@ -455,6 +456,18 @@ export function useElementInteraction({ onSnapPointChange?.(null); return; } + const movingElement = + sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null; + if ( + movingElement && + !dropTarget.isNewTrack && + tracks[dropTarget.trackIndex]?.id === dragState.trackId && + snappedTime === movingElement.startTime + ) { + endDrag(); + onSnapPointChange?.(null); + return; + } if (dropTarget.isNewTrack) { const newTrackId = generateUUID(); From dedb13546a16235e7d6649961d0c9b235f62aa34 Mon Sep 17 00:00:00 2001 From: Maze Date: Mon, 6 Apr 2026 01:13:14 +0200 Subject: [PATCH 12/26] feat: graphs popover --- .../timeline/graph-editor/bezier-graph.tsx | 249 ++++++++++ .../panels/timeline/graph-editor/popover.tsx | 328 +++++++++++++ .../panels/timeline/graph-editor/presets.ts | 90 ++++ .../panels/timeline/graph-editor/session.ts | 448 ++++++++++++++++++ .../timeline/graph-editor/use-controller.ts | 153 ++++++ .../panels/timeline/timeline-toolbar.tsx | 70 ++- apps/web/src/components/ui/tabs.tsx | 2 +- .../web/src/core/managers/timeline-manager.ts | 41 ++ apps/web/src/lib/animation/curve-bridge.ts | 82 ++++ apps/web/src/lib/animation/graph-channels.ts | 95 ++++ apps/web/src/lib/animation/index.ts | 13 + apps/web/src/lib/animation/keyframes.ts | 155 +++++- apps/web/src/lib/animation/resolve.ts | 20 +- apps/web/src/lib/animation/types.ts | 60 +++ .../timeline/element/keyframes/index.ts | 11 +- .../keyframes/update-scalar-keyframe-curve.ts | 85 ++++ 16 files changed, 1845 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/session.ts create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts create mode 100644 apps/web/src/lib/animation/curve-bridge.ts create mode 100644 apps/web/src/lib/animation/graph-channels.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..32f39174 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { useEffect, useRef, useState, type PointerEvent } from "react"; +import { getBezierPoint } from "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import { cn } from "@/utils/ui"; + +const GRAPH_WIDTH = 140; +const GRAPH_HEIGHT = 94; +const GRAPH_PADDING = 12; +const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2; +const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2; +const HANDLE_RADIUS = 3.5; +const ENDPOINT_RADIUS = 2; +const SNAP_THRESHOLD = 0.06; +const SNAP_TARGETS = [0, 1]; +const CURVE_SEGMENTS = 64; +const Y_CLAMP_MIN = -0.5; +const Y_CLAMP_MAX = 1.5; + +type BezierHandle = "c1" | "c2"; + +export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT; + +function snap({ + value, + targets, + isEnabled, +}: { + value: number; + targets: number[]; + isEnabled: boolean; +}) { + if (!isEnabled) return value; + for (const target of targets) { + if (Math.abs(value - target) < SNAP_THRESHOLD) return target; + } + return value; +} + +function toSvgX({ value }: { value: number }) { + return GRAPH_PADDING + value * GRAPH_WIDTH; +} + +function toSvgY({ value }: { value: number }) { + return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT; +} + +function fromSvgX({ svgX }: { svgX: number }) { + return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH)); +} + +function fromSvgY({ svgY }: { svgY: number }) { + return Math.max( + Y_CLAMP_MIN, + Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT), + ); +} + +function curvePath({ curve }: { curve: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= CURVE_SEGMENTS; i++) { + const progress = i / CURVE_SEGMENTS; + points.push( + `${toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) })},${toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) })}`, + ); + } + return `M${points.join("L")}`; +} + +function clampHandleY({ svgY }: { svgY: number }) { + return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY)); +} + +export function BezierGraph({ + value, + onChange, + onChangeEnd, + onCancel, +}: { + value: NormalizedCubicBezier; + onChange?: (value: NormalizedCubicBezier) => void; + onChangeEnd?: (value: NormalizedCubicBezier) => void; + onCancel?: () => void; +}) { + const svgRef = useRef(null); + const [activeHandle, setActiveHandle] = useState(null); + const isShiftPressedRef = useRef(false); + const latestValueRef = useRef(value); + + latestValueRef.current = value; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Shift") isShiftPressedRef.current = true; + }; + const onKeyUp = (event: KeyboardEvent) => { + if (event.key === "Shift") isShiftPressedRef.current = false; + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + }; + }, []); + + function getPointerPosition({ + event, + }: { + event: PointerEvent; + }): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const scale = SVG_WIDTH / rect.width; + return { + x: (event.clientX - rect.left) * scale, + y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height), + }; + } + + function onHandlePointerDown({ handle }: { handle: BezierHandle }) { + return (event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent }) { + if (!activeHandle) return; + const pointerPos = getPointerPosition({ event }); + const x = fromSvgX({ svgX: pointerPos.x }); + const y = snap({ + value: fromSvgY({ svgY: pointerPos.y }), + targets: SNAP_TARGETS, + isEnabled: !isShiftPressedRef.current, + }); + const next: NormalizedCubicBezier = [...value]; + if (activeHandle === "c1") { + next[0] = x; + next[1] = y; + } else { + next[2] = x; + next[3] = y; + } + latestValueRef.current = next; + onChange?.(next); + } + + function onPointerUp() { + if (!activeHandle) return; + setActiveHandle(null); + onChangeEnd?.(latestValueRef.current); + } + + function onPointerCancel() { + if (!activeHandle) return; + setActiveHandle(null); + onCancel?.(); + } + + const path = curvePath({ curve: value }); + const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) }; + const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) }; + const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) }; + const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) }; + const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) }; + const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) }; + + return ( + onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + Bezier curve editor + + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx new file mode 100644 index 00000000..86665392 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + removePreset, + savePreset, + type EasingPreset, + useCustomPresets, +} from "./presets"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + + {componentOptions.length > 1 && ( +
+
+ {componentOptions.map((component) => ( + + ))} +
+
+ )} + +
+ {value ? ( + + ) : ( + + )} +
+ + + + + Presets + + + Saved + + + + COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + onCommitValue?.(preset.value)} + /> + ))} + + + +
+ {custom.map((preset) => ( + onCommitValue?.(preset.value)} + onDelete={() => removePreset(preset.id)} + /> + ))} + +
+
+
+
+
+ ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function ExpandableGrid({ + children, + isExpanded, + shouldExpand, + onExpand, +}: { + children: React.ReactNode; + isExpanded: boolean; + shouldExpand: boolean; + onExpand: () => void; +}) { + const gridStyle = shouldExpand + ? isExpanded + ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } + : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } + : undefined; + + return ( +
+
+ {children} +
+ {!isExpanded && shouldExpand && ( +
+ +
+ )} +
+ ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + + )} + + ); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + points.push( + `${THUMB_PADDING_X + getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) * (THUMB_WIDTH - THUMB_PADDING_X * 2)},${THUMB_PADDING_Y + (1 - getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 })) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2)}`, + ); + } + return ( + + Curve preset preview + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts new file mode 100644 index 00000000..cbcdf566 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts @@ -0,0 +1,90 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; + +const STORAGE_KEY = "opencut:graph-editor-presets"; +export const PRESET_MATCH_TOLERANCE = 0.02; + +export interface EasingPreset { + id: string; + label: string; + value: NormalizedCubicBezier; + isCustom?: boolean; +} + +export const BUILTIN_PRESETS: EasingPreset[] = [ + { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, + { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, + { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, + { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, + { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, + { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, +]; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + // JSON.parse can throw if the stored value is corrupted + return raw ? (JSON.parse(raw) as EasingPreset[]) : []; + } catch { + return []; + } +} + +function writeToStorage(presets: EasingPreset[]): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +function getSnapshot(): EasingPreset[] { + cachedPresets ??= readFromStorage(); + return cachedPresets; +} + +function getServerSnapshot(): EasingPreset[] { + return []; +} + +function notify(): void { + cachedPresets = null; + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +if (typeof window !== "undefined") { + window.addEventListener("storage", (event) => { + if (event.key === STORAGE_KEY) notify(); + }); +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset(value: NormalizedCubicBezier): void { + const current = getSnapshot(); + writeToStorage([ + ...current, + { + id: `custom-${Date.now()}`, + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ]); + notify(); +} + +export function removePreset(id: string): void { + writeToStorage(getSnapshot().filter((preset) => preset.id !== id)); + notify(); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts new file mode 100644 index 00000000..148fa7b4 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -0,0 +1,448 @@ +"use client"; + +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/lib/animation/types"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; + +const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; +const FLAT_VALUE_EPSILON = 1e-6; +const LINEAR_CURVE_EPSILON = 1e-6; + +export type GraphEditorUnavailableReason = + | "no-keyframe-selected" + | "multiple-keyframes-selected" + | "selected-element-missing" + | "selected-element-has-no-animations" + | "selected-keyframe-has-no-scalar-channel" + | "selected-keyframe-missing-on-channel" + | "selected-keyframe-has-no-next-segment" + | "selected-segment-is-hold" + | "selected-segment-is-flat"; + +export interface GraphEditorComponentOption { + key: string; + label: string; +} + +interface GraphEditorBaseSelectionState { + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + message: string; +} + +export interface GraphEditorUnavailableState + extends GraphEditorBaseSelectionState { + status: "unavailable"; + reason: GraphEditorUnavailableReason; +} + +export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { + status: "ready"; + trackId: string; + elementId: string; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + element: TimelineElement; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +} + +export type GraphEditorSelectionState = + | GraphEditorUnavailableState + | GraphEditorReadyState; + +export interface GraphEditorCurvePatch { + keyframeId: string; + patch: ScalarCurveKeyframePatch; +} + +function createUnavailableState({ + reason, + message, + componentOptions = [], + activeComponentKey = null, +}: { + reason: GraphEditorUnavailableReason; + message: string; + componentOptions?: GraphEditorComponentOption[]; + activeComponentKey?: string | null; +}): GraphEditorUnavailableState { + return { + status: "unavailable", + reason, + message, + componentOptions, + activeComponentKey, + }; +} + +function findElementByKeyframe({ + tracks, + keyframe, +}: { + tracks: TimelineTrack[]; + keyframe: SelectedKeyframeRef; +}): { element: TimelineElement; trackId: string; elementId: string } | null { + for (const track of tracks) { + if (track.id !== keyframe.trackId) { + continue; + } + + const element = track.elements.find( + (trackElement) => trackElement.id === keyframe.elementId, + ); + if (!element) { + return null; + } + + return { + element, + trackId: track.id, + elementId: element.id, + }; + } + + return null; +} + +function findKeyframeTime({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations; + propertyPath: AnimationPath; + keyframeId: string; +}): number | null { + const binding = animations.bindings[propertyPath]; + if (!binding) return null; + + for (const component of binding.components) { + const channel = animations.channels[component.channelId]; + if (channel?.kind !== "scalar") continue; + const key = channel.keys.find((k) => k.id === keyframeId); + if (key !== undefined) return key.time; + } + + return null; +} + +function getComponentLabel({ componentKey }: { componentKey: string }): string { + switch (componentKey) { + case "value": + return "Value"; + default: + return componentKey.toUpperCase(); + } +} + +function isFlatSegment({ + context, +}: { + context: ScalarGraphKeyframeContext; +}): boolean { + if (!context.nextKey) { + return true; + } + + return ( + Math.abs(context.nextKey.value - context.keyframe.value) <= + FLAT_VALUE_EPSILON + ); +} + +function isLinearCurve({ + cubicBezier, +}: { + cubicBezier: NormalizedCubicBezier; +}): boolean { + return ( + Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON + ); +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: TimelineTrack[]; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + if (selectedKeyframes.length > 2) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Select one or two adjacent keyframes to edit a curve.", + }); + } + + if (selectedKeyframes.length === 2) { + const [kf1, kf2] = selectedKeyframes; + if ( + kf1.trackId !== kf2.trackId || + kf1.elementId !== kf2.elementId || + kf1.propertyPath !== kf2.propertyPath + ) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Selected keyframes must be on the same element and property.", + }); + } + } + + const primaryKeyframe = selectedKeyframes[0]; + const secondaryKeyframeId = + selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null; + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + if (!selectedElement.element.animations) { + return createUnavailableState({ + reason: "selected-element-has-no-animations", + message: "The selected keyframe has no editable graph.", + }); + } + + const scalarChannels = getEditableScalarChannels({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + }); + if (scalarChannels.length === 0) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-scalar-channel", + message: "The selected keyframe has no editable graph channel.", + }); + } + + // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment + // anchor so the graph editor edits the curve between the two selected keyframes. + let resolvedKeyframeId = primaryKeyframe.keyframeId; + if (secondaryKeyframeId !== null) { + const time1 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: primaryKeyframe.keyframeId, + }); + const time2 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: secondaryKeyframeId, + }); + if (time2 !== null && (time1 === null || time2 < time1)) { + resolvedKeyframeId = secondaryKeyframeId; + } + } + + const contexts = scalarChannels.flatMap((channel) => { + const context = getScalarKeyframeContext({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + componentKey: channel.componentKey, + keyframeId: resolvedKeyframeId, + }); + if (!context) { + return []; + } + + return [ + { + context, + option: { + key: channel.componentKey, + label: getComponentLabel({ componentKey: channel.componentKey }), + }, + }, + ]; + }); + + if (contexts.length === 0) { + return createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + }); + } + + const nextSegmentContexts = contexts.filter( + ({ context }) => context.nextKey !== null, + ); + const preferredContext = + contexts.find(({ option }) => option.key === preferredComponentKey) ?? null; + const activeContext = + preferredContext ?? nextSegmentContexts[0] ?? contexts[0]; + const componentOptions = contexts.map(({ option }) => option); + + if (!activeContext.context.nextKey) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-next-segment", + message: "Select a keyframe that has an outgoing segment.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (isFlatSegment({ context: activeContext.context })) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "Flat segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (activeContext.context.keyframe.segmentToNext === "step") { + return createUnavailableState({ + reason: "selected-segment-is-hold", + message: "Hold segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + const cubicBezier = + activeContext.context.keyframe.segmentToNext === "linear" + ? GRAPH_LINEAR_CURVE + : getNormalizedCubicBezierForScalarSegment({ + leftKey: activeContext.context.keyframe, + rightKey: activeContext.context.nextKey, + }); + if (!cubicBezier) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "The selected segment cannot be represented in this graph view.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + return { + status: "ready", + message: "Edit graph", + componentOptions, + activeComponentKey: activeContext.option.key, + trackId: selectedElement.trackId, + elementId: selectedElement.elementId, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: resolvedKeyframeId, + element: selectedElement.element, + context: activeContext.context, + cubicBezier, + }; +} + +export function buildGraphEditorCurvePatches({ + context, + cubicBezier, +}: { + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): GraphEditorCurvePatch[] | null { + if (!context.nextKey) { + return null; + } + + if (isLinearCurve({ cubicBezier })) { + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "linear", + rightHandle: null, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: null, + }, + }, + ]; + } + + const handles = getCurveHandlesForNormalizedCubicBezier({ + leftKey: context.keyframe, + rightKey: context.nextKey, + cubicBezier, + }); + if (!handles) { + return null; + } + + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "bezier", + rightHandle: handles.rightHandle, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: handles.leftHandle, + }, + }, + ]; +} + +export function applyGraphEditorCurvePreview({ + animations, + context, + cubicBezier, +}: { + animations: ElementAnimations | undefined; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): ElementAnimations | undefined { + const patches = buildGraphEditorCurvePatches({ + context, + cubicBezier, + }); + if (!patches) { + return animations; + } + + return patches.reduce( + (nextAnimations, { keyframeId, patch }) => + updateScalarKeyframeCurve({ + animations: nextAnimations, + propertyPath: context.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + }), + animations, + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts new file mode 100644 index 00000000..3b7520e6 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { registerCanceller } from "@/lib/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import { + applyGraphEditorCurvePreview, + buildGraphEditorCurvePatches, + resolveGraphEditorSelectionState, + type GraphEditorSelectionState, +} from "./session"; + +export function useGraphEditorController() { + const editor = useEditor(); + const renderTracks = useEditor((currentEditor) => + currentEditor.timeline.getRenderTracks(), + ); + const { selectedKeyframes } = useKeyframeSelection(); + const [open, setOpen] = useState(false); + const [activeComponentKey, setActiveComponentKey] = useState( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo( + () => + resolveGraphEditorSelectionState({ + tracks: renderTracks, + selectedKeyframes, + preferredComponentKey: activeComponentKey, + }), + [activeComponentKey, renderTracks, selectedKeyframes], + ); + + const stateKey = + state.status === "ready" + ? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}` + : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; + const previousStateKeyRef = useRef(stateKey); + + const discardPreview = useCallback(() => { + if (!hasPreviewRef.current) { + return; + } + + editor.timeline.discardPreview(); + hasPreviewRef.current = false; + }, [editor]); + + useEffect(() => { + if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { + discardPreview(); + } + + previousStateKeyRef.current = stateKey; + }, [discardPreview, stateKey]); + + useEffect(() => { + if (!open) { + return; + } + + return registerCanceller({ + fn: () => { + discardPreview(); + setOpen(false); + }, + }); + }, [discardPreview, open]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + discardPreview(); + } + + setOpen(nextOpen); + }, + [discardPreview], + ); + + const handleActiveComponentKeyChange = useCallback( + (nextComponentKey: string) => { + discardPreview(); + setActiveComponentKey(nextComponentKey); + }, + [discardPreview], + ); + + function handlePreviewValue(nextValue: NormalizedCubicBezier) { + if (state.status !== "ready") { + return; + } + + const nextAnimations = applyGraphEditorCurvePreview({ + animations: state.element.animations, + context: state.context, + cubicBezier: nextValue, + }); + editor.timeline.previewElements({ + updates: [ + { + trackId: state.trackId, + elementId: state.elementId, + updates: { + animations: nextAnimations, + }, + }, + ], + }); + hasPreviewRef.current = true; + } + + function handleCommitValue(nextValue: NormalizedCubicBezier) { + if (state.status !== "ready") { + return; + } + + const patches = buildGraphEditorCurvePatches({ + context: state.context, + cubicBezier: nextValue, + }); + if (!patches) { + return; + } + + editor.timeline.updateKeyframeCurves({ + keyframes: patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: state.propertyPath, + componentKey: state.context.componentKey, + keyframeId, + patch, + })), + }); + hasPreviewRef.current = false; + } + + return { + open, + onOpenChange: handleOpenChange, + canOpen: state.status === "ready", + tooltip: state.status === "ready" ? "Open graph editor" : state.message, + state, + onActiveComponentKeyChange: handleActiveComponentKeyChange, + onPreviewValue: handlePreviewValue, + onCommitValue: handleCommitValue, + onCancelPreview: discardPreview, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index cb636d3c..80ea10d0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -14,9 +14,7 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { - TIMELINE_ZOOM_BUTTON_FACTOR, -} from "./interaction"; +import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { ScenesView } from "@/components/editor/scenes-view"; @@ -36,7 +34,6 @@ import { SnowIcon, ScissorIcon, MagnetIcon, - Link04Icon, SearchAddIcon, SearchMinusIcon, Copy01Icon, @@ -49,7 +46,9 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { OcRippleIcon } from "@/components/icons"; - +import { GraphEditorPopover } from "./graph-editor/popover"; +import { PopoverTrigger } from "@/components/ui/popover"; +import { useGraphEditorController } from "./graph-editor/use-controller"; export function TimelineToolbar({ zoomLevel, @@ -63,10 +62,7 @@ export function TimelineToolbar({ const handleZoom = ({ direction }: { direction: "in" | "out" }) => { const newZoomLevel = direction === "in" - ? Math.min( - TIMELINE_ZOOM_MAX, - zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR, - ) + ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); setZoomLevel({ zoom: newZoomLevel }); }; @@ -91,8 +87,11 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); - const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets()); + const mediaAssets = useEditor((currentEditor) => + currentEditor.media.getAssets(), + ); const { selectedElements } = useElementSelection(); + const graphEditor = useGraphEditorController(); const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); @@ -164,11 +163,11 @@ function ToolbarLeftSection() { /> - } + icon={ + + } tooltip={sourceAudioLabel} disabled={!canToggleSelectedSourceAudio} onClick={({ event }) => @@ -212,13 +211,35 @@ function ToolbarLeftSection() { /> - + } - tooltip="Open graph editor" - onClick={() => {}} + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + {button} + ) : ( + button + ) + } /> - + ); @@ -338,12 +359,17 @@ function ToolbarButton({ {icon} ); + const trigger = disabled ? ( + {button} + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); return ( - - {buttonWrapper ? buttonWrapper(button) : button} - + {trigger} {tooltip} ); diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index 4fb028c8..5e70f88d 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -72,7 +72,7 @@ const TabsContent = React.forwardRef< ; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }) => + new UpdateScalarKeyframeCurveCommand({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + upsertEffectParamKeyframe({ trackId, elementId, diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts new file mode 100644 index 00000000..55f96933 --- /dev/null +++ b/apps/web/src/lib/animation/curve-bridge.ts @@ -0,0 +1,82 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { + getDefaultLeftHandle, + getDefaultRightHandle, +} from "@/lib/animation/bezier"; +import type { + CurveHandle, + NormalizedCubicBezier, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +const VALUE_EPSILON = 1e-6; + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +export function getNormalizedCubicBezierForScalarSegment({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}): NormalizedCubicBezier | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + return [ + clamp01({ value: rightHandle.dt / spanTime }), + rightHandle.dv / spanValue, + clamp01({ value: 1 + leftHandle.dt / spanTime }), + 1 + leftHandle.dv / spanValue, + ]; +} + +export function getCurveHandlesForNormalizedCubicBezier({ + leftKey, + rightKey, + cubicBezier, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + cubicBezier: NormalizedCubicBezier; +}): { + rightHandle: CurveHandle; + leftHandle: CurveHandle; +} | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const [rawX1, y1, rawX2, y2] = cubicBezier; + const x1 = clamp01({ value: rawX1 }); + const x2 = clamp01({ value: rawX2 }); + + return { + rightHandle: { + dt: spanTime * x1, + dv: spanValue * y1, + }, + leftHandle: { + dt: spanTime * (x2 - 1), + dv: spanValue * (y2 - 1), + }, + }; +} diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts new file mode 100644 index 00000000..240b00ed --- /dev/null +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -0,0 +1,95 @@ +import type { + AnimationPath, + ElementAnimations, + ScalarAnimationChannel, + ScalarGraphChannel, + ScalarGraphKeyframeContext, +} from "@/lib/animation/types"; + +function isScalarAnimationChannel( + channel: ElementAnimations["channels"][string], +): channel is ScalarAnimationChannel { + return channel?.kind === "scalar"; +} + +export function getEditableScalarChannels({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): ScalarGraphChannel[] { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + if (!isScalarAnimationChannel(channel)) { + return []; + } + + return [ + { + propertyPath, + componentKey: component.key, + channelId: component.channelId, + channel, + }, + ]; + }); +} + +export function getEditableScalarChannel({ + animations, + propertyPath, + componentKey, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; +}): ScalarGraphChannel | null { + return ( + getEditableScalarChannels({ + animations, + propertyPath, + }).find((channel) => channel.componentKey === componentKey) ?? null + ); +} + +export function getScalarKeyframeContext({ + animations, + propertyPath, + componentKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; +}): ScalarGraphKeyframeContext | null { + const scalarChannel = getEditableScalarChannel({ + animations, + propertyPath, + componentKey, + }); + if (!scalarChannel) { + return null; + } + + const keyframeIndex = scalarChannel.channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeIndex < 0) { + return null; + } + + return { + ...scalarChannel, + keyframe: scalarChannel.channel.keys[keyframeIndex], + keyframeIndex, + previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, + nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, + }; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 9cb85d2a..7d2646d9 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -12,8 +12,10 @@ export { getChannel, removeElementKeyframe, retimeElementKeyframe, + setBindingComponentChannel, setChannel, splitAnimationsAtTime, + updateScalarKeyframeCurve, upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; @@ -46,6 +48,17 @@ export { hasKeyframesForPath, } from "./keyframe-query"; +export { + getEditableScalarChannel, + getEditableScalarChannels, + getScalarKeyframeContext, +} from "./graph-channels"; + +export { + getCurveHandlesForNormalizedCubicBezier, + getNormalizedCubicBezierForScalarSegment, +} from "./curve-bridge"; + export { buildGraphicParamPath, isGraphicParamPath, diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 8c17f8a6..7eaa5ed6 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -11,6 +11,7 @@ import type { ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, + ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/lib/animation/types"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; @@ -209,7 +210,7 @@ function getBinding({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationBindingInstance | undefined { return animations?.bindings[propertyPath]; } @@ -224,6 +225,16 @@ function getChannelById({ return animations?.channels[channelId]; } +function getBindingComponent({ + binding, + componentKey, +}: { + binding: AnimationBindingInstance; + componentKey: string; +}) { + return binding.components.find((component) => component.key === componentKey) ?? null; +} + function getTargetKeyMetadata({ channel, time, @@ -402,7 +413,7 @@ export function getChannel({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationChannel | undefined { const binding = getBinding({ animations, propertyPath }); const primaryChannelId = @@ -651,7 +662,7 @@ export function setChannel({ channel, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { const binding = getBinding({ animations, propertyPath }); @@ -659,29 +670,66 @@ export function setChannel({ return animations; } - const primaryComponent = getPrimaryComponent({ binding }); - if (!primaryComponent) { - return animations; - } - - const nextAnimations = cloneAnimationsState({ animations }); - if (!channel || !hasChannelKeys({ channel })) { - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; - } - delete nextAnimations.bindings[propertyPath]; - return toAnimation({ - animations: nextAnimations, - }); - } - if (binding.components.length !== 1) { throw new Error( `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, ); } - nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({ + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey: primaryComponent.key, + channel, + }); +} + +export function setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + if (!channel || !hasChannelKeys({ channel })) { + delete nextAnimations.channels[component.channelId]; + const hasRemainingKeys = binding.components.some((candidate) => + hasChannelKeys({ + channel: nextAnimations.channels[candidate.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + } + return toAnimation({ + animations: nextAnimations, + }); + } + + nextAnimations.channels[component.channelId] = normalizeChannel({ channel, }); return toAnimation({ @@ -689,6 +737,73 @@ export function setChannel({ }); } +export function updateScalarKeyframeCurve({ + animations, + propertyPath, + componentKey, + keyframeId, + patch, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (channel?.kind !== "scalar") { + return animations; + } + + const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) { + return animations; + } + + const nextKeys = [...channel.keys]; + const currentKey = nextKeys[keyframeIndex]; + nextKeys[keyframeIndex] = { + ...currentKey, + leftHandle: + patch.leftHandle === undefined + ? currentKey.leftHandle + : patch.leftHandle ?? undefined, + rightHandle: + patch.rightHandle === undefined + ? currentKey.rightHandle + : patch.rightHandle ?? undefined, + segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, + tangentMode: patch.tangentMode ?? currentKey.tangentMode, + }; + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel: { + kind: "scalar", + keys: nextKeys, + extrapolation: channel.extrapolation, + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 3de97026..67f4a487 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,5 +1,9 @@ import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + AnimationPath, AnimationPropertyPath, + AnimationValueForPath, ElementAnimations, } from "@/lib/animation/types"; import type { Transform } from "@/lib/rendering"; @@ -96,7 +100,7 @@ export function resolveNumberAtTime({ }: { baseValue: number; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationNumericPropertyPath; localTime: number; }): number { return resolveAnimationPathValueAtTime({ @@ -115,7 +119,7 @@ export function resolveColorAtTime({ }: { baseColor: string; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationColorPropertyPath; localTime: number; }): string { return resolveAnimationPathValueAtTime({ @@ -126,19 +130,17 @@ export function resolveColorAtTime({ }); } -export function resolveAnimationPathValueAtTime< - T extends number | string | boolean | Transform["position"], ->({ +export function resolveAnimationPathValueAtTime({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: TPath; localTime: number; - fallbackValue: T; -}): T { + fallbackValue: AnimationValueForPath; +}): AnimationValueForPath { const binding = animations?.bindings[propertyPath]; if (!binding) { return fallbackValue; @@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime< return (composeAnimationValue({ binding, componentValues, - }) ?? fallbackValue) as T; + }) ?? fallbackValue) as AnimationValueForPath; } diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 1d07e81d..453723b0 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/lib/params"; + export const ANIMATION_PROPERTY_PATHS = [ "transform.position", "transform.scaleX", @@ -31,6 +33,34 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; export type AnimationValue = number | string | boolean | VectorValue; +export interface AnimationPropertyValueMap { + "transform.position": VectorValue; + "transform.scaleX": number; + "transform.scaleY": number; + "transform.rotate": number; + opacity: number; + volume: number; + color: string; + "background.color": string; + "background.paddingX": number; + "background.paddingY": number; + "background.offsetX": number; + "background.offsetY": number; + "background.cornerRadius": number; +} +export type DynamicAnimationPathValue = ParamValues[string]; +export type AnimationValueForPath = + TPath extends AnimationPropertyPath + ? AnimationPropertyValueMap[TPath] + : TPath extends GraphicParamPath | EffectParamPath + ? DynamicAnimationPathValue + : never; +export type AnimationNumericPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; +}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never; +}[AnimationPropertyPath]; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -144,6 +174,36 @@ export interface ElementAnimations { channels: ElementAnimationChannelMap; } +export type NormalizedCubicBezier = [number, number, number, number]; + +export interface ScalarGraphChannelTarget { + propertyPath: AnimationPath; + componentKey: string; + channelId: string; +} + +export interface ScalarGraphChannel extends ScalarGraphChannelTarget { + channel: ScalarAnimationChannel; +} + +export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { + keyframeId: string; +} + +export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { + keyframe: ScalarAnimationKey; + keyframeIndex: number; + previousKey: ScalarAnimationKey | null; + nextKey: ScalarAnimationKey | null; +} + +export interface ScalarCurveKeyframePatch { + leftHandle?: CurveHandle | null; + rightHandle?: CurveHandle | null; + segmentToNext?: ScalarSegmentType; + tangentMode?: TangentMode; +} + export interface ElementKeyframe { propertyPath: AnimationPath; id: string; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts index ea19286c..319a827b 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -1,5 +1,6 @@ -export * from "./remove-effect-param-keyframe"; -export * from "./remove-keyframe"; -export * from "./retime-keyframe"; -export * from "./upsert-effect-param-keyframe"; -export * from "./upsert-keyframe"; +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/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts new file mode 100644 index 00000000..f1eaab9d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -0,0 +1,85 @@ +import { EditorCore } from "@/core"; +import { + resolveAnimationTarget, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInTracks } from "@/lib/timeline"; +import type { + AnimationPath, + ScalarCurveKeyframePatch, +} from "@/lib/animation/types"; +import type { TimelineTrack } from "@/lib/timeline"; + +export class UpdateScalarKeyframeCurveCommand extends Command { + private savedState: TimelineTrack[] | 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.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + 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); + } +} From ccd552a1c034520cd9ebe51648d2441a92bfeda4 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 01:24:09 +0200 Subject: [PATCH 13/26] chore: addition to last merge --- .../lib/timeline/audio-separation/index.ts | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index 0ae1321f..fe3c1ca4 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -27,13 +27,10 @@ export function isSourceAudioSeparated({ return !isSourceAudioEnabled({ element }); } -export function canExtractSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAudioState | null | undefined; -}): element is VideoElement { +export function canExtractSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { return ( element.type === "video" && isSourceAudioEnabled({ element }) && @@ -48,17 +45,11 @@ export function canRecoverSourceAudio( return element.type === "video" && isSourceAudioSeparated({ element }); } -export function canToggleSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAudioState | null | undefined; -}): element is VideoElement { - return ( - canRecoverSourceAudio({ element }) || - canExtractSourceAudio({ element, mediaAsset }) - ); +export function canToggleSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { + return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset); } export function doesElementHaveEnabledAudio({ From 05243616eaf597c8e81b9401ba09b425928ebf95 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 01:51:09 +0200 Subject: [PATCH 14/26] refactor: use useShiftKey hook in bezier graph --- .../timeline/graph-editor/bezier-graph.tsx | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx index 32f39174..f607dc7a 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef, useState, type PointerEvent } from "react"; +import { useRef, useState, type PointerEvent } from "react"; +import { useShiftKey } from "@/hooks/use-shift-key"; import { getBezierPoint } from "@/lib/animation/bezier"; import type { NormalizedCubicBezier } from "@/lib/animation/types"; import { cn } from "@/utils/ui"; @@ -85,26 +86,11 @@ export function BezierGraph({ }) { const svgRef = useRef(null); const [activeHandle, setActiveHandle] = useState(null); - const isShiftPressedRef = useRef(false); + const isShiftPressedRef = useShiftKey(); const latestValueRef = useRef(value); latestValueRef.current = value; - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Shift") isShiftPressedRef.current = true; - }; - const onKeyUp = (event: KeyboardEvent) => { - if (event.key === "Shift") isShiftPressedRef.current = false; - }; - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - return () => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - }; - }, []); - function getPointerPosition({ event, }: { From 964ac82d5e1ab2774e917338f7cbcbd11c323e94 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 12:40:47 +0200 Subject: [PATCH 15/26] refactor: split presets into data definitions and storage store --- .../graph-editor/custom-presets-store.ts | 101 ++++++++++++++++++ .../timeline/graph-editor/easing-presets.ts | 19 ++++ .../panels/timeline/graph-editor/popover.tsx | 10 +- .../panels/timeline/graph-editor/presets.ts | 90 ---------------- .../panels/timeline/graph-editor/session.ts | 12 +-- 5 files changed, 129 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts create mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts delete mode 100644 apps/web/src/components/editor/panels/timeline/graph-editor/presets.ts diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts new file mode 100644 index 00000000..54722ec2 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { generateUUID } from "@/utils/id"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import type { EasingPreset } from "./easing-presets"; + +const STORAGE_KEY = "opencut:graph-editor-presets"; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function isValidPresetArray(value: unknown): value is EasingPreset[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === "object" && + item !== null && + typeof item.id === "string" && + typeof item.label === "string" && + Array.isArray(item.value) && + item.value.length === 4 && + item.value.every((number: unknown) => typeof number === "number"), + ) + ); +} + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return isValidPresetArray(parsed) ? parsed : []; + } catch { + // Silently recover — corrupted localStorage shouldn't crash the editor + return []; + } +} + +function writeToStorage({ presets }: { presets: EasingPreset[] }): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +function getSnapshot(): EasingPreset[] { + cachedPresets ??= readFromStorage(); + return cachedPresets; +} + +function getServerSnapshot(): EasingPreset[] { + return []; +} + +function notify(): void { + cachedPresets = null; + for (const listener of listeners) { + listener(); + } +} + +function onStorageChange(event: StorageEvent): void { + if (event.key === STORAGE_KEY) notify(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("storage", onStorageChange); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("storage", onStorageChange); + } + }; +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset({ value }: { value: NormalizedCubicBezier }): void { + const current = getSnapshot(); + writeToStorage({ + presets: [ + ...current, + { + id: generateUUID(), + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ], + }); + notify(); +} + +export function removePreset({ id }: { id: string }): void { + writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) }); + notify(); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts new file mode 100644 index 00000000..6ccc9071 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts @@ -0,0 +1,19 @@ +import type { NormalizedCubicBezier } from "@/lib/animation/types"; + +export const PRESET_MATCH_TOLERANCE = 0.02; + +export interface EasingPreset { + id: string; + label: string; + value: NormalizedCubicBezier; + isCustom?: boolean; +} + +export const BUILTIN_PRESETS: EasingPreset[] = [ + { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, + { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, + { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, + { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, + { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, + { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, +]; diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx index 86665392..00348a60 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx @@ -17,11 +17,9 @@ import type { GraphEditorComponentOption } from "./session"; import { BUILTIN_PRESETS, PRESET_MATCH_TOLERANCE, - removePreset, - savePreset, type EasingPreset, - useCustomPresets, -} from "./presets"; +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; const COLLAPSED_MAX = 6; @@ -160,12 +158,12 @@ export function GraphEditorPopover({ isActive={activePresetId === preset.id} disabled={!canEdit} onSelect={() => onCommitValue?.(preset.value)} - onDelete={() => removePreset(preset.id)} + onDelete={() => removePreset({ id: preset.id })} /> ))}