summaryrefslogtreecommitdiff
path: root/apps/web/src/commands/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/commands/timeline')
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste-keyframes.ts27
-rw-r--r--apps/web/src/commands/timeline/clipboard/paste.ts45
-rw-r--r--apps/web/src/commands/timeline/element/duplicate-elements.ts3
-rw-r--r--apps/web/src/commands/timeline/element/insert-element.ts10
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts9
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/retime-keyframe.ts15
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts18
-rw-r--r--apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts24
-rw-r--r--apps/web/src/commands/timeline/element/masks/delete-custom-mask-points.ts27
-rw-r--r--apps/web/src/commands/timeline/element/masks/index.ts4
-rw-r--r--apps/web/src/commands/timeline/element/masks/insert-custom-mask-point.ts21
-rw-r--r--apps/web/src/commands/timeline/element/masks/remove-mask.ts10
-rw-r--r--apps/web/src/commands/timeline/element/masks/toggle-mask-inverted.ts10
-rw-r--r--apps/web/src/commands/timeline/element/split-elements.ts64
-rw-r--r--apps/web/src/commands/timeline/track/add-track.ts16
-rw-r--r--apps/web/src/commands/timeline/tracks-snapshot.ts16
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;