diff --git a/src/components/video-editor/clipSplit.test.ts b/src/components/video-editor/clipSplit.test.ts index c9b588c7..9a287e82 100644 --- a/src/components/video-editor/clipSplit.test.ts +++ b/src/components/video-editor/clipSplit.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; -import { planClipSplit } from "./clipSplit"; +import { planClipSplit, removeSpanAndCloseGap } from "./clipSplit"; import { type ClipRegion, clipsToTrims, getClipSourceEndMs, getClipSourceStartMs, + getTimelineDurationMs, + mapSourceTimeToTimelineTime, mapTimelineTimeToSourceTime, } from "./types"; @@ -127,6 +129,21 @@ describe("planClipSplit", () => { expect(getClipSourceEndMs(kept[1])).toBe(sourceDurationMs); }); + it("closes the timeline gap after deleting the middle of a 3x clip", () => { + const sourceDurationMs = 120_000; + const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 40_000, speed: 3 }; + const { kept, deleted } = splitAndDeleteMiddle(clip, 10_000, 10_000); + const closed = removeSpanAndCloseGap([...kept, deleted], deleted); + + expect(closed).toEqual([ + expect.objectContaining({ startMs: 0, endMs: 10_000 }), + expect.objectContaining({ startMs: 10_000, endMs: 30_000, sourceStartMs: 60_000 }), + ]); + expect(mapSourceTimeToTimelineTime(30_000, closed)).toBe(10_000); + expect(mapSourceTimeToTimelineTime(60_000, closed)).toBe(10_000); + expect(getTimelineDurationMs(closed, sourceDurationMs)).toBe(30_000); + }); + it("removes the source range the user cut out at 1x", () => { const sourceDurationMs = 120_000; const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: sourceDurationMs, speed: 1 }; diff --git a/src/components/video-editor/clipSplit.ts b/src/components/video-editor/clipSplit.ts index 1ff2ebb6..cb250105 100644 --- a/src/components/video-editor/clipSplit.ts +++ b/src/components/video-editor/clipSplit.ts @@ -6,6 +6,20 @@ export interface ClipSplitPlan { right: ClipRegion; } +export function removeSpanAndCloseGap( + spans: T[], + deleted: { startMs: number; endMs: number }, +): T[] { + const durationMs = deleted.endMs - deleted.startMs; + return spans + .filter((span) => span.endMs <= deleted.startMs || span.startMs >= deleted.endMs) + .map((span) => + span.startMs >= deleted.endMs + ? { ...span, startMs: span.startMs - durationMs, endMs: span.endMs - durationMs } + : span, + ); +} + /** * Split the clip under the playhead into two clips. * diff --git a/src/components/video-editor/hooks/useClipRegionCommands.ts b/src/components/video-editor/hooks/useClipRegionCommands.ts index 40f90cd1..198c4a37 100644 --- a/src/components/video-editor/hooks/useClipRegionCommands.ts +++ b/src/components/video-editor/hooks/useClipRegionCommands.ts @@ -2,7 +2,7 @@ import type { Span } from "dnd-timeline"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; import { toast } from "sonner"; import { planClipSpeedChange } from "../clipSpeedChange"; -import { planClipSplit } from "../clipSplit"; +import { planClipSplit, removeSpanAndCloseGap } from "../clipSplit"; import type { AnnotationRegion, AudioRegion, @@ -224,14 +224,14 @@ export function useClipRegionCommands({ const handleClipDelete = useCallback( (id: string) => { const deletedClip = clipRegions.find((clip) => clip.id === id); - setClipRegions((current) => current.filter((clip) => clip.id !== id)); if (deletedClip) { - const outsideDeletedClip = (region: { startMs: number; endMs: number }) => - region.endMs <= deletedClip.startMs || region.startMs >= deletedClip.endMs; - setZoomRegions((current) => current.filter(outsideDeletedClip)); - setAnnotationRegions((current) => current.filter(outsideDeletedClip)); - setSpeedRegions((current) => current.filter(outsideDeletedClip)); - setAudioRegions((current) => current.filter(outsideDeletedClip)); + const closeGap = (regions: T[]) => + removeSpanAndCloseGap(regions, deletedClip); + setClipRegions(closeGap); + setZoomRegions(closeGap); + setAnnotationRegions(closeGap); + setSpeedRegions(closeGap); + setAudioRegions(closeGap); } if (selectedClipId === id) setSelectedClipId(null); }, diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts index 4504b8b9..0d95a9da 100644 --- a/src/components/video-editor/types.test.ts +++ b/src/components/video-editor/types.test.ts @@ -142,6 +142,16 @@ describe("clip timeline mapping", () => { expect(mapSourceTimeToTimelineTime(5_900, clips)).toBe(6_000); }); + it("maps gaps between different source and timeline positions", () => { + const movedClips = [ + { id: "clip-1", startMs: 0, endMs: 4_000, sourceStartMs: 0, speed: 1 }, + { id: "clip-2", startMs: 6_000, endMs: 8_000, sourceStartMs: 10_000, speed: 1 }, + ]; + + expect(mapTimelineTimeToSourceTime(5_900, movedClips)).toBe(10_000); + expect(mapSourceTimeToTimelineTime(9_900, movedClips)).toBe(6_000); + }); + it("finds clips only inside visible kept spans", () => { expect(findClipAtTimelineTime(500, clips)?.id).toBe("clip-1"); expect(findClipAtTimelineTime(5_000, clips)).toBeNull(); @@ -176,10 +186,10 @@ describe("getTimelineDurationMs", () => { ).toBe(20_000); }); - it("keeps the source duration when speed edits make clips shorter", () => { + it("shortens the timeline when speed edits make clips shorter", () => { expect( getTimelineDurationMs([{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], 10_000), - ).toBe(10_000); + ).toBe(5_000); }); }); diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 55de007b..8f038ca1 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -259,7 +259,7 @@ export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: num return clips.reduce( (durationMs, clip) => Math.max(durationMs, Math.max(0, Math.round(clip.endMs))), - baseDurationMs, + 0, ); } @@ -271,25 +271,22 @@ function getSafeClipSpeed(clip: ClipRegion) { return Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1; } -function clampToNearestClipBoundary( - timeMs: number, - clips: ClipRegion[], - kind: "timeline" | "source", -) { +function mapNearestClipBoundary(timeMs: number, clips: ClipRegion[], from: "timeline" | "source") { let nearestTimeMs = Math.round(timeMs); let nearestDistance = Number.POSITIVE_INFINITY; for (const clip of clips) { - const boundaries = - kind === "timeline" - ? [clip.startMs, clip.endMs] - : [getClipSourceStartMs(clip), getClipSourceEndMs(clip)]; + const boundaries = [ + [clip.startMs, getClipSourceStartMs(clip)], + [clip.endMs, getClipSourceEndMs(clip)], + ]; - for (const boundary of boundaries) { - const distance = Math.abs(timeMs - boundary); + for (const [timelineTimeMs, sourceTimeMs] of boundaries) { + const inputTimeMs = from === "timeline" ? timelineTimeMs : sourceTimeMs; + const distance = Math.abs(timeMs - inputTimeMs); if (distance < nearestDistance) { nearestDistance = distance; - nearestTimeMs = Math.round(boundary); + nearestTimeMs = Math.round(from === "timeline" ? sourceTimeMs : timelineTimeMs); } } } @@ -315,7 +312,7 @@ export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[]) return roundedTimeMs; } - return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "timeline"); + return mapNearestClipBoundary(roundedTimeMs, sortedClips, "timeline"); } export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]): number { @@ -336,7 +333,7 @@ export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[]) return roundedTimeMs; } - return clampToNearestClipBoundary(roundedTimeMs, sortedClips, "source"); + return mapNearestClipBoundary(roundedTimeMs, sortedClips, "source"); } export function findClipAtTimelineTime(timeMs: number, clips: ClipRegion[]): ClipRegion | null {