summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/commands/timeline/element
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
commit49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch)
tree1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/lib/commands/timeline/element
parent9b94f89def4f96836c4f42a23a73e97fe647a43c (diff)
downloadopencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/lib/commands/timeline/element')
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/index.ts3
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts141
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts75
-rw-r--r--apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts89
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts45
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts34
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts40
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts21
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts38
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts34
13 files changed, 488 insertions, 61 deletions
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 91eb6397..3d1e50cc 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -5,9 +5,15 @@ import { isMainTrack } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
- constructor(private elements: { trackId: string; elementId: string }[]) {
+ constructor({
+ elements,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ }) {
super();
+ this.elements = elements;
}
execute(): void {
@@ -17,7 +23,7 @@ export class DeleteElementsCommand extends Command {
const updatedTracks = this.savedState
.map((track) => {
const hasElementsToDelete = this.elements.some(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToDelete) {
@@ -29,7 +35,9 @@ export class DeleteElementsCommand extends Command {
elements: track.elements.filter(
(element) =>
!this.elements.some(
- (el) => el.trackId === track.id && el.elementId === element.id,
+ (elementEntry) =>
+ elementEntry.trackId === track.id &&
+ elementEntry.elementId === element.id,
),
),
} as typeof track;
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 cf1bd94c..97b475f4 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -6,6 +6,7 @@ import {
buildEmptyTrack,
getHighestInsertIndexForTrack,
} from "@/lib/timeline/track-utils";
+import { cloneAnimations } from "@/lib/animation";
interface DuplicateElementsParams {
elements: { trackId: string; elementId: string }[];
@@ -32,7 +33,7 @@ export class DuplicateElementsCommand extends Command {
for (const track of this.savedState) {
const elementsToDuplicate = this.elements.filter(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToDuplicate.length === 0) {
@@ -114,5 +115,14 @@ function buildDuplicateElement({
id: string;
startTime: number;
}): TimelineElement {
- return { ...element, id, name: `${element.name} (copy)`, startTime };
+ return {
+ ...element,
+ id,
+ name: `${element.name} (copy)`,
+ startTime,
+ animations: cloneAnimations({
+ animations: element.animations,
+ shouldRegenerateKeyframeIds: true,
+ }),
+ };
}
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 3f10baa4..69d94c24 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -9,3 +9,4 @@ export { UpdateElementCommand } from "./update-element";
export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
export { MoveElementCommand } from "./move-elements";
+export * from "./keyframes";
diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
new file mode 100644
index 00000000..f221e33a
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts
@@ -0,0 +1,3 @@
+export * from "./remove-keyframe";
+export * from "./retime-keyframe";
+export * from "./upsert-keyframe";
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
new file mode 100644
index 00000000..f12f9829
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -0,0 +1,141 @@
+import { EditorCore } from "@/core";
+import {
+ getChannel,
+ getChannelValueAtTime,
+ getElementBaseValueForProperty,
+ removeElementKeyframe,
+ supportsAnimationProperty,
+ withElementBaseValueForProperty,
+} 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";
+
+function sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): number | null {
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath,
+ });
+ const keyframe = channel?.keyframes.find(
+ (candidate) => candidate.id === keyframeId,
+ );
+ if (!channel || !keyframe) {
+ return null;
+ }
+
+ const baseValue = getElementBaseValueForProperty({ element, propertyPath });
+ if (baseValue === null || typeof baseValue !== "number") {
+ return null;
+ }
+
+ const sampled = getChannelValueAtTime({
+ channel,
+ time: keyframe.time,
+ fallbackValue: baseValue,
+ });
+ return typeof sampled === "number" ? sampled : null;
+}
+
+function removeKeyframeAndPersist({
+ element,
+ propertyPath,
+ keyframeId,
+}: {
+ element: TimelineElement;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}): TimelineElement {
+ const valueBefore = sampleValueBeforeRemoval({
+ element,
+ propertyPath,
+ keyframeId,
+ });
+
+ const nextAnimations = removeElementKeyframe({
+ animations: element.animations,
+ propertyPath,
+ keyframeId,
+ });
+
+ const isChannelNowEmpty =
+ getChannel({ animations: nextAnimations, propertyPath }) === undefined;
+ const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null;
+
+ const baseElement = shouldPersistToBase
+ ? withElementBaseValueForProperty({
+ element,
+ propertyPath,
+ value: valueBefore,
+ })
+ : element;
+
+ return { ...baseElement, animations: nextAnimations };
+}
+
+export class RemoveKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) =>
+ removeKeyframeAndPersist({
+ element,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ }),
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
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
new file mode 100644
index 00000000..29d95918
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts
@@ -0,0 +1,75 @@
+import { EditorCore } from "@/core";
+import { retimeElementKeyframe, supportsAnimationProperty } 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";
+
+export class RetimeKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly keyframeId: string;
+ private readonly nextTime: number;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId,
+ nextTime,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+ nextTime: number;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.keyframeId = keyframeId;
+ this.nextTime = nextTime;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration));
+ return {
+ ...element,
+ animations: retimeElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ keyframeId: this.keyframeId,
+ time: boundedTime,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
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
new file mode 100644
index 00000000..370f6865
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -0,0 +1,89 @@
+import { EditorCore } from "@/core";
+import { Command } from "@/lib/commands/base-command";
+import { supportsAnimationProperty, upsertElementKeyframe } from "@/lib/animation";
+import { updateElementInTracks } from "@/lib/timeline";
+import type { TimelineTrack } from "@/types/timeline";
+import type {
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/types/animation";
+
+export class UpsertKeyframeCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly propertyPath: AnimationPropertyPath;
+ private readonly time: number;
+ private readonly value: AnimationValue;
+ private readonly interpolation: AnimationInterpolation | undefined;
+ private readonly keyframeId: string | undefined;
+
+ constructor({
+ trackId,
+ elementId,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ }: {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ }) {
+ super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.propertyPath = propertyPath;
+ this.time = time;
+ this.value = value;
+ this.interpolation = interpolation;
+ this.keyframeId = keyframeId;
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ elementPredicate: (element) =>
+ supportsAnimationProperty({
+ element,
+ propertyPath: this.propertyPath,
+ }),
+ update: (element) => {
+ const boundedTime = Math.max(0, Math.min(this.time, element.duration));
+ return {
+ ...element,
+ animations: upsertElementKeyframe({
+ animations: element.animations,
+ propertyPath: this.propertyPath,
+ time: boundedTime,
+ value: this.value,
+ interpolation: this.interpolation,
+ keyframeId: this.keyframeId,
+ }),
+ };
+ },
+ });
+
+ editor.timeline.updateTracks(updatedTracks);
+ }
+
+ undo(): void {
+ if (!this.savedState) {
+ return;
+ }
+
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index 2fe06694..973d9cb2 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -14,15 +14,31 @@ import {
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly sourceTrackId: string;
+ private readonly targetTrackId: string;
+ private readonly elementId: string;
+ private readonly newStartTime: number;
+ private readonly createTrack: { type: TrackType; index: number } | undefined;
- constructor(
- private sourceTrackId: string,
- private targetTrackId: string,
- private elementId: string,
- private newStartTime: number,
- private createTrack?: { type: TrackType; index: number },
- ) {
+ constructor({
+ sourceTrackId,
+ targetTrackId,
+ elementId,
+ newStartTime,
+ createTrack,
+ }: {
+ sourceTrackId: string;
+ targetTrackId: string;
+ elementId: string;
+ newStartTime: number;
+ createTrack?: { type: TrackType; index: number };
+ }) {
super();
+ this.sourceTrackId = sourceTrackId;
+ this.targetTrackId = targetTrackId;
+ this.elementId = elementId;
+ this.newStartTime = newStartTime;
+ this.createTrack = createTrack;
}
execute(): void {
@@ -30,10 +46,10 @@ export class MoveElementCommand extends Command {
this.savedState = editor.timeline.getTracks();
const sourceTrack = this.savedState.find(
- (t) => t.id === this.sourceTrackId,
+ (track) => track.id === this.sourceTrackId,
);
const element = sourceTrack?.elements.find(
- (el) => el.id === this.elementId,
+ (trackElement) => trackElement.id === this.elementId,
);
if (!sourceTrack || !element) {
@@ -41,7 +57,7 @@ export class MoveElementCommand extends Command {
return;
}
- let targetTrack = this.savedState.find((t) => t.id === this.targetTrackId);
+ let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId);
let tracksToUpdate = this.savedState;
if (!targetTrack && this.createTrack) {
const newTrack = buildEmptyTrack({
@@ -74,6 +90,7 @@ export class MoveElementCommand extends Command {
excludeElementId: this.elementId,
});
+ // keyframe times remain clip-local, so moving only changes element startTime.
const movedElement: TimelineElement = {
...element,
startTime: adjustedStartTime,
@@ -85,8 +102,8 @@ export class MoveElementCommand extends Command {
if (isSameTrack && track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.map((el) =>
- el.id === this.elementId ? movedElement : el,
+ elements: track.elements.map((trackElement) =>
+ trackElement.id === this.elementId ? movedElement : trackElement,
),
};
}
@@ -94,7 +111,9 @@ export class MoveElementCommand extends Command {
if (track.id === this.sourceTrackId) {
return {
...track,
- elements: track.elements.filter((el) => el.id !== this.elementId),
+ elements: track.elements.filter(
+ (trackElement) => trackElement.id !== this.elementId,
+ ),
};
}
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 abe133c9..be89e2ff 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -2,18 +2,29 @@ import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
+import { splitAnimationsAtTime } from "@/lib/animation";
export class SplitElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private rightSideElements: { trackId: string; elementId: string }[] = [];
private previousSelection: { trackId: string; elementId: string }[] = [];
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly splitTime: number;
+ private readonly retainSide: "both" | "left" | "right";
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private splitTime: number,
- private retainSide: "both" | "left" | "right" = "both",
- ) {
+ constructor({
+ elements,
+ splitTime,
+ retainSide = "both",
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ splitTime: number;
+ retainSide?: "both" | "left" | "right";
+ }) {
super();
+ this.elements = elements;
+ this.splitTime = splitTime;
+ this.retainSide = retainSide;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -28,7 +39,7 @@ export class SplitElementsCommand extends Command {
const updatedTracks = this.savedState.map((track) => {
const elementsToSplit = this.elements.filter(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (elementsToSplit.length === 0) {
@@ -39,7 +50,7 @@ export class SplitElementsCommand extends Command {
...track,
elements: track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
- (el) => el.elementId === element.id,
+ (elementEntry) => elementEntry.elementId === element.id,
);
if (!shouldSplit) {
@@ -59,6 +70,11 @@ export class SplitElementsCommand extends Command {
const relativeTime = this.splitTime - element.startTime;
const leftVisibleDuration = relativeTime;
const rightVisibleDuration = element.duration - relativeTime;
+ const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
+ animations: element.animations,
+ splitTime: relativeTime,
+ shouldIncludeSplitBoundary: true,
+ });
if (this.retainSide === "left") {
return [
@@ -67,6 +83,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
];
}
@@ -85,6 +102,7 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
}
@@ -102,6 +120,7 @@ export class SplitElementsCommand extends Command {
duration: leftVisibleDuration,
trimEnd: element.trimEnd + rightVisibleDuration,
name: `${element.name} (left)`,
+ animations: leftAnimations,
},
{
...element,
@@ -110,6 +129,7 @@ export class SplitElementsCommand extends Command {
duration: rightVisibleDuration,
trimStart: element.trimStart + leftVisibleDuration,
name: `${element.name} (right)`,
+ animations: rightAnimations,
},
];
}),
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 6b3a860b..3366548d 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,28 +1,48 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementDurationCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly duration: number;
- constructor(
- private trackId: string,
- private elementId: string,
- private duration: number,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ duration,
+ }: {
+ trackId: string;
+ elementId: string;
+ duration: number;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.duration = duration;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, duration: this.duration } : el,
+ const updatedTracks = this.savedState.map((track) => {
+ if (track.id !== this.trackId) return track;
+ const newElements = track.elements.map((element) =>
+ element.id === this.elementId
+ ? {
+ ...element,
+ duration: this.duration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: this.duration,
+ }),
+ }
+ : element,
);
- return { ...t, elements: newElements } as typeof t;
+ return { ...track, elements: newElements } as typeof track;
});
editor.timeline.updateTracks(updatedTracks);
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 51dcb63d..e25bc0e3 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
@@ -5,12 +5,19 @@ import { enforceMainTrackStart } from "@/lib/timeline/track-utils";
export class UpdateElementStartTimeCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elements: { trackId: string; elementId: string }[];
+ private readonly startTime: number;
- constructor(
- private elements: { trackId: string; elementId: string }[],
- private startTime: number,
- ) {
+ constructor({
+ elements,
+ startTime,
+ }: {
+ elements: { trackId: string; elementId: string }[];
+ startTime: number;
+ }) {
super();
+ this.elements = elements;
+ this.startTime = startTime;
}
execute(): void {
@@ -20,7 +27,7 @@ export class UpdateElementStartTimeCommand extends Command {
const currentTracks = this.savedState;
const updatedTracks = currentTracks.map((track) => {
const hasElementsToUpdate = this.elements.some(
- (el) => el.trackId === track.id,
+ (elementEntry) => elementEntry.trackId === track.id,
);
if (!hasElementsToUpdate) {
@@ -29,7 +36,9 @@ export class UpdateElementStartTimeCommand extends Command {
const newElements = track.elements.map((element) => {
const shouldUpdate = this.elements.some(
- (el) => el.elementId === element.id && el.trackId === track.id,
+ (elementEntry) =>
+ elementEntry.elementId === element.id &&
+ elementEntry.trackId === track.id,
);
if (!shouldUpdate) {
return element;
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 99c63287..6afcb9d0 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,18 +1,35 @@
import { Command } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { clampAnimationsToDuration } from "@/lib/animation";
export class UpdateElementTrimCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly elementId: string;
+ private readonly trimStart: number;
+ private readonly trimEnd: number;
+ private readonly startTime: number | undefined;
+ private readonly duration: number | undefined;
- constructor(
- private elementId: string,
- private trimStart: number,
- private trimEnd: number,
- private startTime?: number,
- private duration?: number,
- ) {
+ constructor({
+ elementId,
+ trimStart,
+ trimEnd,
+ startTime,
+ duration,
+ }: {
+ elementId: string;
+ trimStart: number;
+ trimEnd: number;
+ startTime?: number;
+ duration?: number;
+ }) {
super();
+ this.elementId = elementId;
+ this.trimStart = trimStart;
+ this.trimEnd = trimEnd;
+ this.startTime = startTime;
+ this.duration = duration;
}
execute(): void {
@@ -25,12 +42,17 @@ export class UpdateElementTrimCommand extends Command {
return element;
}
+ const nextDuration = this.duration ?? element.duration;
return {
...element,
trimStart: this.trimStart,
trimEnd: this.trimEnd,
startTime: this.startTime ?? element.startTime,
- duration: this.duration ?? element.duration,
+ duration: nextDuration,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: nextDuration,
+ }),
};
});
return { ...track, elements: newElements } 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 4ee54726..e6b0706f 100644
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/update-element.ts
@@ -1,28 +1,38 @@
import { Command } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/types/timeline";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
import { EditorCore } from "@/core";
+import { updateElementInTracks } from "@/lib/timeline";
export class UpdateElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
+ private readonly trackId: string;
+ private readonly elementId: string;
+ private readonly updates: Partial<TimelineElement>;
- constructor(
- private trackId: string,
- private elementId: string,
- private updates: Partial<Record<string, unknown>>,
- ) {
+ constructor({
+ trackId,
+ elementId,
+ updates,
+ }: {
+ trackId: string;
+ elementId: string;
+ updates: Partial<TimelineElement>;
+ }) {
super();
+ this.trackId = trackId;
+ this.elementId = elementId;
+ this.updates = updates;
}
execute(): void {
const editor = EditorCore.getInstance();
this.savedState = editor.timeline.getTracks();
- const updatedTracks = this.savedState.map((t) => {
- if (t.id !== this.trackId) return t;
- const newElements = t.elements.map((el) =>
- el.id === this.elementId ? { ...el, ...this.updates } : el,
- );
- return { ...t, elements: newElements } as typeof t;
+ const updatedTracks = updateElementInTracks({
+ tracks: this.savedState,
+ trackId: this.trackId,
+ elementId: this.elementId,
+ update: (element) => ({ ...element, ...this.updates }),
});
editor.timeline.updateTracks(updatedTracks);