diff options
Diffstat (limited to 'apps/web/src/commands/timeline')
16 files changed, 219 insertions, 100 deletions
diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts index c19bd526..a587ef14 100644 --- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts +++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts @@ -10,6 +10,13 @@ import type { SceneTracks, TimelineElement } from "@/timeline"; import { updateElementInSceneTracks } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { generateUUID } from "@/utils/id"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; function pasteKeyframesIntoElement({ element, @@ -17,7 +24,7 @@ function pasteKeyframesIntoElement({ clipboardItems, }: { element: TimelineElement; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }): TimelineElement { let nextElement = element; @@ -31,10 +38,13 @@ function pasteKeyframesIntoElement({ continue; } - const keyframeTime = Math.max( - 0, - Math.min(time + item.timeOffset, nextElement.duration), - ); + const keyframeTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ + a: addMediaTime({ a: time, b: item.timeOffset }), + b: nextElement.duration, + }), + }); const nextAnimations = upsertPathKeyframe({ animations: nextElement.animations, propertyPath: item.propertyPath, @@ -42,8 +52,7 @@ function pasteKeyframesIntoElement({ value: item.value, interpolation: item.interpolation, keyframeId: generateUUID(), - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); const pastedKeyframe = getKeyframeAtTime({ @@ -79,7 +88,7 @@ export class PasteKeyframesCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: KeyframeClipboardItem[]; constructor({ @@ -90,7 +99,7 @@ export class PasteKeyframesCommand extends Command { }: { trackId: string; elementId: string; - time: number; + time: MediaTime; clipboardItems: KeyframeClipboardItem[]; }) { super(); diff --git a/apps/web/src/commands/timeline/clipboard/paste.ts b/apps/web/src/commands/timeline/clipboard/paste.ts index 4523566e..be0c2b91 100644 --- a/apps/web/src/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/commands/timeline/clipboard/paste.ts @@ -13,18 +13,25 @@ import { enforceMainTrackStart, } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import { + addMediaTime, + type MediaTime, + maxMediaTime, + subMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class PasteCommand extends Command { private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; - private readonly time: number; + private readonly time: MediaTime; private readonly clipboardItems: ElementClipboardItem[]; constructor({ time, clipboardItems, }: { - time: number; + time: MediaTime; clipboardItems: ElementClipboardItem[]; }) { super(); @@ -39,8 +46,12 @@ export class PasteCommand extends Command { this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; - const minStart = Math.min( - ...this.clipboardItems.map((item) => item.element.startTime), + const minStart = this.clipboardItems.reduce( + (earliestStartTime, item) => + item.element.startTime < earliestStartTime + ? item.element.startTime + : earliestStartTime, + this.clipboardItems[0].element.startTime, ); let updatedTracks = this.savedState; @@ -97,12 +108,18 @@ export class PasteCommand extends Command { targetTrackId: targetTrack.id, requestedStartTime: earliestElement.startTime, }); - const delta = adjustedEarliestStartTime - earliestElement.startTime; + const delta = subMediaTime({ + a: adjustedEarliestStartTime, + b: earliestElement.startTime, + }); - if (delta !== 0) { + if (delta !== ZERO_MEDIA_TIME) { elementsForPlacement = elementsToAdd.map((element) => ({ ...element, - startTime: Math.max(0, element.startTime + delta), + startTime: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: element.startTime, b: delta }), + }), })); } } @@ -168,14 +185,20 @@ function buildPastedElements({ time, }: { items: ElementClipboardItem[]; - minStart: number; - time: number; + minStart: MediaTime; + time: MediaTime; }): TimelineElement[] { const elementsToAdd: TimelineElement[] = []; for (const item of items) { - const relativeOffset = item.element.startTime - minStart; - const startTime = Math.max(0, time + relativeOffset); + const relativeOffset = subMediaTime({ + a: item.element.startTime, + b: minStart, + }); + const startTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: time, b: relativeOffset }), + }); const newElementId = generateUUID(); elementsToAdd.push({ diff --git a/apps/web/src/commands/timeline/element/duplicate-elements.ts b/apps/web/src/commands/timeline/element/duplicate-elements.ts index 8f65f7c7..2b1ab4ea 100644 --- a/apps/web/src/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/commands/timeline/element/duplicate-elements.ts @@ -8,6 +8,7 @@ import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/timeline/placement"; import { cloneAnimations } from "@/animation"; +import type { MediaTime } from "@/wasm"; interface DuplicateElementsParams { elements: { trackId: string; elementId: string }[]; @@ -119,7 +120,7 @@ function buildDuplicateElement({ }: { element: TimelineElement; id: string; - startTime: number; + startTime: MediaTime; }): TimelineElement { return { ...element, diff --git a/apps/web/src/commands/timeline/element/insert-element.ts b/apps/web/src/commands/timeline/element/insert-element.ts index 49ac6f2d..dfde0ffe 100644 --- a/apps/web/src/commands/timeline/element/insert-element.ts +++ b/apps/web/src/commands/timeline/element/insert-element.ts @@ -18,6 +18,7 @@ import { resolveTrackPlacement, validateElementTrackCompatibility, } from "@/timeline/placement"; +import { roundMediaTime } from "@/wasm"; type InsertElementPlacement = | { mode: "explicit"; trackId: string } @@ -188,7 +189,7 @@ export class InsertElementCommand extends Command { } } - if (element.type === "text" && !element.content) { + if (element.type === "text" && !element.params.content) { console.error("Text element must have content"); return false; } @@ -266,7 +267,12 @@ export class InsertElementCommand extends Command { placementResult.kind === "existingTrack" ? { ...element, - startTime: placementResult.adjustedStartTime ?? element.startTime, + startTime: + placementResult.adjustedStartTime !== undefined + ? roundMediaTime({ + time: placementResult.adjustedStartTime, + }) + : element.startTime, } : element; diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts index 07abe2be..ad12e680 100644 --- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts @@ -5,7 +5,8 @@ import { } from "@/animation"; import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; -import type { AnimationPath, AnimationValue } from "@/animation/types"; +import type { AnimationPath } from "@/animation/types"; +import type { ParamValue } from "@/params"; import type { SceneTracks, TimelineElement } from "@/timeline"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; @@ -18,7 +19,7 @@ function removeKeyframeAndPersist({ element: TimelineElement; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }): TimelineElement { const target = resolveAnimationTarget({ element, path: propertyPath }); if (!target) { @@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly valueAtPlayhead: AnimationValue | null; + private readonly valueAtPlayhead: ParamValue | null; constructor({ trackId, @@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - valueAtPlayhead: AnimationValue | null; + valueAtPlayhead: ParamValue | null; }) { super(); this.trackId = trackId; diff --git a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts index aa04e2bc..94054372 100644 --- a/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts @@ -4,6 +4,12 @@ import { Command, type CommandResult } from "@/commands/base-command"; import { updateElementInSceneTracks } from "@/timeline"; import type { AnimationPath } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; export class RetimeKeyframeCommand extends Command { @@ -12,7 +18,7 @@ export class RetimeKeyframeCommand extends Command { private readonly elementId: string; private readonly propertyPath: AnimationPath; private readonly keyframeId: string; - private readonly nextTime: number; + private readonly nextTime: MediaTime; constructor({ trackId, @@ -25,7 +31,7 @@ export class RetimeKeyframeCommand extends Command { elementId: string; propertyPath: AnimationPath; keyframeId: string; - nextTime: number; + nextTime: MediaTime; }) { super(); this.trackId = trackId; @@ -48,7 +54,10 @@ export class RetimeKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.nextTime, b: element.duration }), + }); return { ...element, animations: retimeElementKeyframe({ diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 7bd1b2e5..babe1ad0 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -9,6 +9,12 @@ import { isVisualElement } from "@/timeline/element-utils"; import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationInterpolation } from "@/animation/types"; import type { SceneTracks } from "@/timeline"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertEffectParamKeyframeCommand extends Command { private savedState: SceneTracks | null = null; @@ -16,7 +22,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { private readonly elementId: string; private readonly effectId: string; private readonly paramKey: string; - private readonly time: number; + private readonly time: MediaTime; private readonly value: number | string | boolean; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -35,7 +41,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: string; effectId: string; paramKey: string; - time: number; + time: MediaTime; value: number | string | boolean; interpolation?: AnimationInterpolation; keyframeId?: string; @@ -61,7 +67,10 @@ export class UpsertEffectParamKeyframeCommand extends Command { elementId: this.elementId, elementPredicate: isVisualElement, update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); const propertyPath = buildEffectParamPath({ effectId: this.effectId, paramKey: this.paramKey, @@ -81,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }); return { ...element, animations }; diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts index 15c18bfc..8ba13a11 100644 --- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -7,16 +7,22 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets"; import type { AnimationPath, AnimationInterpolation, - AnimationValue, } from "@/animation/types"; +import type { ParamValue } from "@/params"; +import { + type MediaTime, + maxMediaTime, + minMediaTime, + ZERO_MEDIA_TIME, +} from "@/wasm"; export class UpsertKeyframeCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly propertyPath: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; + private readonly time: MediaTime; + private readonly value: ParamValue; private readonly interpolation: AnimationInterpolation | undefined; private readonly keyframeId: string | undefined; @@ -32,8 +38,8 @@ export class UpsertKeyframeCommand extends Command { trackId: string; elementId: string; propertyPath: AnimationPath; - time: number; - value: AnimationValue; + time: MediaTime; + value: ParamValue; interpolation?: AnimationInterpolation; keyframeId?: string; }) { @@ -64,7 +70,10 @@ export class UpsertKeyframeCommand extends Command { return element; } - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const boundedTime = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: minMediaTime({ a: this.time, b: element.duration }), + }); return { ...element, animations: upsertPathKeyframe({ @@ -74,8 +83,7 @@ export class UpsertKeyframeCommand extends Command { value: this.value, interpolation: this.interpolation, keyframeId: this.keyframeId, - kind: target.kind, - defaultInterpolation: target.defaultInterpolation, + channelLayout: target.channelLayout, coerceValue: target.coerceValue, }), }; diff --git a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts index 1445b1f8..fb02554d 100644 --- a/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts +++ b/apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts @@ -1,22 +1,22 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; import { - getCustomMaskClosedStateAfterPointRemoval, - removeCustomMaskPoints, -} from "@/masks/custom-path"; -import type { CustomMask } from "@/masks/types"; + getFreeformPathClosedStateAfterPointRemoval, + removeFreeformPathPoints, +} from "@/masks/freeform/path"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function deletePointsFromCustomMask({ +function deletePointsFromFreeformPathMask({ mask, pointIds, }: { - mask: CustomMask; + mask: FreeformPathMask; pointIds: string[]; -}): CustomMask { +}): FreeformPathMask { const points = mask.params.path; - const nextPoints = removeCustomMaskPoints({ points, pointIds }); + const nextPoints = removeFreeformPathPoints({ points, pointIds }); if (nextPoints.length === points.length) { return mask; } @@ -26,7 +26,7 @@ function deletePointsFromCustomMask({ params: { ...mask.params, path: nextPoints, - closed: getCustomMaskClosedStateAfterPointRemoval({ + closed: getFreeformPathClosedStateAfterPointRemoval({ wasClosed: mask.params.closed, remainingPointCount: nextPoints.length, }), @@ -46,11 +46,11 @@ function deletePointsFromElementMask({ const currentMasks = element.masks ?? []; let didDeletePoints = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const nextMask = deletePointsFromCustomMask({ + const nextMask = deletePointsFromFreeformPathMask({ mask, pointIds, }); @@ -64,7 +64,7 @@ function deletePointsFromElementMask({ }; } -export class DeleteCustomMaskPointsCommand extends Command { +export class DeleteFreeformPathMaskPointsCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -100,8 +100,9 @@ export class DeleteCustomMaskPointsCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = deletePointsFromElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, pointIds: this.pointIds, }); diff --git a/apps/web/src/commands/timeline/element/masks/index.ts b/apps/web/src/commands/timeline/element/masks/index.ts index dd6373b1..70b4333e 100644 --- a/apps/web/src/commands/timeline/element/masks/index.ts +++ b/apps/web/src/commands/timeline/element/masks/index.ts @@ -1,4 +1,4 @@ -export { DeleteCustomMaskPointsCommand } from "./delete-custom-mask-points"; -export { InsertCustomMaskPointCommand } from "./insert-custom-mask-point"; +export { DeleteFreeformPathMaskPointsCommand } from "./delete-custom-mask-points"; +export { InsertFreeformPathMaskPointCommand } from "./insert-custom-mask-point"; export { RemoveMaskCommand } from "./remove-mask"; export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts index 58559af6..0f714884 100644 --- a/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts +++ b/apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts @@ -1,23 +1,23 @@ import { EditorCore } from "@/core"; import { Command, type CommandResult } from "@/commands/base-command"; -import { insertPointOnCustomMaskSegment } from "@/masks/definitions/custom"; +import { insertPointOnFreeformSegment } from "@/masks/freeform/definition"; import type { ElementBounds } from "@/preview/element-bounds"; -import type { CustomMask } from "@/masks/types"; +import type { FreeformPathMask } from "@/masks/types"; import { isMaskableElement, updateElementInSceneTracks } from "@/timeline"; import type { MaskableElement, SceneTracks } from "@/timeline"; -function insertPointIntoCustomMask({ +function insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, bounds, }: { - mask: CustomMask; + mask: FreeformPathMask; segmentIndex: number; canvasPoint: { x: number; y: number }; bounds: ElementBounds; -}): { mask: CustomMask; insertedPointId: string | null } { - const result = insertPointOnCustomMaskSegment({ +}): { mask: FreeformPathMask; insertedPointId: string | null } { + const result = insertPointOnFreeformSegment({ params: mask.params, segmentIndex, canvasPoint, @@ -61,11 +61,11 @@ function insertPointIntoElementMask({ let didInsertPoint = false; const nextMasks = currentMasks.map((mask) => { - if (mask.id !== maskId || mask.type !== "custom") { + if (mask.id !== maskId || mask.type !== "freeform") { return mask; } - const result = insertPointIntoCustomMask({ + const result = insertPointIntoFreeformPathMask({ mask, segmentIndex, canvasPoint, @@ -85,7 +85,7 @@ function insertPointIntoElementMask({ }; } -export class InsertCustomMaskPointCommand extends Command { +export class InsertFreeformPathMaskPointCommand extends Command { private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; @@ -130,8 +130,9 @@ export class InsertCustomMaskPointCommand extends Command { elementId: this.elementId, elementPredicate: isMaskableElement, update: (element) => { + if (!isMaskableElement(element)) return element; const result = insertPointIntoElementMask({ - element: element as MaskableElement, + element, maskId: this.maskId, segmentIndex: this.segmentIndex, canvasPoint: this.canvasPoint, diff --git a/apps/web/src/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/commands/timeline/element/masks/remove-mask.ts index b543bce1..b92d1d8c 100644 --- a/apps/web/src/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/commands/timeline/element/masks/remove-mask.ts @@ -45,11 +45,13 @@ export class RemoveMaskCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return removeMaskFromElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts index fec9b923..b53e24ca 100644 --- a/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -56,11 +56,13 @@ export class ToggleMaskInvertedCommand extends Command { trackId: this.trackId, elementId: this.elementId, elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, + update: (element) => { + if (!isMaskableElement(element)) return element; + return toggleMaskInvertedOnElement({ + element, maskId: this.maskId, - }), + }); + }, }); editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/commands/timeline/element/split-elements.ts b/apps/web/src/commands/timeline/element/split-elements.ts index 11c317f4..0a551fc3 100644 --- a/apps/web/src/commands/timeline/element/split-elements.ts +++ b/apps/web/src/commands/timeline/element/split-elements.ts @@ -9,12 +9,18 @@ import { EditorCore } from "@/core"; import { isRetimableElement } from "@/timeline"; import { splitAnimationsAtTime } from "@/animation"; import { getSourceSpanAtClipTime } from "@/retime"; +import { + addMediaTime, + type MediaTime, + roundMediaTime, + subMediaTime, +} from "@/wasm"; export class SplitElementsCommand extends Command { private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; - private readonly splitTime: number; + private readonly splitTime: MediaTime; private readonly retainSide: "both" | "left" | "right"; constructor({ @@ -23,7 +29,7 @@ export class SplitElementsCommand extends Command { retainSide = "both", }: { elements: { trackId: string; elementId: string }[]; - splitTime: number; + splitTime: MediaTime; retainSide?: "both" | "left" | "right"; }) { super(); @@ -73,21 +79,39 @@ export class SplitElementsCommand extends Command { return [element]; } - const relativeTime = this.splitTime - element.startTime; + const relativeTime = subMediaTime({ + a: this.splitTime, + b: element.startTime, + }); const leftVisibleDuration = relativeTime; - const rightVisibleDuration = element.duration - relativeTime; + const rightVisibleDuration = subMediaTime({ + a: element.duration, + b: relativeTime, + }); const retimeRef = isRetimableElement(element) ? element.retime : undefined; - const leftSourceSpan = getSourceSpanAtClipTime({ - clipTime: leftVisibleDuration, - retime: retimeRef, + // Snap the source-side split point exactly once and derive the right + // half from it. Independently rounding both spans (left and total) + // would let a 1-tick rounding error desynchronise them, breaking the + // invariant `leftSourceSpan + rightSourceSpan == totalSourceSpan`. + // See the same discipline in `compute-resize.ts` (snap-once comment). + const leftSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: leftVisibleDuration, + retime: retimeRef, + }), + }); + const totalSourceSpan = roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: element.duration, + retime: retimeRef, + }), }); - const totalSourceSpan = getSourceSpanAtClipTime({ - clipTime: element.duration, - retime: retimeRef, + const rightSourceSpan = subMediaTime({ + a: totalSourceSpan, + b: leftSourceSpan, }); - const rightSourceSpan = totalSourceSpan - leftSourceSpan; const { leftAnimations, rightAnimations } = splitAnimationsAtTime({ animations: element.animations, splitTime: relativeTime, @@ -95,12 +119,21 @@ export class SplitElementsCommand extends Command { }); let splitResult: TimelineElement[]; + const leftTrimEnd = addMediaTime({ + a: element.trimEnd, + b: rightSourceSpan, + }); + const rightTrimStart = addMediaTime({ + a: element.trimStart, + b: leftSourceSpan, + }); + if (this.retainSide === "left") { splitResult = [ { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -118,14 +151,13 @@ export class SplitElementsCommand extends Command { id: newId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), }, ]; } else { - // "both" - split into two pieces const secondElementId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -135,7 +167,7 @@ export class SplitElementsCommand extends Command { { ...element, duration: leftVisibleDuration, - trimEnd: element.trimEnd + rightSourceSpan, + trimEnd: leftTrimEnd, name: `${element.name} (left)`, animations: leftAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), @@ -145,7 +177,7 @@ export class SplitElementsCommand extends Command { id: secondElementId, startTime: this.splitTime, duration: rightVisibleDuration, - trimStart: element.trimStart + leftSourceSpan, + trimStart: rightTrimStart, name: `${element.name} (right)`, animations: rightAnimations, ...(retimeRef !== undefined ? { retime: retimeRef } : {}), diff --git a/apps/web/src/commands/timeline/track/add-track.ts b/apps/web/src/commands/timeline/track/add-track.ts index 6b58b687..90becf13 100644 --- a/apps/web/src/commands/timeline/track/add-track.ts +++ b/apps/web/src/commands/timeline/track/add-track.ts @@ -11,14 +11,22 @@ export class AddTrackCommand extends Command { private trackId: string; private savedState: SceneTracks | null = null; - constructor( - private type: TrackType, - private index?: number, - ) { + constructor({ + type, + index, + }: { + type: TrackType; + index?: number; + }) { super(); + this.type = type; + this.index = index; this.trackId = generateUUID(); } + private type: TrackType; + private index?: number; + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedState = editor.scenes.getActiveScene().tracks; diff --git a/apps/web/src/commands/timeline/tracks-snapshot.ts b/apps/web/src/commands/timeline/tracks-snapshot.ts index 5b6d871a..8e55e602 100644 --- a/apps/web/src/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/commands/timeline/tracks-snapshot.ts @@ -3,13 +3,21 @@ import type { SceneTracks } from "@/timeline"; import { EditorCore } from "@/core"; export class TracksSnapshotCommand extends Command { - constructor( - private before: SceneTracks, - private after: SceneTracks, - ) { + constructor({ + before, + after, + }: { + before: SceneTracks; + after: SceneTracks; + }) { super(); + this.before = before; + this.after = after; } + private before: SceneTracks; + private after: SceneTracks; + execute(): CommandResult | undefined { EditorCore.getInstance().timeline.updateTracks(this.after); return undefined; |
