summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts2
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts4
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx8
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx4
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx10
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx8
-rw-r--r--apps/web/src/core/managers/commands.ts33
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts203
-rw-r--r--apps/web/src/hooks/actions/use-editor-actions.ts2
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts5
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts5
-rw-r--r--apps/web/src/lib/commands/timeline/element/delete-elements.ts57
-rw-r--r--apps/web/src/lib/commands/timeline/element/index.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/element/move-elements.ts14
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/index.ts1
-rw-r--r--apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts115
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts19
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts57
-rw-r--r--apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-duration.ts58
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-start-time.ts70
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element-trim.ts116
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-element.ts48
-rw-r--r--apps/web/src/lib/commands/timeline/element/update-elements.ts71
-rw-r--r--apps/web/src/lib/ripple/apply.ts65
-rw-r--r--apps/web/src/lib/ripple/diff.ts272
-rw-r--r--apps/web/src/lib/ripple/index.ts4
-rw-r--r--apps/web/src/lib/ripple/shift.ts17
-rw-r--r--apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts44
-rw-r--r--apps/web/src/lib/timeline/audio-separation/index.ts8
-rw-r--r--apps/web/src/lib/timeline/index.ts1
-rw-r--r--apps/web/src/lib/timeline/ripple-utils.ts17
-rw-r--r--apps/web/src/lib/timeline/update-pipeline.ts193
34 files changed, 836 insertions, 753 deletions
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
index ddd3d2e4..0c10d443 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts
@@ -144,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
- updates: buildBaseUpdates({ value: nextValue }),
+ patch: buildBaseUpdates({ value: nextValue }),
},
],
});
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
index a342859c..7ee1f221 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts
@@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({
}
editor.timeline.updateElements({
updates: [
- { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ { trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};
@@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({
}
editor.timeline.updateElements({
updates: [
- { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ { trackId, elementId, patch: buildBaseUpdates({ value: vector }) },
],
});
};
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
index a69acebf..f8e5397d 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx
@@ -41,7 +41,7 @@ type BlendingElement = {
animations?: ElementAnimations;
};
-const BLEND_MODE_GROUPS = [
+const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [
[{ value: "normal", label: "Normal" }],
[
{ value: "darken", label: "Darken" },
@@ -99,7 +99,7 @@ export function BlendingTab({
],
});
- const commitBlendMode = (value: string) => {
+ const commitBlendMode = (value: BlendMode) => {
if (editor.timeline.isPreviewActive()) {
editor.timeline.commitPreview();
} else {
@@ -108,7 +108,7 @@ export function BlendingTab({
{
trackId,
elementId: element.id,
- updates: { blendMode: value as BlendMode },
+ patch: { blendMode: value },
},
],
});
@@ -209,7 +209,7 @@ export function BlendingTab({
key={option.value}
value={option.value}
onPointerEnter={() =>
- previewBlendMode({ value: option.value as BlendMode })
+ previewBlendMode({ value: option.value })
}
>
{option.label}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
index 4593050e..c61c9cd7 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx
@@ -123,7 +123,7 @@ function StrokeSection({
{
trackId,
elementId: element.id,
- updates: { params: { ...element.params, strokeWidth: 0 } },
+ patch: { params: { ...element.params, strokeWidth: 0 } },
},
],
});
@@ -133,7 +133,7 @@ function StrokeSection({
{
trackId,
elementId: element.id,
- updates: {
+ patch: {
params: {
...element.params,
strokeWidth: lastStrokeWidth.current,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
index d416dc28..f48a50cb 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx
@@ -165,7 +165,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
{
trackId,
elementId: element.id,
- updates: {
+ patch: {
masks: [
buildDefaultMaskInstance({
maskType,
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
index 11412c04..5fffbcec 100644
--- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
+++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx
@@ -165,7 +165,7 @@ function TypographySection({
{
trackId,
elementId: element.id,
- updates: { fontFamily: value },
+ patch: { fontFamily: value },
},
],
})
@@ -188,7 +188,7 @@ function TypographySection({
{
trackId,
elementId: element.id,
- updates: {
+ patch: {
fontSize: DEFAULTS.text.element.fontSize,
},
},
@@ -291,7 +291,7 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { letterSpacing: DEFAULTS.text.letterSpacing },
+ patch: { letterSpacing: DEFAULTS.text.letterSpacing },
},
],
})
@@ -317,7 +317,7 @@ function SpacingSection({
{
trackId,
elementId: element.id,
- updates: { lineHeight: DEFAULTS.text.lineHeight },
+ patch: { lineHeight: DEFAULTS.text.lineHeight },
},
],
})
@@ -512,7 +512,7 @@ function BackgroundSection({
{
trackId,
elementId: element.id,
- updates: {
+ patch: {
background: {
...element.background,
enabled,
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index 20476a13..12b64853 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -7,6 +7,7 @@ import { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
import { useKeybindingsListener } from "@/hooks/use-keybindings";
import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useTimelineStore } from "@/stores/timeline-store";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
import { loadFontAtlas } from "@/lib/fonts/google-fonts";
import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer";
@@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
function EditorRuntimeBindings() {
const editor = useEditor();
+ const rippleEditingEnabled = useTimelineStore(
+ (state) => state.rippleEditingEnabled,
+ );
+
+ useEffect(() => {
+ editor.command.isRippleEnabled = rippleEditingEnabled;
+ }, [editor, rippleEditingEnabled]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts
index 918fc33e..a3665852 100644
--- a/apps/web/src/core/managers/commands.ts
+++ b/apps/web/src/core/managers/commands.ts
@@ -1,6 +1,7 @@
import type { EditorCore } from "@/core";
import type { Command, CommandResult } from "@/lib/commands";
-import type { ElementRef } from "@/lib/timeline/types";
+import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple";
+import type { TimelineTrack, ElementRef } from "@/lib/timeline/types";
interface CommandHistoryEntry {
command: Command;
@@ -9,6 +10,7 @@ interface CommandHistoryEntry {
}
export class CommandManager {
+ public isRippleEnabled = false;
private history: CommandHistoryEntry[] = [];
private redoStack: CommandHistoryEntry[] = [];
private reactors: Array<() => void> = [];
@@ -16,8 +18,10 @@ export class CommandManager {
constructor(private editor: EditorCore) {}
execute({ command }: { command: Command }): Command {
+ const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
const previousSelection = this.getSelectionSnapshot();
const result = command.execute();
+ this.applyRippleIfEnabled({ beforeTracks });
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
this.history.push({
@@ -67,8 +71,10 @@ export class CommandManager {
return;
}
+ const beforeTracks = this.isRippleEnabled ? this.editor.timeline.getTracks() : null;
const previousSelection = this.getSelectionSnapshot();
const result = entry.command.redo();
+ this.applyRippleIfEnabled({ beforeTracks });
const selectionOverride = this.applySelectionOverride(result);
this.runReactors();
@@ -113,4 +119,29 @@ export class CommandManager {
reactor();
}
}
+
+ private applyRippleIfEnabled({
+ beforeTracks,
+ }: {
+ beforeTracks: TimelineTrack[] | null;
+ }): void {
+ if (!this.isRippleEnabled || !beforeTracks) {
+ return;
+ }
+
+ const afterTracks = this.editor.timeline.getTracks();
+ const adjustments = computeRippleAdjustments({
+ beforeTracks,
+ afterTracks,
+ });
+ if (adjustments.length === 0) {
+ return;
+ }
+
+ const tracksWithRipple = applyRippleAdjustments({
+ tracks: afterTracks,
+ adjustments,
+ });
+ this.editor.timeline.updateTracks(tracksWithRipple);
+ }
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 6f262e57..a6f2462a 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -7,32 +7,31 @@ import type {
ClipboardItem,
RetimeConfig,
} from "@/lib/timeline";
+import { calculateTotalDuration } from "@/lib/timeline";
+import {
+ canElementBeHidden,
+ canElementHaveAudio,
+} from "@/lib/timeline/element-utils";
import type {
AnimationPath,
AnimationInterpolation,
AnimationValue,
} from "@/lib/animation/types";
-import { calculateTotalDuration } from "@/lib/timeline";
import { getLastFrameTime } from "opencut-wasm";
+import { BatchCommand } from "@/lib/commands";
import {
AddTrackCommand,
RemoveTrackCommand,
ToggleTrackMuteCommand,
ToggleTrackVisibilityCommand,
InsertElementCommand,
- UpdateElementTrimCommand,
- UpdateElementDurationCommand,
DeleteElementsCommand,
DuplicateElementsCommand,
- ToggleElementsVisibilityCommand,
- ToggleElementsMutedCommand,
- UpdateElementCommand,
+ UpdateElementsCommand,
SplitElementsCommand,
PasteCommand,
- UpdateElementStartTimeCommand,
MoveElementCommand,
TracksSnapshotCommand,
- UpdateElementRetimeCommand,
UpsertKeyframeCommand,
RemoveKeyframeCommand,
RetimeKeyframeCommand,
@@ -47,7 +46,6 @@ import {
RemoveEffectParamKeyframeCommand,
ToggleSourceAudioSeparationCommand,
} from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
@@ -80,7 +78,6 @@ export class TimelineManager {
startTime,
duration,
pushHistory = true,
- rippleEnabled = false,
}: {
elementId: string;
trimStart: number;
@@ -88,44 +85,33 @@ export class TimelineManager {
startTime?: number;
duration?: number;
pushHistory?: boolean;
- rippleEnabled?: boolean;
}): void {
- const command = new UpdateElementTrimCommand({
- elementId,
+ const trackId = this.findTrackIdForElement({ elementId });
+ if (!trackId) {
+ return;
+ }
+
+ const nextUpdates: Partial<TimelineElement> = {
trimStart,
trimEnd,
- startTime,
- duration,
- rippleEnabled,
- });
- if (pushHistory) {
- this.editor.command.execute({ command });
- } else {
- command.execute();
+ };
+ if (startTime !== undefined) {
+ nextUpdates.startTime = startTime;
+ }
+ if (duration !== undefined) {
+ nextUpdates.duration = duration;
}
- }
- updateElementDuration({
- trackId,
- elementId,
- duration,
- pushHistory = true,
- }: {
- trackId: string;
- elementId: string;
- duration: number;
- pushHistory?: boolean;
- }): void {
- const command = new UpdateElementDurationCommand({
- trackId,
- elementId,
- duration,
+ this.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ patch: nextUpdates,
+ },
+ ],
+ pushHistory,
});
- if (pushHistory) {
- this.editor.command.execute({ command });
- } else {
- command.execute();
- }
}
updateElementRetime({
@@ -139,30 +125,18 @@ export class TimelineManager {
retime?: RetimeConfig;
pushHistory?: boolean;
}): void {
- const command = new UpdateElementRetimeCommand({
- trackId,
- elementId,
- retime,
- });
- if (pushHistory) {
- this.editor.command.execute({ command });
- } else {
- command.execute();
- }
- }
-
- updateElementStartTime({
- elements,
- startTime,
- }: {
- elements: { trackId: string; elementId: string }[];
- startTime: number;
- }): void {
- const command = new UpdateElementStartTimeCommand({
- elements,
- startTime,
+ this.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ patch: {
+ retime,
+ },
+ },
+ ],
+ pushHistory,
});
- this.editor.command.execute({ command });
}
moveElement({
@@ -171,14 +145,12 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
- rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
- rippleEnabled?: boolean;
}): void {
const command = new MoveElementCommand({
sourceTrackId,
@@ -186,7 +158,6 @@ export class TimelineManager {
elementId,
newStartTime,
createTrack,
- rippleEnabled,
});
this.editor.command.execute({ command });
}
@@ -205,18 +176,15 @@ export class TimelineManager {
elements,
splitTime,
retainSide = "both",
- rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
- rippleEnabled?: boolean;
}): { trackId: string; elementId: string }[] {
const command = new SplitElementsCommand({
elements,
splitTime,
retainSide,
- rippleEnabled,
});
this.editor.command.execute({ command });
return command.getRightSideElements();
@@ -273,12 +241,10 @@ export class TimelineManager {
deleteElements({
elements,
- rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
- rippleEnabled?: boolean;
}): void {
- const command = new DeleteElementsCommand({ elements, rippleEnabled });
+ const command = new DeleteElementsCommand({ elements });
this.editor.command.execute({ command });
}
@@ -303,20 +269,17 @@ export class TimelineManager {
updates: Array<{
trackId: string;
elementId: string;
- updates: Partial<TimelineElement>;
+ patch: Partial<TimelineElement>;
}>;
pushHistory?: boolean;
}): void {
- const commands = updates.map(
- ({ trackId, elementId, updates: elementUpdates }) =>
- new UpdateElementCommand({
- trackId,
- elementId,
- updates: elementUpdates,
- }),
- );
- const command =
- commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ if (updates.length === 0) {
+ return;
+ }
+
+ const command = new UpdateElementsCommand({
+ updates,
+ });
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@@ -679,8 +642,27 @@ export class TimelineManager {
}: {
elements: { trackId: string; elementId: string }[];
}): void {
- const command = new ToggleElementsVisibilityCommand(elements);
- this.editor.command.execute({ command });
+ const shouldHide = elements.some(({ trackId, elementId }) => {
+ const element = this.getElementByRef({ trackId, elementId });
+ return element && canElementBeHidden(element) && !element.hidden;
+ });
+
+ const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
+ const element = this.getElementByRef({ trackId, elementId });
+ if (!element || !canElementBeHidden(element)) {
+ return [];
+ }
+
+ return [
+ {
+ trackId,
+ elementId,
+ patch: { hidden: shouldHide },
+ },
+ ];
+ });
+
+ this.updateElements({ updates: nextUpdates });
}
toggleElementsMuted({
@@ -688,8 +670,27 @@ export class TimelineManager {
}: {
elements: { trackId: string; elementId: string }[];
}): void {
- const command = new ToggleElementsMutedCommand(elements);
- this.editor.command.execute({ command });
+ const shouldMute = elements.some(({ trackId, elementId }) => {
+ const element = this.getElementByRef({ trackId, elementId });
+ return element && canElementHaveAudio(element) && !element.muted;
+ });
+
+ const nextUpdates = elements.flatMap(({ trackId, elementId }) => {
+ const element = this.getElementByRef({ trackId, elementId });
+ if (!element || !canElementHaveAudio(element)) {
+ return [];
+ }
+
+ return [
+ {
+ trackId,
+ elementId,
+ patch: { muted: shouldMute },
+ },
+ ];
+ });
+
+ this.updateElements({ updates: nextUpdates });
}
getTracks(): TimelineTrack[] {
@@ -712,6 +713,30 @@ export class TimelineManager {
});
}
+ private getElementByRef({
+ trackId,
+ elementId,
+ }: {
+ trackId: string;
+ elementId: string;
+ }): TimelineElement | undefined {
+ return this.getTrackById({ trackId })?.elements.find(
+ (element) => element.id === elementId,
+ );
+ }
+
+ private findTrackIdForElement({
+ elementId,
+ }: {
+ elementId: string;
+ }): string | null {
+ return (
+ this.getTracks().find((track) =>
+ track.elements.some((element) => element.id === elementId),
+ )?.id ?? null
+ );
+ }
+
updateTracks(newTracks: TimelineTrack[]): void {
this.previewOverlay.clear();
this.previewTracks = null;
diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts
index 87aa6b48..be2995dd 100644
--- a/apps/web/src/hooks/actions/use-editor-actions.ts
+++ b/apps/web/src/hooks/actions/use-editor-actions.ts
@@ -212,7 +212,6 @@ export function useEditorActions() {
elements: elementsToSplit,
splitTime: currentTime,
retainSide: "right",
- rippleEnabled: rippleEditingEnabled,
});
if (rippleEditingEnabled && rightSideElements.length > 0) {
@@ -263,7 +262,6 @@ export function useEditorActions() {
}
editor.timeline.deleteElements({
elements: selectedElements,
- rippleEnabled: rippleEditingEnabled,
});
},
undefined,
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index 193379ed..fcc821fe 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -8,7 +8,6 @@ import {
} from "react";
import { useEditor } from "@/hooks/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import { useTimelineStore } from "@/stores/timeline-store";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction";
@@ -161,7 +160,6 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
- const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled);
const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const {
@@ -467,7 +465,6 @@ export function useElementInteraction({
elementId: dragState.elementId,
newStartTime: snappedTime,
createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex },
- rippleEnabled: rippleEditingEnabled,
});
selectElement({ trackId: newTrackId, elementId: dragState.elementId });
} else {
@@ -478,7 +475,6 @@ export function useElementInteraction({
targetTrackId: targetTrack.id,
elementId: dragState.elementId,
newStartTime: snappedTime,
- rippleEnabled: rippleEditingEnabled,
});
if (targetTrack.id !== dragState.trackId) {
selectElement({
@@ -509,7 +505,6 @@ export function useElementInteraction({
tracksContainerRef,
tracksScrollRef,
headerRef,
- rippleEditingEnabled,
selectElement,
]);
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 890820d7..3dd98bea 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -45,9 +45,6 @@ export function useTimelineElementResize({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
- const rippleEditingEnabled = useTimelineStore(
- (state) => state.rippleEditingEnabled,
- );
const [resizing, setResizing] = useState<ResizeState | null>(null);
const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart);
@@ -458,7 +455,6 @@ export function useTimelineElementResize({
trimEnd: finalTrimEnd,
startTime: startTimeChanged ? finalStartTime : undefined,
duration: durationChanged ? finalDuration : undefined,
- rippleEnabled: rippleEditingEnabled,
});
}
@@ -471,7 +467,6 @@ export function useTimelineElementResize({
element.id,
onResizeStateChange,
onSnapPointChange,
- rippleEditingEnabled,
]);
useEffect(() => {
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 1e1ad661..c8cc4494 100644
--- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts
@@ -1,23 +1,18 @@
import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { EditorCore } from "@/core";
-import { rippleShiftElements } from "@/lib/timeline";
export class DeleteElementsCommand extends Command {
private savedState: TimelineTrack[] | null = null;
private readonly elements: { trackId: string; elementId: string }[];
- private readonly rippleEnabled: boolean;
constructor({
elements,
- rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
- rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
- this.rippleEnabled = rippleEnabled;
}
execute(): CommandResult | undefined {
@@ -25,45 +20,25 @@ export class DeleteElementsCommand extends Command {
this.savedState = editor.timeline.getTracks();
const updatedTracks = this.savedState.map((track) => {
- const elementsToDeleteOnTrack = this.elements.filter(
- (target) => target.trackId === track.id,
- );
- const hasElementsToDelete = elementsToDeleteOnTrack.length > 0;
+ const elementsToDeleteOnTrack = this.elements.filter(
+ (target) => target.trackId === track.id,
+ );
- if (!hasElementsToDelete) {
- return track;
- }
+ if (elementsToDeleteOnTrack.length === 0) {
+ return track;
+ }
- const deletedElementInfos = elementsToDeleteOnTrack
- .map((target) =>
- track.elements.find((element) => element.id === target.elementId),
- )
- .filter((element): element is NonNullable<typeof element> => element !== undefined)
- .map((element) => ({ startTime: element.startTime, duration: element.duration }));
+ const elements = track.elements.filter(
+ (element) =>
+ !this.elements.some(
+ (target) =>
+ target.trackId === track.id &&
+ target.elementId === element.id,
+ ),
+ );
- let elements = track.elements.filter(
- (element) =>
- !this.elements.some(
- (target) =>
- target.trackId === track.id && target.elementId === element.id,
- ),
- );
-
- if (this.rippleEnabled && deletedElementInfos.length > 0) {
- const sortedByStartDesc = [...deletedElementInfos].sort(
- (a, b) => b.startTime - a.startTime,
- );
- for (const { startTime, duration } of sortedByStartDesc) {
- elements = rippleShiftElements({
- elements,
- afterTime: startTime,
- shiftAmount: duration,
- });
- }
- }
-
- return { ...track, elements } as typeof track;
- });
+ return { ...track, elements } as typeof track;
+ });
editor.timeline.updateTracks(updatedTracks);
diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts
index 60e12ddd..eabdc385 100644
--- a/apps/web/src/lib/commands/timeline/element/index.ts
+++ b/apps/web/src/lib/commands/timeline/element/index.ts
@@ -1,17 +1,11 @@
export { InsertElementCommand } from "./insert-element";
export { DeleteElementsCommand } from "./delete-elements";
export { DuplicateElementsCommand } from "./duplicate-elements";
-export { UpdateElementTrimCommand } from "./update-element-trim";
-export { UpdateElementDurationCommand } from "./update-element-duration";
-export { UpdateElementStartTimeCommand } from "./update-element-start-time";
export { SplitElementsCommand } from "./split-elements";
-export { UpdateElementCommand } from "./update-element";
-export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility";
-export { ToggleElementsMutedCommand } from "./toggle-elements-muted";
+export { UpdateElementsCommand } from "./update-elements";
export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation";
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/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts
index e944ca81..cf3afa6d 100644
--- a/apps/web/src/lib/commands/timeline/element/move-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts
@@ -10,7 +10,6 @@ import {
validateElementTrackCompatibility,
enforceMainTrackStart,
} from "@/lib/timeline/placement";
-import { rippleShiftElements } from "@/lib/timeline/ripple-utils";
export class MoveElementCommand extends Command {
private savedState: TimelineTrack[] | null = null;
@@ -19,7 +18,6 @@ export class MoveElementCommand extends Command {
private readonly elementId: string;
private readonly newStartTime: number;
private readonly createTrack: { type: TrackType; index: number } | undefined;
- private readonly rippleEnabled: boolean;
constructor({
sourceTrackId,
@@ -27,14 +25,12 @@ export class MoveElementCommand extends Command {
elementId,
newStartTime,
createTrack,
- rippleEnabled = false,
}: {
sourceTrackId: string;
targetTrackId: string;
elementId: string;
newStartTime: number;
createTrack?: { type: TrackType; index: number };
- rippleEnabled?: boolean;
}) {
super();
this.sourceTrackId = sourceTrackId;
@@ -42,7 +38,6 @@ export class MoveElementCommand extends Command {
this.elementId = elementId;
this.newStartTime = newStartTime;
this.createTrack = createTrack;
- this.rippleEnabled = rippleEnabled;
}
execute(): CommandResult | undefined {
@@ -113,14 +108,7 @@ export class MoveElementCommand extends Command {
const remainingElements = track.elements.filter(
(trackElement) => trackElement.id !== this.elementId,
);
- const shiftedElements = this.rippleEnabled
- ? rippleShiftElements({
- elements: remainingElements,
- afterTime: element.startTime,
- shiftAmount: element.duration,
- })
- : remainingElements;
- return { ...track, elements: shiftedElements } as typeof track;
+ return { ...track, elements: remainingElements } as typeof track;
}
if (track.id === this.targetTrackId) {
diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts
deleted file mode 100644
index e9c24c44..00000000
--- a/apps/web/src/lib/commands/timeline/element/retime/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 474396a1..00000000
--- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { EditorCore } from "@/core";
-import { clampRetimeRate } from "@/lib/retime/rate";
-import { clampAnimationsToDuration } from "@/lib/animation";
-import { Command, type CommandResult } 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(): CommandResult | undefined {
- 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);
- return undefined;
- }
-
- 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 250a1ce6..79722b4c 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -2,7 +2,7 @@ import { Command, type CommandResult } from "@/lib/commands/base-command";
import type { TimelineTrack } from "@/lib/timeline";
import { generateUUID } from "@/utils/id";
import { EditorCore } from "@/core";
-import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
+import { isRetimableElement } from "@/lib/timeline";
import { splitAnimationsAtTime } from "@/lib/animation";
import { getSourceSpanAtClipTime } from "@/lib/retime";
@@ -12,24 +12,20 @@ export class SplitElementsCommand extends Command {
private readonly elements: { trackId: string; elementId: string }[];
private readonly splitTime: number;
private readonly retainSide: "both" | "left" | "right";
- private readonly rippleEnabled: boolean;
constructor({
elements,
splitTime,
retainSide = "both",
- rippleEnabled = false,
}: {
elements: { trackId: string; elementId: string }[];
splitTime: number;
retainSide?: "both" | "left" | "right";
- rippleEnabled?: boolean;
}) {
super();
this.elements = elements;
this.splitTime = splitTime;
this.retainSide = retainSide;
- this.rippleEnabled = rippleEnabled;
}
getRightSideElements(): { trackId: string; elementId: string }[] {
@@ -50,8 +46,6 @@ export class SplitElementsCommand extends Command {
return track;
}
- let leftVisibleDurationForRipple: number | null = null;
-
let elements = track.elements.flatMap((element) => {
const shouldSplit = elementsToSplit.some(
(target) => target.elementId === element.id,
@@ -106,9 +100,6 @@ export class SplitElementsCommand extends Command {
}
if (this.retainSide === "right") {
- if (this.rippleEnabled && elementsToSplit.length === 1) {
- leftVisibleDurationForRipple = leftVisibleDuration;
- }
const newId = generateUUID();
this.rightSideElements.push({
trackId: track.id,
@@ -157,14 +148,6 @@ export class SplitElementsCommand extends Command {
];
});
- if (this.rippleEnabled && leftVisibleDurationForRipple !== null) {
- elements = rippleShiftElements({
- elements,
- afterTime: this.splitTime,
- shiftAmount: leftVisibleDurationForRipple,
- });
- }
-
return { ...track, elements } as typeof track;
});
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
deleted file mode 100644
index 75b77562..00000000
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { canElementHaveAudio } from "@/lib/timeline/element-utils";
-import { EditorCore } from "@/core";
-
-export class ToggleElementsMutedCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
-
- constructor(private elements: { trackId: string; elementId: string }[]) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const mutableElements = this.elements.filter(({ trackId, elementId }) => {
- const track = this.savedState?.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
- return element && canElementHaveAudio(element);
- });
-
- if (mutableElements.length === 0) {
- return;
- }
-
- const shouldMute = mutableElements.some(({ trackId, elementId }) => {
- const track = this.savedState?.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
- return element && canElementHaveAudio(element) && !element.muted;
- });
-
- const updatedTracks = this.savedState.map((track) => {
- const newElements = track.elements.map((element) => {
- const shouldUpdate = mutableElements.some(
- ({ trackId, elementId }) =>
- track.id === trackId && element.id === elementId,
- );
- return shouldUpdate &&
- canElementHaveAudio(element) &&
- element.muted !== shouldMute
- ? { ...element, muted: shouldMute }
- : element;
- });
- return { ...track, elements: newElements } as typeof track;
- });
-
- 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/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
deleted file mode 100644
index 271b0a00..00000000
--- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { canElementBeHidden } from "@/lib/timeline/element-utils";
-import { EditorCore } from "@/core";
-
-export class ToggleElementsVisibilityCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
-
- constructor(private elements: { trackId: string; elementId: string }[]) {
- super();
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const shouldHide = this.elements.some(({ trackId, elementId }) => {
- const track = this.savedState?.find((t) => t.id === trackId);
- const element = track?.elements.find((e) => e.id === elementId);
- return element && canElementBeHidden(element) && !element.hidden;
- });
-
- const updatedTracks = this.savedState.map((track) => {
- const newElements = track.elements.map((element) => {
- const shouldUpdate = this.elements.some(
- ({ trackId, elementId }) =>
- track.id === trackId && element.id === elementId,
- );
- return shouldUpdate &&
- canElementBeHidden(element) &&
- element.hidden !== shouldHide
- ? { ...element, hidden: shouldHide }
- : element;
- });
- return { ...track, elements: newElements } as typeof track;
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
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
deleted file mode 100644
index 7ad62000..00000000
--- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/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({
- trackId,
- elementId,
- duration,
- }: {
- trackId: string;
- elementId: string;
- duration: number;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.duration = duration;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- 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 { ...track, elements: newElements } as typeof track;
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
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
deleted file mode 100644
index 6232d539..00000000
--- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { enforceMainTrackStart } from "@/lib/timeline/placement";
-
-export class UpdateElementStartTimeCommand extends Command {
- private savedState: TimelineTrack[] | null = null;
- private readonly elements: { trackId: string; elementId: string }[];
- private readonly startTime: number;
-
- constructor({
- elements,
- startTime,
- }: {
- elements: { trackId: string; elementId: string }[];
- startTime: number;
- }) {
- super();
- this.elements = elements;
- this.startTime = startTime;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const currentTracks = this.savedState;
- const updatedTracks = currentTracks.map((track) => {
- const hasElementsToUpdate = this.elements.some(
- (elementEntry) => elementEntry.trackId === track.id,
- );
-
- if (!hasElementsToUpdate) {
- return track;
- }
-
- const newElements = track.elements.map((element) => {
- const shouldUpdate = this.elements.some(
- (elementEntry) =>
- elementEntry.elementId === element.id &&
- elementEntry.trackId === track.id,
- );
- if (!shouldUpdate) {
- return element;
- }
-
- const baseStartTime = Math.max(0, this.startTime);
- const adjustedStartTime = enforceMainTrackStart({
- tracks: currentTracks,
- targetTrackId: track.id,
- requestedStartTime: baseStartTime,
- excludeElementId: element.id,
- });
-
- return { ...element, startTime: adjustedStartTime };
- });
- return { ...track, elements: newElements } as typeof track;
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
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
deleted file mode 100644
index e3cb596b..00000000
--- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineTrack } from "@/lib/timeline";
-import { EditorCore } from "@/core";
-import { clampAnimationsToDuration } from "@/lib/animation";
-import { isRetimableElement, rippleShiftElements } from "@/lib/timeline";
-import { enforceMainTrackStart } from "@/lib/timeline/placement";
-
-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;
- private readonly rippleEnabled: boolean;
-
- constructor({
- elementId,
- trimStart,
- trimEnd,
- startTime,
- duration,
- rippleEnabled = false,
- }: {
- elementId: string;
- trimStart: number;
- trimEnd: number;
- startTime?: number;
- duration?: number;
- rippleEnabled?: boolean;
- }) {
- super();
- this.elementId = elementId;
- this.trimStart = trimStart;
- this.trimEnd = trimEnd;
- this.startTime = startTime;
- this.duration = duration;
- this.rippleEnabled = rippleEnabled;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const updatedTracks = this.savedState.map((track) => {
- const targetElement = track.elements.find(
- (element) => element.id === this.elementId,
- );
- if (!targetElement) return track;
-
- const nextDuration = this.duration ?? targetElement.duration;
- 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;
- const shiftAmount = oldEndTime - newEndTime;
-
- const updatedElement = {
- ...targetElement,
- trimStart: this.trimStart,
- trimEnd: this.trimEnd,
- startTime: nextStartTime,
- duration: nextDuration,
- ...(isRetimableElement(targetElement)
- ? { retime: targetElement.retime }
- : {}),
- animations: clampAnimationsToDuration({
- animations: targetElement.animations,
- duration: nextDuration,
- }),
- };
-
- if (this.rippleEnabled && Math.abs(shiftAmount) > 0) {
- const shiftedOthers = rippleShiftElements({
- elements: track.elements.filter(
- (element) => element.id !== this.elementId,
- ),
- afterTime: oldEndTime,
- shiftAmount,
- });
- return {
- ...track,
- elements: track.elements.map((element) =>
- element.id === this.elementId
- ? updatedElement
- : (shiftedOthers.find((shifted) => shifted.id === element.id) ??
- element),
- ),
- } as typeof track;
- }
-
- return {
- ...track,
- elements: track.elements.map((element) =>
- element.id === this.elementId ? updatedElement : element,
- ),
- } as typeof track;
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts
deleted file mode 100644
index 45b2603f..00000000
--- a/apps/web/src/lib/commands/timeline/element/update-element.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Command, type CommandResult } from "@/lib/commands/base-command";
-import type { TimelineElement, TimelineTrack } from "@/lib/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({
- trackId,
- elementId,
- updates,
- }: {
- trackId: string;
- elementId: string;
- updates: Partial<TimelineElement>;
- }) {
- super();
- this.trackId = trackId;
- this.elementId = elementId;
- this.updates = updates;
- }
-
- execute(): CommandResult | undefined {
- const editor = EditorCore.getInstance();
- this.savedState = editor.timeline.getTracks();
-
- const updatedTracks = updateElementInTracks({
- tracks: this.savedState,
- trackId: this.trackId,
- elementId: this.elementId,
- update: (element) => ({ ...element, ...this.updates }) as TimelineElement,
- });
-
- editor.timeline.updateTracks(updatedTracks);
- return undefined;
- }
-
- undo(): void {
- if (this.savedState) {
- const editor = EditorCore.getInstance();
- editor.timeline.updateTracks(this.savedState);
- }
- }
-}
diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts
new file mode 100644
index 00000000..abeec842
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/element/update-elements.ts
@@ -0,0 +1,71 @@
+import { EditorCore } from "@/core";
+import { Command, type CommandResult } from "@/lib/commands/base-command";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline";
+import { updateElementInTracks } from "@/lib/timeline";
+import { applyElementUpdate } from "@/lib/timeline/update-pipeline";
+
+export class UpdateElementsCommand extends Command {
+ private savedState: TimelineTrack[] | null = null;
+ private readonly updates: Array<{
+ trackId: string;
+ elementId: string;
+ patch: Partial<TimelineElement>;
+ }>;
+
+ constructor({
+ updates,
+ }: {
+ updates: Array<{
+ trackId: string;
+ elementId: string;
+ patch: Partial<TimelineElement>;
+ }>;
+ }) {
+ super();
+ this.updates = updates;
+ }
+
+ execute(): CommandResult | undefined {
+ const editor = EditorCore.getInstance();
+ this.savedState = editor.timeline.getTracks();
+ let updatedTracks = this.savedState;
+
+ for (const updateEntry of this.updates) {
+ const currentTrack = updatedTracks.find(
+ (track) => track.id === updateEntry.trackId,
+ );
+ const currentElement = currentTrack?.elements.find(
+ (element) => element.id === updateEntry.elementId,
+ );
+ if (!currentTrack || !currentElement) {
+ continue;
+ }
+
+ const nextElement = applyElementUpdate({
+ element: currentElement,
+ patch: updateEntry.patch,
+ context: {
+ tracks: updatedTracks,
+ trackId: updateEntry.trackId,
+ },
+ });
+
+ updatedTracks = updateElementInTracks({
+ tracks: updatedTracks,
+ trackId: updateEntry.trackId,
+ elementId: updateEntry.elementId,
+ update: () => nextElement,
+ });
+ }
+
+ editor.timeline.updateTracks(updatedTracks);
+ return undefined;
+ }
+
+ undo(): void {
+ if (this.savedState) {
+ const editor = EditorCore.getInstance();
+ editor.timeline.updateTracks(this.savedState);
+ }
+ }
+}
diff --git a/apps/web/src/lib/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts
new file mode 100644
index 00000000..098a0247
--- /dev/null
+++ b/apps/web/src/lib/ripple/apply.ts
@@ -0,0 +1,65 @@
+import type { TimelineTrack } from "@/lib/timeline/types";
+import { rippleShiftElements } from "./shift";
+
+export interface RippleAdjustment {
+ trackId: string;
+ afterTime: number;
+ shiftAmount: number;
+}
+
+export function applyRippleAdjustments({
+ tracks,
+ adjustments,
+}: {
+ tracks: TimelineTrack[];
+ adjustments: RippleAdjustment[];
+}): TimelineTrack[] {
+ if (adjustments.length === 0) {
+ return tracks;
+ }
+
+ const adjustmentsByTrack = new Map<string, RippleAdjustment[]>();
+ for (const adjustment of adjustments) {
+ const trackAdjustments = adjustmentsByTrack.get(adjustment.trackId) ?? [];
+ trackAdjustments.push(adjustment);
+ adjustmentsByTrack.set(adjustment.trackId, trackAdjustments);
+ }
+
+ return tracks.map((track) =>
+ applyTrackRippleAdjustments({
+ track,
+ adjustments: adjustmentsByTrack.get(track.id) ?? [],
+ }),
+ );
+}
+
+function applyTrackRippleAdjustments<
+ TElement extends TimelineTrack["elements"][number],
+ TTrack extends TimelineTrack & { elements: TElement[] },
+>({
+ track,
+ adjustments,
+}: {
+ track: TTrack;
+ adjustments: RippleAdjustment[];
+}): TTrack {
+ if (adjustments.length === 0) {
+ return track;
+ }
+
+ const sortedAdjustments = [...adjustments].sort(
+ (firstAdjustment, secondAdjustment) =>
+ secondAdjustment.afterTime - firstAdjustment.afterTime,
+ );
+
+ let elements: TElement[] = track.elements;
+ for (const adjustment of sortedAdjustments) {
+ elements = rippleShiftElements({
+ elements,
+ afterTime: adjustment.afterTime,
+ shiftAmount: adjustment.shiftAmount,
+ });
+ }
+
+ return { ...track, elements };
+}
diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts
new file mode 100644
index 00000000..8a9730e1
--- /dev/null
+++ b/apps/web/src/lib/ripple/diff.ts
@@ -0,0 +1,272 @@
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+import type { TimelineElement, TimelineTrack } from "@/lib/timeline/types";
+import type { RippleAdjustment } from "./apply";
+
+interface Interval {
+ startTime: number;
+ endTime: number;
+}
+
+interface ElementSpan extends Interval {
+ id: string;
+}
+
+export function computeRippleAdjustments({
+ beforeTracks,
+ afterTracks,
+}: {
+ beforeTracks: TimelineTrack[];
+ afterTracks: TimelineTrack[];
+}): RippleAdjustment[] {
+ const afterTracksById = new Map(afterTracks.map((track) => [track.id, track]));
+ const allAfterElementIds = new Set(
+ afterTracks.flatMap((track) => track.elements.map((element) => element.id)),
+ );
+
+ return beforeTracks.flatMap((beforeTrack): RippleAdjustment[] =>
+ computeTrackRippleAdjustments({
+ trackId: beforeTrack.id,
+ beforeElements: beforeTrack.elements,
+ afterElements: afterTracksById.get(beforeTrack.id)?.elements ?? [],
+ allAfterElementIds,
+ }),
+ );
+}
+
+function computeTrackRippleAdjustments({
+ trackId,
+ beforeElements,
+ afterElements,
+ allAfterElementIds,
+}: {
+ trackId: string;
+ beforeElements: TimelineElement[];
+ afterElements: TimelineElement[];
+ allAfterElementIds: Set<string>;
+}): RippleAdjustment[] {
+ const beforeElementsById = buildElementSpanMap({ elements: beforeElements });
+ const afterElementsById = buildElementSpanMap({ elements: afterElements });
+ const { vacatedIntervals, joinedIntervals } = collectTrackIntervals({
+ beforeElementsById,
+ afterElementsById,
+ allAfterElementIds,
+ });
+ const freedIntervals = subtractIntervalSets({
+ sourceIntervals: vacatedIntervals,
+ overlappingIntervals: joinedIntervals,
+ });
+
+ return buildAdjustments({ trackId, intervals: freedIntervals });
+}
+
+function buildElementSpanMap({
+ elements,
+}: {
+ elements: TimelineElement[];
+}): Map<string, ElementSpan> {
+ return new Map(
+ elements.map((element) => [
+ element.id,
+ {
+ id: element.id,
+ startTime: element.startTime,
+ endTime: element.startTime + element.duration,
+ },
+ ]),
+ );
+}
+
+function collectTrackIntervals({
+ beforeElementsById,
+ afterElementsById,
+ allAfterElementIds,
+}: {
+ beforeElementsById: Map<string, ElementSpan>;
+ afterElementsById: Map<string, ElementSpan>;
+ allAfterElementIds: Set<string>;
+}): {
+ vacatedIntervals: Interval[];
+ joinedIntervals: Interval[];
+} {
+ const vacatedIntervals: Interval[] = [];
+ const joinedIntervals: Interval[] = [];
+
+ for (const beforeElement of beforeElementsById.values()) {
+ const afterElement = afterElementsById.get(beforeElement.id);
+ if (!afterElement) {
+ const wasMovedToAnotherTrack = allAfterElementIds.has(beforeElement.id);
+ if (!wasMovedToAnotherTrack) {
+ pushInterval({
+ intervals: vacatedIntervals,
+ startTime: beforeElement.startTime,
+ endTime: beforeElement.endTime,
+ });
+ }
+ continue;
+ }
+
+ if (beforeElement.endTime > afterElement.endTime + TIME_EPSILON_SECONDS) {
+ pushInterval({
+ intervals: vacatedIntervals,
+ startTime: afterElement.endTime,
+ endTime: beforeElement.endTime,
+ });
+ }
+ }
+
+ for (const afterElement of afterElementsById.values()) {
+ if (beforeElementsById.has(afterElement.id)) {
+ continue;
+ }
+
+ pushInterval({
+ intervals: joinedIntervals,
+ startTime: afterElement.startTime,
+ endTime: afterElement.endTime,
+ });
+ }
+
+ return {
+ vacatedIntervals: normalizeIntervals({ intervals: vacatedIntervals }),
+ joinedIntervals: normalizeIntervals({ intervals: joinedIntervals }),
+ };
+}
+
+function buildAdjustments({
+ trackId,
+ intervals,
+}: {
+ trackId: string;
+ intervals: Interval[];
+}): RippleAdjustment[] {
+ return intervals.flatMap((interval): RippleAdjustment[] => {
+ const shiftAmount = interval.endTime - interval.startTime;
+ if (shiftAmount <= TIME_EPSILON_SECONDS) {
+ return [];
+ }
+
+ return [
+ {
+ trackId,
+ afterTime: interval.endTime,
+ shiftAmount,
+ },
+ ];
+ });
+}
+
+function subtractIntervalSets({
+ sourceIntervals,
+ overlappingIntervals,
+}: {
+ sourceIntervals: Interval[];
+ overlappingIntervals: Interval[];
+}): Interval[] {
+ const normalizedSourceIntervals = normalizeIntervals({
+ intervals: sourceIntervals,
+ });
+ const normalizedOverlappingIntervals = normalizeIntervals({
+ intervals: overlappingIntervals,
+ });
+
+ return normalizedSourceIntervals.flatMap((sourceInterval) =>
+ subtractSingleInterval({
+ sourceInterval,
+ overlappingIntervals: normalizedOverlappingIntervals,
+ }),
+ );
+}
+
+function normalizeIntervals({
+ intervals,
+}: {
+ intervals: Interval[];
+}): Interval[] {
+ const validIntervals: Interval[] = [];
+ for (const interval of intervals) {
+ pushInterval({
+ intervals: validIntervals,
+ startTime: interval.startTime,
+ endTime: interval.endTime,
+ });
+ }
+
+ const sortedIntervals = validIntervals.sort(
+ (leftInterval, rightInterval) =>
+ leftInterval.startTime - rightInterval.startTime,
+ );
+
+ if (sortedIntervals.length === 0) {
+ return [];
+ }
+
+ const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }];
+ for (const interval of sortedIntervals.slice(1)) {
+ const previousInterval = mergedIntervals[mergedIntervals.length - 1];
+ if (interval.startTime <= previousInterval.endTime + TIME_EPSILON_SECONDS) {
+ previousInterval.endTime = Math.max(
+ previousInterval.endTime,
+ interval.endTime,
+ );
+ continue;
+ }
+
+ mergedIntervals.push({ ...interval });
+ }
+
+ return mergedIntervals;
+}
+
+function subtractSingleInterval({
+ sourceInterval,
+ overlappingIntervals,
+}: {
+ sourceInterval: Interval;
+ overlappingIntervals: Interval[];
+}): Interval[] {
+ let remainingIntervals: Interval[] = [{ ...sourceInterval }];
+
+ for (const overlappingInterval of overlappingIntervals) {
+ remainingIntervals = remainingIntervals.flatMap((remainingInterval) => {
+ if (
+ overlappingInterval.endTime <=
+ remainingInterval.startTime + TIME_EPSILON_SECONDS ||
+ overlappingInterval.startTime >=
+ remainingInterval.endTime - TIME_EPSILON_SECONDS
+ ) {
+ return [remainingInterval];
+ }
+
+ const nextIntervals: Interval[] = [];
+ pushInterval({
+ intervals: nextIntervals,
+ startTime: remainingInterval.startTime,
+ endTime: overlappingInterval.startTime,
+ });
+ pushInterval({
+ intervals: nextIntervals,
+ startTime: overlappingInterval.endTime,
+ endTime: remainingInterval.endTime,
+ });
+ return nextIntervals;
+ });
+
+ if (remainingIntervals.length === 0) {
+ return [];
+ }
+ }
+
+ return remainingIntervals;
+}
+
+function pushInterval({
+ intervals,
+ startTime,
+ endTime,
+}: { intervals: Interval[]; startTime: number; endTime: number }): void {
+ if (endTime - startTime <= TIME_EPSILON_SECONDS) {
+ return;
+ }
+
+ intervals.push({ startTime, endTime });
+}
diff --git a/apps/web/src/lib/ripple/index.ts b/apps/web/src/lib/ripple/index.ts
new file mode 100644
index 00000000..5a14de64
--- /dev/null
+++ b/apps/web/src/lib/ripple/index.ts
@@ -0,0 +1,4 @@
+export type { RippleAdjustment } from "./apply";
+export { applyRippleAdjustments } from "./apply";
+export { computeRippleAdjustments } from "./diff";
+export { rippleShiftElements } from "./shift";
diff --git a/apps/web/src/lib/ripple/shift.ts b/apps/web/src/lib/ripple/shift.ts
new file mode 100644
index 00000000..03ab4a67
--- /dev/null
+++ b/apps/web/src/lib/ripple/shift.ts
@@ -0,0 +1,17 @@
+import type { TimelineElement } from "@/lib/timeline/types";
+
+export function rippleShiftElements<TElement extends TimelineElement>({
+ elements,
+ afterTime,
+ shiftAmount,
+}: {
+ elements: TElement[];
+ afterTime: number;
+ shiftAmount: number;
+}): TElement[] {
+ return elements.map((element) =>
+ element.startTime >= afterTime
+ ? ({ ...element, startTime: element.startTime - shiftAmount } as TElement)
+ : element,
+ );
+}
diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
index 173b6402..1946f1e0 100644
--- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
+++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import type { AudioElement, VideoElement } from "@/lib/timeline";
+import type { UploadAudioElement, VideoElement } from "@/lib/timeline";
import {
buildSeparatedAudioElement,
doesElementHaveEnabledAudio,
@@ -80,31 +80,11 @@ describe("audio separation", () => {
});
test("skips source audio collection when the source clip is separated", () => {
- const mediaAsset = {
- id: "media-1",
- type: "video",
- name: "Clip",
- size: 1,
- lastModified: 1,
- file: new File(["video"], "clip.mp4", { type: "video/mp4" }),
- url: "blob:clip",
- hasAudio: true,
- };
+ const mediaAsset = { hasAudio: true };
const videoElement = buildVideoElement({
isSourceAudioEnabled: false,
});
- const audioElement = {
- id: "audio-1",
- type: "audio",
- sourceType: "upload",
- mediaId: "audio-media-1",
- name: "Detached audio",
- duration: 5,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- volume: 0,
- } as AudioElement;
+ const audioElement = buildAudioElement();
expect(
doesElementHaveEnabledAudio({
@@ -145,3 +125,21 @@ function buildVideoElement(
...overrides,
};
}
+
+function buildAudioElement(
+ overrides: Partial<UploadAudioElement> = {},
+): UploadAudioElement {
+ return {
+ id: "audio-1",
+ type: "audio",
+ sourceType: "upload",
+ mediaId: "audio-media-1",
+ name: "Detached audio",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ volume: 0,
+ ...overrides,
+ } satisfies UploadAudioElement;
+}
diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts
index b92cb79b..093edfbc 100644
--- a/apps/web/src/lib/timeline/audio-separation/index.ts
+++ b/apps/web/src/lib/timeline/audio-separation/index.ts
@@ -9,6 +9,8 @@ import type {
VideoElement,
} from "../types";
+type MediaAudioState = Pick<MediaAsset, "hasAudio">;
+
export function isSourceAudioEnabled({
element,
}: {
@@ -30,7 +32,7 @@ export function canExtractSourceAudio({
mediaAsset,
}: {
element: TimelineElement;
- mediaAsset: MediaAsset | null | undefined;
+ mediaAsset: MediaAudioState | null | undefined;
}): element is VideoElement {
return (
element.type === "video" &&
@@ -53,7 +55,7 @@ export function canToggleSourceAudio({
mediaAsset,
}: {
element: TimelineElement;
- mediaAsset: MediaAsset | null | undefined;
+ mediaAsset: MediaAudioState | null | undefined;
}): element is VideoElement {
return (
canRecoverSourceAudio({ element }) ||
@@ -66,7 +68,7 @@ export function doesElementHaveEnabledAudio({
mediaAsset,
}: {
element: AudioElement | VideoElement;
- mediaAsset?: MediaAsset | null;
+ mediaAsset?: MediaAudioState | null;
}): boolean {
if (element.type === "audio") {
return true;
diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts
index 469b916e..194cd84b 100644
--- a/apps/web/src/lib/timeline/index.ts
+++ b/apps/web/src/lib/timeline/index.ts
@@ -8,7 +8,6 @@ export * from "./element-utils";
export * from "./audio-separation";
export * from "./zoom-utils";
export * from "./ruler-utils";
-export * from "./ripple-utils";
export * from "./pixel-utils";
export function calculateTotalDuration({
diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts
deleted file mode 100644
index 5e17e9a3..00000000
--- a/apps/web/src/lib/timeline/ripple-utils.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { TimelineElement } from "@/lib/timeline";
-
-export function rippleShiftElements({
- elements,
- afterTime,
- shiftAmount,
-}: {
- elements: TimelineElement[];
- afterTime: number;
- shiftAmount: number;
-}): TimelineElement[] {
- return elements.map((element) =>
- element.startTime >= afterTime
- ? { ...element, startTime: element.startTime - shiftAmount }
- : element,
- );
-}
diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts
new file mode 100644
index 00000000..cef99abc
--- /dev/null
+++ b/apps/web/src/lib/timeline/update-pipeline.ts
@@ -0,0 +1,193 @@
+import { clampAnimationsToDuration } from "@/lib/animation";
+import {
+ clampRetimeRate,
+ getSourceSpanAtClipTime,
+ getTimelineDurationForSourceSpan,
+} from "@/lib/retime";
+import { enforceMainTrackStart } from "@/lib/timeline/placement";
+import type { RetimeConfig, TimelineElement, TimelineTrack } from "@/lib/timeline";
+import { isRetimableElement } from "@/lib/timeline";
+
+type ElementUpdateField = keyof TimelineElement;
+
+export interface ElementUpdateContext {
+ tracks: TimelineTrack[];
+ trackId: string;
+}
+
+interface ElementUpdateRuleResult {
+ element: TimelineElement;
+ changedFields?: ElementUpdateField[];
+}
+
+interface ElementUpdateRuleParams {
+ element: TimelineElement;
+ originalElement: TimelineElement;
+ patch: Partial<TimelineElement>;
+ context: ElementUpdateContext;
+}
+
+interface ElementUpdateRule {
+ triggers: ElementUpdateField[];
+ apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult;
+}
+
+const deriveRules: ElementUpdateRule[] = [
+ {
+ triggers: ["retime"],
+ apply: ({ element, originalElement, patch }) => {
+ if (!("retime" in patch) || !isRetimableElement(element)) {
+ return { element };
+ }
+
+ const nextRetime = patch.retime
+ ? {
+ ...patch.retime,
+ rate: clampRetimeRate({ rate: patch.retime.rate }),
+ }
+ : undefined;
+
+ const sourceDuration = getSourceDuration({
+ trimStart: originalElement.trimStart,
+ trimEnd: originalElement.trimEnd,
+ duration: originalElement.duration,
+ sourceDuration: isRetimableElement(originalElement)
+ ? originalElement.sourceDuration
+ : undefined,
+ retime: isRetimableElement(originalElement)
+ ? originalElement.retime
+ : undefined,
+ });
+ const visibleSourceSpan = Math.max(
+ 0,
+ sourceDuration - element.trimStart - element.trimEnd,
+ );
+ const nextDuration = getTimelineDurationForSourceSpan({
+ sourceSpan: visibleSourceSpan,
+ retime: nextRetime,
+ });
+
+ return {
+ element: {
+ ...element,
+ retime: nextRetime,
+ duration: nextDuration,
+ },
+ changedFields: ["retime", "duration"],
+ };
+ },
+ },
+];
+
+const enforceRules: ElementUpdateRule[] = [
+ {
+ triggers: ["duration"],
+ apply: ({ element }) => ({
+ element: {
+ ...element,
+ animations: clampAnimationsToDuration({
+ animations: element.animations,
+ duration: element.duration,
+ }),
+ },
+ }),
+ },
+ {
+ triggers: ["startTime"],
+ apply: ({ element, context }) => ({
+ element: {
+ ...element,
+ startTime: enforceMainTrackStart({
+ tracks: context.tracks,
+ targetTrackId: context.trackId,
+ requestedStartTime: Math.max(0, element.startTime),
+ excludeElementId: element.id,
+ }),
+ },
+ }),
+ },
+];
+
+export function applyElementUpdate({
+ element,
+ patch,
+ context,
+}: {
+ element: TimelineElement;
+ patch: Partial<TimelineElement>;
+ context: ElementUpdateContext;
+}): TimelineElement {
+ let nextElement = { ...element, ...patch } as TimelineElement;
+ const changedFields = new Set(
+ Object.keys(patch) as ElementUpdateField[],
+ );
+
+ for (const rule of deriveRules) {
+ if (!shouldApplyRule({ rule, changedFields })) {
+ continue;
+ }
+
+ const result = rule.apply({
+ element: nextElement,
+ originalElement: element,
+ patch,
+ context,
+ });
+ nextElement = result.element;
+ for (const field of result.changedFields ?? []) {
+ changedFields.add(field);
+ }
+ }
+
+ for (const rule of enforceRules) {
+ if (!shouldApplyRule({ rule, changedFields })) {
+ continue;
+ }
+
+ nextElement = rule.apply({
+ element: nextElement,
+ originalElement: element,
+ patch,
+ context,
+ }).element;
+ }
+
+ return nextElement;
+}
+
+function shouldApplyRule({
+ rule,
+ changedFields,
+}: {
+ rule: ElementUpdateRule;
+ changedFields: Set<ElementUpdateField>;
+}): boolean {
+ return rule.triggers.some((trigger) => changedFields.has(trigger));
+}
+
+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
+ );
+}