diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/commands/timeline | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/commands/timeline')
33 files changed, 431 insertions, 136 deletions
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 800cbb90..00758eb7 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -4,7 +4,7 @@ import type { TimelineTrack, TimelineElement, ClipboardItem, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { wouldElementOverlap } from "@/lib/timeline/element-utils"; import { diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 7fb46c36..4b439870 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { isMainTrack, rippleShiftElements } from "@/lib/timeline"; diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index 97b475f4..207e2373 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts index 5299749d..34ca12ab 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; import { buildDefaultEffectInstance } from "@/lib/effects"; function addEffectToElement({ diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts index b475474f..1b309f13 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function removeEffectFromElement({ element, diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts index 81c66503..d946be18 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function reorderEffectsOnElement({ element, diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts index 70bf955d..25e7c356 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -1,7 +1,7 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; export function toggleEffectOnElement({ element, diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts index d9bf0aad..c3443c22 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -1,8 +1,8 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { EffectParamValues } from "@/types/effects"; -import type { TimelineTrack, VisualElement } from "@/types/timeline"; +import type { ParamValues } from "@/lib/params"; +import type { TimelineTrack, VisualElement } from "@/lib/timeline"; function updateEffectParamsOnElement({ element, @@ -11,7 +11,7 @@ function updateEffectParamsOnElement({ }: { element: VisualElement; effectId: string; - params: Partial<EffectParamValues>; + params: Partial<ParamValues>; }): VisualElement { const currentEffects = element.effects ?? []; const updated = currentEffects.map((effect) => { @@ -36,7 +36,7 @@ export class UpdateClipEffectParamsCommand extends Command { private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; - private readonly params: Partial<EffectParamValues>; + private readonly params: Partial<ParamValues>; constructor({ trackId, @@ -47,7 +47,7 @@ export class UpdateClipEffectParamsCommand extends Command { trackId: string; elementId: string; effectId: string; - params: Partial<EffectParamValues>; + params: Partial<ParamValues>; }) { super(); this.trackId = trackId; @@ -65,12 +65,12 @@ export class UpdateClipEffectParamsCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isVisualElement, - update: (element) => { - return updateEffectParamsOnElement({ - element: element as VisualElement, - effectId: this.effectId, - params: this.params, - }); + update: (element) => { + return updateEffectParamsOnElement({ + element: element as VisualElement, + effectId: this.effectId, + params: this.params, + }); }, }); diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 413d1adf..899754fb 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -11,3 +11,5 @@ export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; export { MoveElementCommand } from "./move-elements"; export * from "./keyframes"; export * from "./effects"; +export * from "./masks"; +export * from "./retime"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 68fc9841..acd72b66 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -6,7 +6,7 @@ import type { TimelineElement, TrackType, ElementType, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { requiresMediaId, @@ -19,8 +19,9 @@ import { validateElementTrackCompatibility, enforceMainTrackStart, } from "@/lib/timeline/track-utils"; -import type { MediaAsset } from "@/types/assets"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import type { MediaAsset } from "@/lib/media/types"; +import { ELEMENT_TRACK_MAP, TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -168,6 +169,14 @@ export class InsertElementCommand extends Command { return false; } + if (element.type === "graphic") { + registerDefaultGraphics(); + if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { + console.error("Graphic element must have a valid definitionId"); + return false; + } + } + if (element.type === "text" && !element.content) { console.error("Text element must have content"); return false; @@ -344,9 +353,6 @@ export class InsertElementCommand extends Command { }: { element: { type: ElementType }; }): TrackType { - if (element.type === "video" || element.type === "image") { - return "video"; - } - return element.type; + return ELEMENT_TRACK_MAP[element.type]; } } diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts index d4922532..984a5ed7 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command"; import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; export class RemoveEffectParamKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index f12f9829..eb5cf742 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -2,15 +2,13 @@ import { EditorCore } from "@/core"; import { getChannel, getChannelValueAtTime, - getElementBaseValueForProperty, removeElementKeyframe, - supportsAnimationProperty, - withElementBaseValueForProperty, + resolveAnimationTarget, } from "@/lib/animation"; import { Command } from "@/lib/commands/base-command"; import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; function sampleValueBeforeRemoval({ element, @@ -18,9 +16,9 @@ function sampleValueBeforeRemoval({ keyframeId, }: { element: TimelineElement; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; -}): number | null { +}): AnimationValue | null { const channel = getChannel({ animations: element.animations, propertyPath, @@ -32,17 +30,20 @@ function sampleValueBeforeRemoval({ return null; } - const baseValue = getElementBaseValueForProperty({ element, propertyPath }); - if (baseValue === null || typeof baseValue !== "number") { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return null; + } + const baseValue = target.getBaseValue(); + if (baseValue === null) { return null; } - const sampled = getChannelValueAtTime({ + return getChannelValueAtTime({ channel, time: keyframe.time, fallbackValue: baseValue, }); - return typeof sampled === "number" ? sampled : null; } function removeKeyframeAndPersist({ @@ -51,9 +52,14 @@ function removeKeyframeAndPersist({ keyframeId, }: { element: TimelineElement; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; }): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + const valueBefore = sampleValueBeforeRemoval({ element, propertyPath, @@ -71,11 +77,7 @@ function removeKeyframeAndPersist({ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; const baseElement = shouldPersistToBase - ? withElementBaseValueForProperty({ - element, - propertyPath, - value: valueBefore, - }) + ? target.setBaseValue(valueBefore) : element; return { ...baseElement, animations: nextAnimations }; @@ -85,7 +87,7 @@ export class RemoveKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly keyframeId: string; constructor({ @@ -96,7 +98,7 @@ export class RemoveKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; }) { super(); @@ -114,11 +116,6 @@ export class RemoveKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => removeKeyframeAndPersist({ element, diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts index 5328419f..9e912f27 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,15 +1,15 @@ import { EditorCore } from "@/core"; -import { retimeElementKeyframe, supportsAnimationProperty } from "@/lib/animation"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; import { Command } from "@/lib/commands/base-command"; import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPropertyPath } from "@/types/animation"; -import type { TimelineTrack } from "@/types/timeline"; +import type { AnimationPath } from "@/lib/animation/types"; +import type { TimelineTrack } from "@/lib/timeline"; export class RetimeKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly keyframeId: string; private readonly nextTime: number; @@ -22,7 +22,7 @@ export class RetimeKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; keyframeId: string; nextTime: number; }) { @@ -42,14 +42,12 @@ export class RetimeKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); - if (!Number.isFinite(boundedTime)) return element; return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index f5236279..285bd80c 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -3,7 +3,7 @@ import { Command } from "@/lib/commands/base-command"; import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; import { updateElementInTracks } from "@/lib/timeline"; import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 370f6865..28e7feff 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,19 +1,19 @@ import { EditorCore } from "@/core"; import { Command } from "@/lib/commands/base-command"; -import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import type { + AnimationPath, AnimationInterpolation, - AnimationPropertyPath, AnimationValue, -} from "@/types/animation"; +} from "@/lib/animation/types"; export class UpsertKeyframeCommand extends Command { private savedState: TimelineTrack[] | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly propertyPath: AnimationPropertyPath; + private readonly propertyPath: AnimationPath; private readonly time: number; private readonly value: AnimationValue; private readonly interpolation: AnimationInterpolation | undefined; @@ -30,7 +30,7 @@ export class UpsertKeyframeCommand extends Command { }: { trackId: string; elementId: string; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationPath; time: number; value: AnimationValue; interpolation?: AnimationInterpolation; @@ -54,22 +54,28 @@ export class UpsertKeyframeCommand extends Command { tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, - elementPredicate: (element) => - supportsAnimationProperty({ - element, - propertyPath: this.propertyPath, - }), update: (element) => { + const target = resolveAnimationTarget({ + element, + path: this.propertyPath, + }); + if (!target) { + return element; + } + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); return { ...element, - animations: upsertElementKeyframe({ + animations: upsertPathKeyframe({ animations: element.animations, propertyPath: this.propertyPath, time: boundedTime, value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, + valueKind: target.valueKind, + defaultInterpolation: target.defaultInterpolation, + numericRange: target.numericRange, }), }; }, diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts new file mode 100644 index 00000000..acc47184 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -0,0 +1,2 @@ +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts new file mode 100644 index 00000000..ee09cbdc --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -0,0 +1,64 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts new file mode 100644 index 00000000..ac3c8c7c --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { Command } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; +import type { Mask } from "@/lib/masks/types"; +import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = <TMask extends Mask>(mask: TMask): TMask => ({ + ...mask, + params: { + ...mask.params, + inverted: !mask.params.inverted, + }, + }); + const updatedMasks = currentMasks.map((mask) => + mask.id !== maskId ? mask : toggleMask(mask), + ); + + return { ...element, masks: updatedMasks }; +} + +export class ToggleMaskInvertedCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + toggleMaskInvertedOnElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 51e18c21..a36e8e93 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -4,7 +4,7 @@ import type { TimelineTrack, TimelineElement, TrackType, -} from "@/types/timeline"; +} from "@/lib/timeline"; import { buildEmptyTrack, isMainTrack, diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts new file mode 100644 index 00000000..5daa59bf --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/retime/index.ts @@ -0,0 +1 @@ +export { UpdateElementRetimeCommand } from "./update-element-retime"; diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts new file mode 100644 index 00000000..d1f859e6 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts @@ -0,0 +1,114 @@ +import { EditorCore } from "@/core"; +import { clampRetimeRate } from "@/constants/retime-constants"; +import { clampAnimationsToDuration } from "@/lib/animation"; +import { Command } from "@/lib/commands/base-command"; +import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; +import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; +import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} + +export class UpdateElementRetimeCommand extends Command { + private savedState: TimelineTrack[] | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly retime: RetimeConfig | undefined; + + constructor({ + trackId, + elementId, + retime, + }: { + trackId: string; + elementId: string; + retime?: RetimeConfig; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.retime = retime; + } + + execute(): void { + const editor = EditorCore.getInstance(); + this.savedState = editor.timeline.getTracks(); + + const updatedTracks = updateElementInTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isRetimableElement, + update: (element) => { + if (!isRetimableElement(element)) { + return element; + } + + const nextRetime = this.retime + ? { + ...this.retime, + rate: clampRetimeRate({ rate: this.retime.rate }), + } + : undefined; + const sourceDuration = getSourceDuration({ + trimStart: element.trimStart, + trimEnd: element.trimEnd, + duration: element.duration, + sourceDuration: element.sourceDuration, + retime: element.retime, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + ...element, + retime: nextRetime, + duration: nextDuration, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: nextDuration, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index 37ccbb12..2acac683 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -1,9 +1,10 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; import { splitAnimationsAtTime } from "@/lib/animation"; +import { getSourceSpanAtClipTime } from "@/lib/retime"; export class SplitElementsCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -75,6 +76,18 @@ export class SplitElementsCommand extends Command { const relativeTime = this.splitTime - element.startTime; const leftVisibleDuration = relativeTime; const rightVisibleDuration = element.duration - relativeTime; + const retimeRef = isRetimableElement(element) + ? element.retime + : undefined; + const leftSourceSpan = getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }); + const totalSourceSpan = getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }); + const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -83,63 +96,67 @@ export class SplitElementsCommand extends Command { if (this.retainSide === "left") { return [ - { - ...element, - duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, - name: `${element.name} (left)`, - animations: leftAnimations, - }, - ]; - } - - if (this.retainSide === "right") { - if (this.rippleEnabled && elementsToSplit.length === 1) { - leftVisibleDurationForRipple = leftVisibleDuration; - } - const newId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: newId, - }); - return [ - { - ...element, - id: newId, - startTime: this.splitTime, - duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, - name: `${element.name} (right)`, - animations: rightAnimations, - }, - ]; - } - - // "both" - split into two pieces - const secondElementId = generateUUID(); - this.rightSideElements.push({ - trackId: track.id, - elementId: secondElementId, - }); - - return [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, name: `${element.name} (left)`, animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, + ]; + } + + if (this.retainSide === "right") { + if (this.rippleEnabled && elementsToSplit.length === 1) { + leftVisibleDurationForRipple = leftVisibleDuration; + } + const newId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: newId, + }); + return [ { ...element, - id: secondElementId, + id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, name: `${element.name} (right)`, animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; + } + + // "both" - split into two pieces + const secondElementId = generateUUID(); + this.rightSideElements.push({ + trackId: track.id, + elementId: secondElementId, + }); + + return [ + { + ...element, + duration: leftVisibleDuration, + trimEnd: element.trimEnd + rightSourceSpan, + name: `${element.name} (left)`, + animations: leftAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + { + ...element, + id: secondElementId, + startTime: this.splitTime, + duration: rightVisibleDuration, + trimStart: element.trimStart + leftSourceSpan, + name: `${element.name} (right)`, + animations: rightAnimations, + ...(retimeRef !== undefined ? { retime: retimeRef } : {}), + }, + ]; }); if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts index 1fa7421e..f416e7c5 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { canElementHaveAudio } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts index daeb759a..24d51c15 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { canElementBeHidden } from "@/lib/timeline/element-utils"; import { EditorCore } from "@/core"; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts index 3366548d..d88cf9cb 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts index e25bc0e3..75773ec2 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts index 7548638a..b2c77418 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts @@ -1,8 +1,9 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { clampAnimationsToDuration } from "@/lib/animation"; -import { rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; +import { enforceMainTrackStart } from "@/lib/timeline/track-utils"; export class UpdateElementTrimCommand extends Command { private savedState: TimelineTrack[] | null = null; @@ -48,7 +49,13 @@ export class UpdateElementTrimCommand extends Command { if (!targetElement) return track; const nextDuration = this.duration ?? targetElement.duration; - const nextStartTime = this.startTime ?? targetElement.startTime; + const requestedStartTime = this.startTime ?? targetElement.startTime; + const nextStartTime = enforceMainTrackStart({ + tracks: this.savedState ?? [], + targetTrackId: track.id, + requestedStartTime, + excludeElementId: this.elementId, + }); const oldEndTime = targetElement.startTime + targetElement.duration; const newEndTime = nextStartTime + nextDuration; @@ -60,6 +67,9 @@ export class UpdateElementTrimCommand extends Command { trimEnd: this.trimEnd, startTime: nextStartTime, duration: nextDuration, + ...(isRetimableElement(targetElement) + ? { retime: targetElement.retime } + : {}), animations: clampAnimationsToDuration({ animations: targetElement.animations, duration: nextDuration, @@ -68,7 +78,9 @@ export class UpdateElementTrimCommand extends Command { if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { const shiftedOthers = rippleShiftElements({ - elements: track.elements.filter((element) => element.id !== this.elementId), + elements: track.elements.filter( + (element) => element.id !== this.elementId, + ), afterTime: oldEndTime, shiftAmount, }); @@ -77,7 +89,8 @@ export class UpdateElementTrimCommand extends Command { elements: track.elements.map((element) => element.id === this.elementId ? updatedElement - : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? element) + : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? + element), ), } as typeof track; } @@ -85,7 +98,7 @@ export class UpdateElementTrimCommand extends Command { return { ...track, elements: track.elements.map((element) => - element.id === this.elementId ? updatedElement : element + element.id === this.elementId ? updatedElement : element, ), } as typeof track; }); diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts index 643f8e4b..cf0c5cbc 100644 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ b/apps/web/src/lib/commands/timeline/element/update-element.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/types/timeline"; +import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { updateElementInTracks } from "@/lib/timeline"; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index 95338888..dd4ceb64 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/types/timeline"; +import type { TrackType, TimelineTrack } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index a32aacd7..35f29b1c 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,6 +1,6 @@ import { Command } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { getMainTrack } from "@/lib/timeline"; export class RemoveTrackCommand extends Command { diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 3fe526c7..9acf732a 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTracktHaveAudio } from "@/lib/timeline"; diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts index e81714ac..4bfd53c0 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; import { canTrackBeHidden } from "@/lib/timeline"; diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index 2ba059c7..a4370cab 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,5 +1,5 @@ import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/types/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { |
