From 44d8c4c8764d49a8e8d0ae713432c07f07cb7597 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 6 Apr 2026 00:02:23 +0200 Subject: [PATCH] refactor: unify element updates into pipeline and centralize ripple --- .../hooks/use-keyframed-number-property.ts | 2 +- .../hooks/use-keyframed-vector-property.ts | 4 +- .../panels/properties/tabs/blending-tab.tsx | 8 +- .../panels/properties/tabs/graphic-tab.tsx | 4 +- .../panels/properties/tabs/masks-tab.tsx | 2 +- .../panels/properties/tabs/text-tab.tsx | 10 +- .../components/providers/editor-provider.tsx | 8 + apps/web/src/core/managers/commands.ts | 33 ++- .../web/src/core/managers/timeline-manager.ts | 203 +++++++------ .../src/hooks/actions/use-editor-actions.ts | 2 - .../element/use-element-interaction.ts | 5 - .../timeline/element/use-element-resize.ts | 5 - .../timeline/element/delete-elements.ts | 57 ++-- .../lib/commands/timeline/element/index.ts | 8 +- .../timeline/element/move-elements.ts | 14 +- .../commands/timeline/element/retime/index.ts | 1 - .../element/retime/update-element-retime.ts | 115 -------- .../timeline/element/split-elements.ts | 19 +- .../timeline/element/toggle-elements-muted.ts | 57 ---- .../element/toggle-elements-visibility.ts | 48 ---- .../element/update-element-duration.ts | 58 ---- .../element/update-element-start-time.ts | 70 ----- .../timeline/element/update-element-trim.ts | 116 -------- .../timeline/element/update-element.ts | 48 ---- .../timeline/element/update-elements.ts | 71 +++++ apps/web/src/lib/ripple/apply.ts | 65 +++++ apps/web/src/lib/ripple/diff.ts | 272 ++++++++++++++++++ apps/web/src/lib/ripple/index.ts | 4 + apps/web/src/lib/ripple/shift.ts | 17 ++ .../audio-separation/__tests__/index.test.ts | 44 ++- .../lib/timeline/audio-separation/index.ts | 8 +- apps/web/src/lib/timeline/index.ts | 1 - apps/web/src/lib/timeline/ripple-utils.ts | 17 -- apps/web/src/lib/timeline/update-pipeline.ts | 193 +++++++++++++ bun.lock | 6 +- package.json | 2 +- 36 files changed, 841 insertions(+), 756 deletions(-) delete mode 100644 apps/web/src/lib/commands/timeline/element/retime/index.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-duration.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-start-time.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element-trim.ts delete mode 100644 apps/web/src/lib/commands/timeline/element/update-element.ts create mode 100644 apps/web/src/lib/commands/timeline/element/update-elements.ts create mode 100644 apps/web/src/lib/ripple/apply.ts create mode 100644 apps/web/src/lib/ripple/diff.ts create mode 100644 apps/web/src/lib/ripple/index.ts create mode 100644 apps/web/src/lib/ripple/shift.ts delete mode 100644 apps/web/src/lib/timeline/ripple-utils.ts create mode 100644 apps/web/src/lib/timeline/update-pipeline.ts 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 = { 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; + patch: Partial; }>; 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(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 => 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; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial; - }) { - 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; + }>; + + constructor({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + }) { + 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(); + 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; +}): 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 { + 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; + afterElementsById: Map; + allAfterElementIds: Set; +}): { + 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({ + 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 { + 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; + 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; + 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; + 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; +}): 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 + ); +} diff --git a/bun.lock b/bun.lock index c3f1f850..d8b0e0ff 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 2b77a0d3..22504650 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "turbo": "^2.8.20", - "typescript": "5.8.3" + "typescript": "^6.0.2" }, "trustedDependencies": [ "@tailwindcss/oxide"