From e7dcb586c0408255a0bf788e355fdb6d50d9b923 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 2 Mar 2026 13:13:07 +0100 Subject: feat: Clip effects, asset sorting, and timeline improvements Major features and improvements: * **Clip Effects**: * Added UI in Properties Panel to manage effects on video/image clips (add, remove, toggle, reorder). * Implemented dynamic parameter fields for effects. * Added support for keyframing effect parameters. * **Assets Panel**: * Added sorting options: Name, Type, Duration, and File Size. * Persisted view preferences (grid/list mode, sort order) to local storage. * Refactored media item rendering and drag interactions. * **Timeline & Interaction**: * **Keyframe Dragging**: Added ability to drag keyframes directly on the timeline element. * **Resizing**: Improved resize logic to respect neighboring clips (prevents overlaps). * **Visuals**: Implemented tiled background rendering for video/image clips on the timeline. * **Shortcuts**: Added "Deselect All" action bound to the `Escape` key. * **Fixes**: Corrected drag-and-drop coordinate calculations when the timeline track area is scrolled. * **Text Elements**: * Refactored text background storage to use an explicit `enabled` flag. * Added `V8toV9` storage migration to update existing projects. * **Architecture**: * Moved export state management to `ProjectManager` for better lifecycle handling. * Refactored `PropertiesPanel` sections to be more composable (custom headers, borders). --- .../timeline/element/effects/add-effect.ts | 74 +++++++++++++++++++ .../lib/commands/timeline/element/effects/index.ts | 5 ++ .../timeline/element/effects/remove-effect.ts | 65 ++++++++++++++++ .../timeline/element/effects/reorder-effect.ts | 73 ++++++++++++++++++ .../timeline/element/effects/toggle-effect.ts | 67 +++++++++++++++++ .../element/effects/update-effect-params.ts | 86 ++++++++++++++++++++++ .../web/src/lib/commands/timeline/element/index.ts | 1 + .../commands/timeline/element/keyframes/index.ts | 2 + .../keyframes/remove-effect-param-keyframe.ts | 68 +++++++++++++++++ .../keyframes/upsert-effect-param-keyframe.ts | 84 +++++++++++++++++++++ .../lib/commands/timeline/element/move-elements.ts | 39 ++++++---- 11 files changed, 548 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/lib/commands/timeline/element/effects/add-effect.ts create mode 100644 apps/web/src/lib/commands/timeline/element/effects/index.ts create mode 100644 apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts create mode 100644 apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts create mode 100644 apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts create mode 100644 apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts create mode 100644 apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts (limited to 'apps/web/src/lib/commands/timeline') 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 new file mode 100644 index 00000000..5299749d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -0,0 +1,74 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import { 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; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts new file mode 100644 index 00000000..133ce661 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -0,0 +1,5 @@ +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts new file mode 100644 index 00000000..b475474f --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -0,0 +1,65 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/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); + } + } +} 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 new file mode 100644 index 00000000..81c66503 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -0,0 +1,73 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/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); + } + } +} 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 new file mode 100644 index 00000000..70bf955d --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -0,0 +1,67 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, VisualElement } from "@/types/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); + } + } +} 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 new file mode 100644 index 00000000..d9bf0aad --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -0,0 +1,86 @@ +import { Command } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import type { EffectParamValues } from "@/types/effects"; +import type { TimelineTrack, VisualElement } from "@/types/timeline"; + +function updateEffectParamsOnElement({ + element, + effectId, + params, +}: { + element: VisualElement; + effectId: string; + params: Partial; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => { + if (effect.id !== effectId) { + return effect; + } + + const nextParams = { ...effect.params }; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + + return { ...effect, params: nextParams }; + }); + return { ...element, effects: updated }; +} + +export class UpdateClipEffectParamsCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly params: Partial; + + constructor({ + trackId, + elementId, + effectId, + params, + }: { + trackId: string; + elementId: string; + effectId: string; + params: Partial; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.params = params; + } + + 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 updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); + }, + }); + + 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/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 69d94c24..413d1adf 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -10,3 +10,4 @@ export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; export { MoveElementCommand } from "./move-elements"; export * from "./keyframes"; +export * from "./effects"; 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 f221e33a..c03b14e9 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -1,3 +1,5 @@ +export * from "./remove-effect-param-keyframe"; export * from "./remove-keyframe"; export * from "./retime-keyframe"; +export * from "./upsert-effect-param-keyframe"; export * from "./upsert-keyframe"; 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 new file mode 100644 index 00000000..d4922532 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -0,0 +1,68 @@ +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 "@/types/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); + } +} 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 new file mode 100644 index 00000000..f5236279 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -0,0 +1,84 @@ +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 "@/types/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); + } +} 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 6aadcf16..51e18c21 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -11,6 +11,7 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; +import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; export class MoveElementCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -19,6 +20,7 @@ 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, @@ -26,12 +28,14 @@ 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; @@ -39,6 +43,7 @@ export class MoveElementCommand extends Command { this.elementId = elementId; this.newStartTime = newStartTime; this.createTrack = createTrack; + this.rippleEnabled = rippleEnabled; } execute(): void { @@ -53,8 +58,7 @@ export class MoveElementCommand extends Command { ); if (!sourceTrack || !element) { - console.error("Source track or element not found"); - return; + throw new Error("Source track or element not found"); } let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); @@ -69,8 +73,7 @@ export class MoveElementCommand extends Command { targetTrack = newTrack; } if (!targetTrack) { - console.error("Target track not found"); - return; + throw new Error("Target track not found"); } const validation = validateElementTrackCompatibility({ @@ -79,8 +82,7 @@ export class MoveElementCommand extends Command { }); if (!validation.isValid) { - console.error(validation.errorMessage); - return; + throw new Error(validation.errorMessage); } const adjustedStartTime = enforceMainTrackStart({ @@ -105,27 +107,32 @@ export class MoveElementCommand extends Command { elements: track.elements.map((trackElement) => trackElement.id === this.elementId ? movedElement : trackElement, ), - }; + } as typeof track; } if (track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ), - }; + 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; - }); + return track; + }); if (!isSameTrack) { const sourceTrackAfterMove = updatedTracks.find( -- cgit v1.3.1