fix(editor): split clips without moving their source in-point

Splitting a sped-up clip and deleting the middle leaves the deleted footage
in the export and drops a matching stretch from the end of the recording.

`ClipRegion.startMs` is a source position while the split position is a
timeline position, and the two only coincide at 1x. `handleClipSplit`
assigned the timeline position as the right half's source start, so at 2x
the right half re-read footage the left half already covered. Cutting the
10s-20s playback window out of a 2x clip removed source [100s,120s] instead
of [20s,40s]: nothing in the middle was cut and the last 20s of the
recording silently disappeared.

Anchoring the right half at the source time the split maps to fixes the
export, but it would also drag the clip across the timeline, since the clip
row draws items at `startMs`. So separate the two meanings: `sourceStartMs`
says where a clip reads from, `startMs` stays where it sits. It falls back
to `startMs` when absent, so stored projects behave exactly as before and
need no migration.

Left-edge drags in `handleClipSpanChange` were wrong in the same way and now
advance `sourceStartMs` by the source the trimmed edge covered; moves carry
their footage along unchanged.

The split itself moves into a pure `planClipSplit`, matching how
`planClipSpeedChange` is already factored, so it can be covered by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ZyJLpqdwGjGvR4yy44xWU
This commit is contained in:
Yuta Isozaki
2026-09-11 02:07:22 +09:00
co-authored by Claude Opus 5
parent cdb3e34e2d
commit 8e5e5e2de5
9 changed files with 269 additions and 27 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:
@@ -6,6 +6,7 @@ import type {
ClipRegion,
ZoomRegion,
} from "../../types";
import { getClipSourceStartMs } from "../../types";
import { CAPTION_ROW_ID, CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants";
import {
getAnnotationTrackIndex,
@@ -70,7 +71,7 @@ export function buildTimelineItems(params: {
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,
+23 -7
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) {
@@ -370,8 +385,9 @@ export function clipsToTrims(clips: ClipRegion[], totalDurationMs: number): Trim
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 });
const sourceStartMs = getClipSourceStartMs(clip);
if (sourceStartMs > cursor) {
trims.push({ id: `trim-gap-${trimId++}`, startMs: cursor, endMs: sourceStartMs });
}
cursor = getClipSourceEndMs(clip);
}