mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
Keep clip timelines continuous after cuts
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user