Merge pull request #923 from webadderallorg/fix/clip-split-source-in-point

Fix clip splitting and reordering so timeline positions remain independent from source in-points.
This commit is contained in:
webadderall
2026-09-13 18:00:47 +10:00
committed by GitHub
11 changed files with 341 additions and 32 deletions
@@ -1,5 +1,5 @@
import { getClipSourceEndMs, sortClipRegions } from "../types";
import type { ClipRegion } from "../types";
import { getClipSourceEndMs, getClipSourceStartMs, sortClipRegions } from "../types";
export function getActiveClipIdAtSourceTime(
sourceTimeSeconds: number,
@@ -7,7 +7,7 @@ export function getActiveClipIdAtSourceTime(
): string | null {
const sourceMs = Math.round(sourceTimeSeconds * 1000);
const activeClip = sortClipRegions(clipRegions).find(
(clip) => sourceMs >= clip.startMs && sourceMs < getClipSourceEndMs(clip),
(clip) => sourceMs >= getClipSourceStartMs(clip) && sourceMs < getClipSourceEndMs(clip),
);
return activeClip?.id ?? null;
}
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import { planClipSplit } from "./clipSplit";
import {
type ClipRegion,
clipsToTrims,
getClipSourceEndMs,
getClipSourceStartMs,
mapTimelineTimeToSourceTime,
} from "./types";
function createIdFactory() {
let next = 1;
return () => `clip-${next++}`;
}
function splitAndDeleteMiddle(clip: ClipRegion, firstSplitMs: number, secondOffsetMs: number) {
const createId = createIdFactory();
const first = planClipSplit({ clipRegions: [clip], splitMs: firstSplitMs, createId });
if (!first) throw new Error("first split failed");
const second = planClipSplit({
clipRegions: [first.right],
splitMs: first.right.startMs + secondOffsetMs,
createId,
});
if (!second) throw new Error("second split failed");
return { kept: [first.left, second.right], deleted: second.left };
}
describe("planClipSplit", () => {
it("returns null when no clip contains the split position", () => {
const clips: ClipRegion[] = [{ id: "clip-1", startMs: 0, endMs: 1000, speed: 1 }];
expect(
planClipSplit({ clipRegions: clips, splitMs: 2000, createId: createIdFactory() }),
).toBeNull();
expect(
planClipSplit({ clipRegions: clips, splitMs: 0, createId: createIdFactory() }),
).toBeNull();
expect(
planClipSplit({ clipRegions: clips, splitMs: 1000, createId: createIdFactory() }),
).toBeNull();
});
it("splits a 1x clip at the playhead", () => {
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 120_000, speed: 1 };
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 30_000,
createId: createIdFactory(),
});
expect(plan?.left).toMatchObject({ startMs: 0, endMs: 30_000 });
expect(plan?.right).toMatchObject({ startMs: 30_000, endMs: 120_000 });
});
it("anchors the right half to the source time the split maps to at non-1x speed", () => {
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 60_000, speed: 2 };
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 10_000,
createId: createIdFactory(),
});
if (!plan) throw new Error("split failed");
// 10s of playback at 2x consumes 20s of source.
expect(getClipSourceEndMs(plan.left)).toBe(20_000);
expect(getClipSourceStartMs(plan.right)).toBe(20_000);
// The halves still cover exactly the source the original clip covered.
expect(getClipSourceEndMs(plan.right)).toBe(getClipSourceEndMs(clip));
});
it("leaves both halves where the clip already sat on the timeline", () => {
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 60_000, speed: 2 };
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 10_000,
createId: createIdFactory(),
});
// No visual gap: the halves abut at the playhead and still end where the clip did.
expect(plan?.left).toMatchObject({ startMs: 0, endMs: 10_000 });
expect(plan?.right).toMatchObject({ startMs: 10_000, endMs: 60_000 });
});
it("keeps the timeline-to-source mapping continuous across the split", () => {
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 60_000, speed: 2 };
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 10_000,
createId: createIdFactory(),
});
if (!plan) throw new Error("split failed");
const halves = [plan.left, plan.right];
for (const timelineMs of [0, 5_000, 10_000, 30_000, 60_000]) {
expect(mapTimelineTimeToSourceTime(timelineMs, halves)).toBe(
mapTimelineTimeToSourceTime(timelineMs, [clip]),
);
}
});
it("keeps split halves contiguous in source time so no gap is trimmed", () => {
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 48_000, speed: 2.5 };
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 17_333,
createId: createIdFactory(),
});
if (!plan) throw new Error("split failed");
expect(clipsToTrims([plan.left, plan.right], getClipSourceEndMs(clip))).toEqual([]);
});
it("removes the source range the user cut out when the clip is sped up", () => {
const sourceDurationMs = 120_000;
const clip: ClipRegion = { id: "clip-1", startMs: 0, endMs: 60_000, speed: 2 };
// Cut the 10s-20s playback window out of a 2x clip => source 20s-40s.
const { kept, deleted } = splitAndDeleteMiddle(clip, 10_000, 10_000);
expect([getClipSourceStartMs(deleted), getClipSourceEndMs(deleted)]).toEqual([
20_000, 40_000,
]);
expect(clipsToTrims(kept, sourceDurationMs)).toEqual([
{ id: "trim-gap-1", startMs: 20_000, endMs: 40_000 },
]);
// Nothing else is lost: the tail of the recording is still covered.
expect(getClipSourceEndMs(kept[1])).toBe(sourceDurationMs);
});
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 };
const { kept } = splitAndDeleteMiddle(clip, 10_000, 10_000);
expect(clipsToTrims(kept, sourceDurationMs)).toEqual([
{ id: "trim-gap-1", startMs: 10_000, endMs: 20_000 },
]);
expect(getClipSourceEndMs(kept[1])).toBe(sourceDurationMs);
});
it("carries clip settings into both halves", () => {
const clip: ClipRegion = {
id: "clip-1",
startMs: 0,
endMs: 60_000,
speed: 2,
muted: true,
showSourceAudio: true,
};
const plan = planClipSplit({
clipRegions: [clip],
splitMs: 10_000,
createId: createIdFactory(),
});
for (const half of [plan?.left, plan?.right]) {
expect(half).toMatchObject({ speed: 2, muted: true, showSourceAudio: true });
}
expect(plan?.left.id).not.toBe(plan?.right.id);
});
});
+48
View File
@@ -0,0 +1,48 @@
import { type ClipRegion, getClipSourceEndMs } from "./types";
export interface ClipSplitPlan {
targetId: string;
left: ClipRegion;
right: ClipRegion;
}
/**
* Split the clip under the playhead into two clips.
*
* `splitMs` is a timeline position, so the halves stay put on the timeline and
* only their source in-points differ. At non-1x speed the split consumes more
* (or less) source than timeline, so the right half reads from
* `getClipSourceEndMs(left)` — otherwise it re-reads footage the left half
* already covers and the tail of the recording falls outside every clip.
*/
export function planClipSplit(params: {
clipRegions: ClipRegion[];
splitMs: number;
createId: () => string;
}): ClipSplitPlan | null {
const { clipRegions, splitMs, createId } = params;
if (!Number.isFinite(splitMs)) {
return null;
}
const splitAtMs = Math.round(splitMs);
const target = clipRegions.find((clip) => splitAtMs > clip.startMs && splitAtMs < clip.endMs);
if (!target) {
return null;
}
const left: ClipRegion = {
...target,
id: createId(),
endMs: splitAtMs,
};
const right: ClipRegion = {
...target,
id: createId(),
startMs: splitAtMs,
endMs: target.endMs,
sourceStartMs: getClipSourceEndMs(left),
};
return { targetId: target.id, left, right };
}
@@ -2,6 +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 type {
AnnotationRegion,
AudioRegion,
@@ -10,6 +11,7 @@ import type {
SpeedRegion,
ZoomRegion,
} from "../types";
import { getClipSourceStartMs } from "../types";
type Translator = (
key: string,
@@ -79,19 +81,18 @@ export function useClipRegionCommands({
const handleClipSplit = useCallback(
(splitMs: number) => {
const target = clipRegions.find(
(clip) => splitMs > clip.startMs && splitMs < clip.endMs,
);
if (!target) return;
const leftId = `clip-${nextClipIdRef.current++}`;
const rightId = `clip-${nextClipIdRef.current++}`;
const splitAt = Math.round(splitMs);
const left: ClipRegion = { ...target, id: leftId, endMs: splitAt };
const right: ClipRegion = { ...target, id: rightId, startMs: splitAt };
const plan = planClipSplit({
clipRegions,
splitMs,
createId: () => `clip-${nextClipIdRef.current++}`,
});
if (!plan) return;
setClipRegions((current) =>
current.flatMap((clip) => (clip.id === target.id ? [left, right] : [clip])),
current.flatMap((clip) =>
clip.id === plan.targetId ? [plan.left, plan.right] : [clip],
),
);
if (selectedClipId === target.id) setSelectedClipId(leftId);
if (selectedClipId === plan.targetId) setSelectedClipId(plan.left.id);
},
[clipRegions, nextClipIdRef, selectedClipId, setClipRegions, setSelectedClipId],
);
@@ -149,9 +150,19 @@ export function useClipRegionCommands({
}
setClipRegions((current) =>
current.map((clip) =>
clip.id === id ? { ...clip, startMs: newStart, endMs: newEnd } : clip,
),
current.map((clip) => {
if (clip.id !== id) return clip;
const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
const startDelta = newStart - clip.startMs;
const endDelta = newEnd - clip.endMs;
// A move carries its footage along; trimming the left edge skips
// into the source by however much source that edge covered.
const isMove = Math.abs(startDelta - endDelta) < 1;
const sourceStartMs = isMove
? getClipSourceStartMs(clip)
: Math.max(0, Math.round(getClipSourceStartMs(clip) + startDelta * speed));
return { ...clip, startMs: newStart, endMs: newEnd, sourceStartMs };
}),
);
},
[
@@ -7,6 +7,7 @@ import {
clipsToTrims,
extendAutoFullTrackClip,
getClipSourceEndMs,
getClipSourceStartMs,
getTimelineDurationMs,
mapSourceTimeToTimelineTime,
mapTimelineTimeToSourceTime,
@@ -119,7 +120,7 @@ export function useTimelineProjection({
.filter(({ speed }) => speed !== 1)
.map((clip) => ({
id: `clip-speed-${clip.id}`,
startMs: clip.startMs,
startMs: getClipSourceStartMs(clip),
endMs: getClipSourceEndMs(clip),
speed: clip.speed as SpeedRegion["speed"],
}));
@@ -5,7 +5,7 @@ import { toFileUrl } from "../projectPersistence";
import type { useAppearanceState } from "../state/useAppearanceState";
import type { useProjectState } from "../state/useProjectState";
import type { useTimelineState } from "../state/useTimelineState";
import { getClipSourceEndMs, type SpeedRegion } from "../types";
import { getClipSourceEndMs, getClipSourceStartMs, type SpeedRegion } from "../types";
import type { VideoPlaybackRef } from "../VideoPlayback";
type Input = {
@@ -180,7 +180,7 @@ export function useProjectLibraryController({
.filter((clip) => clip.speed !== 1)
.map((clip) => ({
id: `clip-speed-${clip.id}`,
startMs: clip.startMs,
startMs: getClipSourceStartMs(clip),
endMs: getClipSourceEndMs(clip),
speed: clip.speed as SpeedRegion["speed"],
}));
@@ -537,6 +537,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
id: region.id,
startMs,
endMs,
...(isFiniteNumber(region.sourceStartMs)
? { sourceStartMs: Math.max(0, Math.round(region.sourceStartMs)) }
: {}),
speed: isFiniteNumber(region.speed) ? region.speed : 1,
muted: typeof region.muted === "boolean" ? region.muted : false,
showSourceAudio:
@@ -74,6 +74,24 @@ describe("timeline model", () => {
});
});
it("keeps a split clip's sourceSpan in source coordinates", () => {
const items = buildTimelineItems({
zoomRegions: [],
// The right half of a 2x clip split at timeline 10s: it still sits at
// 10s but reads from 20s.
clipRegions: [
{ id: "c1", startMs: 10_000, endMs: 60_000, sourceStartMs: 20_000, speed: 2 },
],
annotationRegions: [],
audioRegions: [],
});
expect(items[0]).toMatchObject({
span: { start: 10_000, end: 60_000 },
sourceSpan: { start: 20_000, end: 120_000 },
});
});
it("builds all variant labels for annotation and audio", () => {
expect(getAnnotationLabel({ ...BASE_ANNOTATION, type: "text", content: " " })).toBe(
"Empty text",
@@ -6,6 +6,7 @@ import type {
ClipRegion,
ZoomRegion,
} from "../../types";
import { getClipSourceEndMs, getClipSourceStartMs } from "../../types";
import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
@@ -61,16 +62,15 @@ export function buildTimelineItems(params: {
}));
const clips: TimelineRenderItem[] = clipRegions.map((region, index) => {
const displayDurationMs = Math.max(0, region.endMs - region.startMs);
const speed = Number.isFinite(region.speed) && region.speed > 0 ? region.speed : 1;
const sourceEndMs = region.startMs + displayDurationMs * speed;
const sourceEndMs = getClipSourceEndMs(region);
const speedLabel = formatClipSpeedLabel(speed);
return {
id: region.id,
rowId: CLIP_ROW_ID,
span: { start: region.startMs, end: region.endMs },
sourceSpan: { start: region.startMs, end: sourceEndMs },
sourceSpan: { start: getClipSourceStartMs(region), end: sourceEndMs },
label: speedLabel ? `Clip ${index + 1} ${speedLabel}` : `Clip ${index + 1}`,
speedValue: speedLabel ? speed : undefined,
showSourceAudio: region.showSourceAudio,
+42
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { deriveNextId } from "./projectPersistence";
import {
type ClipRegion,
clipsToTrims,
extendAutoFullTrackClip,
findClipAtTimelineTime,
getTimelineDurationMs,
@@ -180,3 +182,43 @@ describe("getTimelineDurationMs", () => {
).toBe(10_000);
});
});
describe("clipsToTrims", () => {
it("trims the source ranges no clip covers", () => {
const clips: ClipRegion[] = [
{ id: "clip-1", startMs: 0, endMs: 10_000, speed: 1 },
{ id: "clip-2", startMs: 10_000, endMs: 20_000, sourceStartMs: 30_000, speed: 1 },
];
expect(clipsToTrims(clips, 60_000)).toEqual([
{ id: "trim-gap-1", startMs: 10_000, endMs: 30_000 },
{ id: "trim-gap-2", startMs: 40_000, endMs: 60_000 },
]);
});
it("covers source ranges that sit out of order on the timeline", () => {
// A moved clip keeps its source in-point, so the clip that comes first on
// the timeline can read from later in the recording.
const clips: ClipRegion[] = [
{ id: "clip-1", startMs: 0, endMs: 10_000, sourceStartMs: 20_000, speed: 1 },
{ id: "clip-2", startMs: 10_000, endMs: 20_000, sourceStartMs: 0, speed: 1 },
];
// Source [0,10] and [20,30] are both in use; only the gaps go.
expect(clipsToTrims(clips, 40_000)).toEqual([
{ id: "trim-gap-1", startMs: 10_000, endMs: 20_000 },
{ id: "trim-gap-2", startMs: 30_000, endMs: 40_000 },
]);
});
it("merges overlapping source spans instead of trimming between them", () => {
const clips: ClipRegion[] = [
{ id: "clip-1", startMs: 0, endMs: 20_000, sourceStartMs: 0, speed: 1 },
{ id: "clip-2", startMs: 20_000, endMs: 30_000, sourceStartMs: 10_000, speed: 1 },
];
expect(clipsToTrims(clips, 40_000)).toEqual([
{ id: "trim-gap-1", startMs: 20_000, endMs: 40_000 },
]);
});
});
+34 -10
View File
@@ -226,17 +226,29 @@ export interface TrimRegion {
export interface ClipRegion {
id: string;
/** Where the clip sits on the timeline. */
startMs: number;
/** Where the clip ends on the timeline (`startMs` + source duration / speed). */
endMs: number;
/**
* Where the clip reads from in the recording. Defaults to `startMs`, which is
* only the same thing while everything before it plays at 1x — splitting or
* left-trimming a sped-up clip moves the source in without moving the clip.
*/
sourceStartMs?: number;
speed: number;
muted?: boolean;
showSourceAudio?: boolean;
}
export function getClipSourceStartMs(clip: ClipRegion): number {
return Number.isFinite(clip.sourceStartMs) ? (clip.sourceStartMs as number) : clip.startMs;
}
export function getClipSourceEndMs(clip: ClipRegion): number {
const displayDurationMs = Math.max(0, clip.endMs - clip.startMs);
const speed = Number.isFinite(clip.speed) && clip.speed > 0 ? clip.speed : 1;
return Math.round(clip.startMs + displayDurationMs * speed);
return Math.round(getClipSourceStartMs(clip) + displayDurationMs * speed);
}
export function getTimelineDurationMs(clips: ClipRegion[], sourceDurationMs: number): number {
@@ -271,7 +283,7 @@ function clampToNearestClipBoundary(
const boundaries =
kind === "timeline"
? [clip.startMs, clip.endMs]
: [clip.startMs, getClipSourceEndMs(clip)];
: [getClipSourceStartMs(clip), getClipSourceEndMs(clip)];
for (const boundary of boundaries) {
const distance = Math.abs(timeMs - boundary);
@@ -294,7 +306,9 @@ export function mapTimelineTimeToSourceTime(timeMs: number, clips: ClipRegion[])
continue;
}
return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip));
return Math.round(
getClipSourceStartMs(clip) + (roundedTimeMs - clip.startMs) * getSafeClipSpeed(clip),
);
}
if (sortedClips.length === 0) {
@@ -309,12 +323,13 @@ export function mapSourceTimeToTimelineTime(timeMs: number, clips: ClipRegion[])
const sortedClips = sortClipRegions(clips);
for (const clip of sortedClips) {
const sourceStartMs = getClipSourceStartMs(clip);
const sourceEndMs = getClipSourceEndMs(clip);
if (roundedTimeMs < clip.startMs || roundedTimeMs > sourceEndMs) {
if (roundedTimeMs < sourceStartMs || roundedTimeMs > sourceEndMs) {
continue;
}
return Math.round(clip.startMs + (roundedTimeMs - clip.startMs) / getSafeClipSpeed(clip));
return Math.round(clip.startMs + (roundedTimeMs - sourceStartMs) / getSafeClipSpeed(clip));
}
if (sortedClips.length === 0) {
@@ -365,15 +380,24 @@ export function extendAutoFullTrackClip(
/** Convert clip regions (kept segments) to trim regions (gaps to remove). */
export function clipsToTrims(clips: ClipRegion[], totalDurationMs: number): TrimRegion[] {
if (clips.length === 0) return [];
const sorted = [...clips].sort((a, b) => a.startMs - b.startMs);
// Clips are ordered on the timeline, but a moved clip keeps its source
// in-point, so timeline order says nothing about source order. Walk the
// source ranges the clips claim and trim whatever is left uncovered.
const coveredSpans = clips
.map((clip) => ({
startMs: getClipSourceStartMs(clip),
endMs: getClipSourceEndMs(clip),
}))
.filter((span) => span.endMs > span.startMs)
.sort((left, right) => left.startMs - right.startMs);
const trims: TrimRegion[] = [];
let cursor = 0;
let trimId = 1;
for (const clip of sorted) {
if (clip.startMs > cursor) {
trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: clip.startMs });
for (const span of coveredSpans) {
if (span.startMs > cursor) {
trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: span.startMs });
}
cursor = getClipSourceEndMs(clip);
cursor = Math.max(cursor, span.endMs);
}
if (cursor < totalDurationMs) {
trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: totalDurationMs });