Keep clip timelines continuous after cuts

This commit is contained in:
webadderall
2026-09-13 20:09:35 +10:00
parent 8b9b106d6e
commit 76efbc2d4a
5 changed files with 64 additions and 26 deletions
+18 -1
View File
@@ -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 };
+14
View File
@@ -6,6 +6,20 @@ export interface ClipSplitPlan {
right: ClipRegion;
}
export function removeSpanAndCloseGap<T extends { startMs: number; endMs: number }>(
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.
*
@@ -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 = <T extends { startMs: number; endMs: number }>(regions: T[]) =>
removeSpanAndCloseGap(regions, deletedClip);
setClipRegions(closeGap);
setZoomRegions(closeGap);
setAnnotationRegions(closeGap);
setSpeedRegions(closeGap);
setAudioRegions(closeGap);
}
if (selectedClipId === id) setSelectedClipId(null);
},
+12 -2
View File
@@ -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);
});
});
+12 -15
View File
@@ -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 {