refactor: unify element updates into pipeline and centralize ripple

This commit is contained in:
Maze Winther 2026-04-06 00:02:23 +02:00
parent 20235d0724
commit 44d8c4c876
36 changed files with 841 additions and 756 deletions

View File

@ -144,7 +144,7 @@ export function useKeyframedNumberProperty({
{
trackId,
elementId,
updates: buildBaseUpdates({ value: nextValue }),
patch: buildBaseUpdates({ value: nextValue }),
},
],
});

View File

@ -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 }) },
],
});
};

View File

@ -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}

View File

@ -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,

View File

@ -165,7 +165,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) {
{
trackId,
elementId: element.id,
updates: {
patch: {
masks: [
buildDefaultMaskInstance({
maskType,

View File

@ -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,

View File

@ -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) => {

View File

@ -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);
}
}

View File

@ -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,
this.updateElements({
updates: [
{
trackId,
elementId,
patch: {
retime,
},
},
],
pushHistory,
});
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.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;

View File

@ -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,

View File

@ -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,
]);

View File

@ -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(() => {

View File

@ -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);

View File

@ -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";

View File

@ -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) {

View File

@ -1 +0,0 @@
export { UpdateElementRetimeCommand } from "./update-element-retime";

View File

@ -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);
}
}
}

View File

@ -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;
});

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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 };
}

View File

@ -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 });
}

View File

@ -0,0 +1,4 @@
export type { RippleAdjustment } from "./apply";
export { applyRippleAdjustments } from "./apply";
export { computeRippleAdjustments } from "./diff";
export { rippleShiftElements } from "./shift";

View File

@ -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,
);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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({

View File

@ -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,
);
}

View File

@ -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
);
}

View File

@ -13,7 +13,7 @@
},
"devDependencies": {
"turbo": "^2.8.20",
"typescript": "5.8.3",
"typescript": "^6.0.2",
},
},
"apps/web": {
@ -1601,7 +1601,7 @@
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
@ -1723,6 +1723,8 @@
"@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],

View File

@ -31,7 +31,7 @@
},
"devDependencies": {
"turbo": "^2.8.20",
"typescript": "5.8.3"
"typescript": "^6.0.2"
},
"trustedDependencies": [
"@tailwindcss/oxide"