Jump to frame
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index b279102c90..002552d7d1 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -3,7 +3,8 @@ import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElements";
-import { defaultTimelineTheme } from "./timelineTheme";
+import { defaultTimelineTheme, timelineShellStyle } from "./timelineTheme";
+import { activeTrimMode } from "./timelineTrimTools";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineZoom } from "./useTimelineZoom";
@@ -445,14 +446,10 @@ export const Timeline = memo(function Timeline({
ref={setContainerRef}
aria-label="Timeline"
data-timeline-element-count={expandedElements.length}
- className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
+ className={`relative border-t select-none h-full overflow-hidden ${assetDrop.isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : activeTrimMode(activeTool) ? "cursor-ew-resize" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
onMouseMove={updateRazorGuide}
onMouseLeave={clearRazorGuide}
- style={{
- touchAction: "pan-x pan-y",
- background: theme.shellBackground,
- borderColor: theme.shellBorder,
- }}
+ style={timelineShellStyle(theme)}
>
,
+ clientX: number,
+ scroll: HTMLDivElement | null,
+): { originScrollLeft: number; effectiveClientX: number } {
+ const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
+ return {
+ originScrollLeft,
+ effectiveClientX: clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft),
+ };
+}
+
export interface ResizePreviewResult {
originScrollLeft: number;
previewStart: number;
@@ -237,11 +255,7 @@ export function computeResizePreview(
ctx: ResizePreviewContext,
): ResizePreviewResult {
const { scroll, pps, buildSnapTargets } = ctx;
- // Scroll compensation: auto-scroll moves the content while the pointer stays
- // put, so fold the scroll delta into the pointer x (mirrors
- // resolveTimelineMove's originScrollLeft handling).
- const originScrollLeft = resize.originScrollLeft ?? scroll?.scrollLeft ?? 0;
- const effectiveClientX = clientX + ((scroll?.scrollLeft ?? originScrollLeft) - originScrollLeft);
+ const { originScrollLeft, effectiveClientX } = compensateResizeScroll(resize, clientX, scroll);
const sourceRemaining =
resize.element.sourceDuration != null
diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts
index 251a299f6d..8fef6126c0 100644
--- a/packages/studio/src/player/components/timelineClipDragTypes.ts
+++ b/packages/studio/src/player/components/timelineClipDragTypes.ts
@@ -1,6 +1,7 @@
import type { TimelineElement } from "../store/playerStore";
import type { TimelineSnapType } from "./timelineSnapping";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
+import type { TimelineTrimMode } from "./timelineTrimOps";
/* ── Shared clip-drag state types ───────────────────────────────── */
export interface DraggedClipState {
@@ -45,6 +46,12 @@ export interface ResizingClipState {
pointerId: number;
element: TimelineElement;
edge: "start" | "end";
+ /**
+ * Active trim tool. Absent ⇒ the plain single-clip (or multi-select group)
+ * resize this gesture has always been. `edge` is the grabbed edit point for
+ * ripple and roll; slip and slide are whole-clip gestures that ignore it.
+ */
+ trimMode?: TimelineTrimMode;
originClientX: number;
/**
* scrollLeft at gesture start. Edge auto-scroll moves the content under a
diff --git a/packages/studio/src/player/components/timelineClipGestureHandlers.ts b/packages/studio/src/player/components/timelineClipGestureHandlers.ts
index 42d4f4dd56..ee6b956629 100644
--- a/packages/studio/src/player/components/timelineClipGestureHandlers.ts
+++ b/packages/studio/src/player/components/timelineClipGestureHandlers.ts
@@ -8,6 +8,9 @@ import {
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { CLIP_HANDLE_W } from "./timelineLayout";
+import type { TimelineTrimMode } from "./timelineTrimOps";
+import { canStartTimelineTrim } from "./timelineTrimSession";
+import { trimToolFor } from "./timelineTrimTools";
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
export interface ClipGestureDeps {
@@ -32,22 +35,43 @@ export interface ClipGestureDeps {
onSelectElement?: (element: TimelineElement | null) => void;
}
-/** Whether a resize-handle drag on `edge` is allowed to begin at all. */
-function canStartResize(
- edge: "start" | "end",
+/** Whether the clip can take a trim on this edge at all. */
+const canTrimEdge = (edge: "start" | "end", caps: TimelineEditCapabilities): boolean =>
+ edge === "start" ? caps.canTrimStart : caps.canTrimEnd;
+
+/** The opening state of a clip-move gesture, before any pointer movement. */
+function openDrag(
+ el: TimelineElement,
e: ReactPointerEvent,
- capabilities: TimelineEditCapabilities,
- onResizeElement: ClipGestureDeps["onResizeElement"],
-): boolean {
- if (e.button !== 0 || e.shiftKey || !onResizeElement) return false;
- if (edge === "start") return capabilities.canTrimStart;
- return capabilities.canTrimEnd;
+ rect: DOMRect,
+ scroll: HTMLDivElement | null,
+): DraggedClipState {
+ return {
+ pointerId: e.pointerId,
+ element: el,
+ originClientX: e.clientX,
+ originClientY: e.clientY,
+ originScrollLeft: scroll?.scrollLeft ?? 0,
+ originScrollTop: scroll?.scrollTop ?? 0,
+ pointerClientX: e.clientX,
+ pointerClientY: e.clientY,
+ pointerOffsetX: e.clientX - rect.left,
+ pointerOffsetY: e.clientY - rect.top,
+ previewStart: el.start,
+ previewTrack: el.track,
+ desiredTrack: el.track,
+ insertRow: null,
+ snapTime: null,
+ snapType: null,
+ started: false,
+ };
}
type PointerDownAction =
| { kind: "ignore" }
| { kind: "arm-shift-click" }
| { kind: "block"; intent: BlockedTimelineEditIntent; rect: DOMRect }
+ | { kind: "trim"; mode: TimelineTrimMode }
| { kind: "move"; rect: DOMRect };
/**
@@ -78,7 +102,14 @@ function resolvePointerDownAction(
onMoveElement: ClipGestureDeps["onMoveElement"],
): PointerDownAction {
if (e.button !== 0) return { kind: "ignore" };
- if (usePlayerStore.getState().activeTool === "razor") return { kind: "ignore" };
+ // Any tool but Select owns the body outright: it either claims it (slip,
+ // slide) or wants nothing from it (razor, ripple, roll — whose body drag
+ // means nothing, so ignoring it leaves plain click-to-select working).
+ const tool = usePlayerStore.getState().activeTool;
+ if (tool !== "select") {
+ const mode = trimToolFor(tool, "body");
+ return mode ? { kind: "trim", mode } : { kind: "ignore" };
+ }
if (e.shiftKey) return { kind: "arm-shift-click" };
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -127,8 +158,53 @@ export function createClipGestureHandlers(
onSelectElement,
} = deps;
+ /**
+ * Open a trim gesture, or arm the blocked-attempt report when the tool cannot
+ * act on this clip (a roll with nothing across the cut, a slip on generated
+ * pixels, a locked neighbour). Refusing here — rather than starting a gesture
+ * that silently does nothing — is what makes the tools legible.
+ */
+ const startTrim = (mode: TimelineTrimMode, edge: "start" | "end", e: ReactPointerEvent): void => {
+ blockedClipRef.current = null;
+ if (!canStartTimelineTrim(el, mode, edge, usePlayerStore.getState().elements)) {
+ blockedClipRef.current = {
+ pointerId: e.pointerId,
+ element: el,
+ intent: mode,
+ originClientX: e.clientX,
+ originClientY: e.clientY,
+ started: false,
+ };
+ return;
+ }
+ setShowPopover(false);
+ setRangeSelection(null);
+ setResizingClip({
+ pointerId: e.pointerId,
+ element: el,
+ edge,
+ trimMode: mode,
+ originClientX: e.clientX,
+ originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
+ previewStart: el.start,
+ previewDuration: el.duration,
+ previewPlaybackStart: el.playbackStart,
+ started: false,
+ });
+ };
+
const onResizeStart = (edge: "start" | "end", e: ReactPointerEvent): void => {
- if (!canStartResize(edge, e, capabilities, onResizeElement)) return;
+ if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
+ const tool = usePlayerStore.getState().activeTool;
+ const trim = trimToolFor(tool, "edge");
+ if (trim) {
+ e.stopPropagation();
+ startTrim(trim, edge, e);
+ return;
+ }
+ // A body tool (slip, slide) or the razor lets the handle fall through to
+ // the clip body, which is where those gestures actually live.
+ if (tool !== "select" || !canTrimEdge(edge, capabilities)) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
@@ -150,6 +226,13 @@ export function createClipGestureHandlers(
const action = resolvePointerDownAction(e, capabilities, onResizeElement, onMoveElement);
if (action.kind === "ignore") return;
+ if (action.kind === "trim") {
+ if (!onResizeElement) return;
+ // Slip and slide are whole-clip gestures: `edge` is inert for them.
+ startTrim(action.mode, "end", e);
+ return;
+ }
+
if (action.kind === "arm-shift-click") {
shiftClickClipRef.current = { element: el, anchorX: e.clientX, anchorY: e.clientY };
return;
@@ -167,29 +250,10 @@ export function createClipGestureHandlers(
return;
}
- const { rect } = action;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
- setDraggedClip({
- pointerId: e.pointerId,
- element: el,
- originClientX: e.clientX,
- originClientY: e.clientY,
- originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
- originScrollTop: scrollRef.current?.scrollTop ?? 0,
- pointerClientX: e.clientX,
- pointerClientY: e.clientY,
- pointerOffsetX: e.clientX - rect.left,
- pointerOffsetY: e.clientY - rect.top,
- previewStart: el.start,
- previewTrack: el.track,
- desiredTrack: el.track,
- insertRow: null,
- snapTime: null,
- snapType: null,
- started: false,
- });
+ setDraggedClip(openDrag(el, e, action.rect, scrollRef.current));
};
const onClick = (e: ReactMouseEvent): void => {
diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts
index 5ceb30c2bc..68692e329a 100644
--- a/packages/studio/src/player/components/timelineEditing.ts
+++ b/packages/studio/src/player/components/timelineEditing.ts
@@ -5,6 +5,7 @@ import { resolveTimelineLayerStackingMove } from "./timelineLayerDrag";
import { shouldShowTimelineLayerGroupHeader } from "./TimelineLayerGroupHeader";
import type { TimelineStackingElement, TimelineStackingReorderIntent } from "./timelineStacking";
import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
+import type { BlockedTimelineEditIntent } from "./timelineBlockedEdits";
export {
getTimelineEditCapabilities,
@@ -254,7 +255,7 @@ export interface TimelinePromptElement {
track: number;
}
-export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
+export type { BlockedTimelineEditIntent } from "./timelineBlockedEdits";
export interface TimelineRangeSelection {
start: number;
diff --git a/packages/studio/src/player/components/timelineTheme.ts b/packages/studio/src/player/components/timelineTheme.ts
index b9d7f827bf..e0eea0413f 100644
--- a/packages/studio/src/player/components/timelineTheme.ts
+++ b/packages/studio/src/player/components/timelineTheme.ts
@@ -1,3 +1,4 @@
+import type { CSSProperties } from "react";
import type { TimelineElement } from "../store/playerStore";
export interface TimelineTrackStyle {
@@ -118,3 +119,15 @@ export function getRenderedTimelineElement({
track: previewTrack,
};
}
+
+/**
+ * Chrome for the timeline shell: the theme owns how the surface is painted, so
+ * the component only has to say which theme it is in.
+ */
+export function timelineShellStyle(theme: TimelineTheme): CSSProperties {
+ return {
+ touchAction: "pan-x pan-y",
+ background: theme.shellBackground,
+ borderColor: theme.shellBorder,
+ };
+}
diff --git a/packages/studio/src/player/components/timelineTrimOps.test.ts b/packages/studio/src/player/components/timelineTrimOps.test.ts
new file mode 100644
index 0000000000..7219032a77
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrimOps.test.ts
@@ -0,0 +1,221 @@
+import { describe, expect, it } from "vitest";
+import {
+ applyTrimDelta,
+ clampTrimDelta,
+ resolveTrimDeltaBounds,
+ resolveTrimPlan,
+ trimPlanKeys,
+ trimSnapAnchor,
+ type TrimClip,
+ type TrimPlan,
+} from "./timelineTrimOps";
+
+/** A, B, C butted together: 0-4, 4-6, 6-9. B and C carry source media. */
+const LANE: TrimClip[] = [
+ { key: "a", start: 0, duration: 4, playbackStart: 0, sourceDuration: 10 },
+ { key: "b", start: 4, duration: 2, playbackStart: 3, sourceDuration: 12, sourceWindow: true },
+ { key: "c", start: 6, duration: 3 },
+];
+
+const planOf = (
+ grabbed: string,
+ mode: Parameters[2],
+ edge: Parameters[3] = "end",
+ lane: TrimClip[] = LANE,
+): TrimPlan => {
+ const plan = resolveTrimPlan(lane, grabbed, mode, edge);
+ if (!plan) throw new Error(`expected a ${mode} plan for ${grabbed}`);
+ return plan;
+};
+
+const byKey = (changes: ReturnType) =>
+ Object.fromEntries(changes.map((c) => [c.key, c]));
+
+describe("resolveTrimPlan", () => {
+ it("returns null for a clip that is not on the lane", () => {
+ expect(resolveTrimPlan(LANE, "nope", "ripple", "end")).toBeNull();
+ });
+
+ it("collects only the clips after the grabbed one as ripple followers", () => {
+ const plan = planOf("b", "ripple");
+ expect(plan.mode === "ripple" && plan.followers.map((f) => f.key)).toEqual(["c"]);
+ });
+
+ it("refuses a roll with no clip across the edit point", () => {
+ expect(resolveTrimPlan(LANE, "c", "roll", "end")).toBeNull();
+ expect(resolveTrimPlan(LANE, "a", "roll", "start")).toBeNull();
+ });
+
+ it("pairs a roll with the neighbour on the grabbed side", () => {
+ const fromEnd = planOf("a", "roll", "end");
+ expect(fromEnd.mode === "roll" && [fromEnd.left.key, fromEnd.right.key]).toEqual(["a", "b"]);
+ const fromStart = planOf("b", "roll", "start");
+ expect(fromStart.mode === "roll" && [fromStart.left.key, fromStart.right.key]).toEqual([
+ "a",
+ "b",
+ ]);
+ });
+
+ it("refuses a slip on a clip with no source window", () => {
+ expect(resolveTrimPlan(LANE, "c", "slip", "end")).toBeNull();
+ });
+
+ it("only lets ADJACENT neighbours absorb a slide", () => {
+ const gapped: TrimClip[] = [
+ { key: "a", start: 0, duration: 4 },
+ { key: "b", start: 5, duration: 2 },
+ { key: "c", start: 7, duration: 3 },
+ ];
+ const plan = planOf("b", "slide", "end", gapped);
+ expect(plan.mode === "slide" && [plan.prev?.key ?? null, plan.next?.key ?? null]).toEqual([
+ null,
+ "c",
+ ]);
+ });
+});
+
+describe("ripple", () => {
+ it("extends the out point and pushes every later clip by the same amount", () => {
+ const changes = byKey(applyTrimDelta(planOf("b", "ripple", "end"), 1.5));
+ expect(changes.b).toMatchObject({ start: 4, duration: 3.5 });
+ expect(changes.c).toMatchObject({ start: 7.5, duration: 3 });
+ });
+
+ it("keeps the clip's START when the head is trimmed, and pulls the lane in", () => {
+ const changes = byKey(applyTrimDelta(planOf("b", "ripple", "start"), 0.5));
+ // Start pinned, duration and in point absorb the trim, lane closes behind it.
+ expect(changes.b).toMatchObject({ start: 4, duration: 1.5, playbackStart: 3.5 });
+ expect(changes.c).toMatchObject({ start: 5.5 });
+ });
+
+ it("leaves no gap or overlap on the lane for either edge", () => {
+ for (const [edge, delta] of [
+ ["end", 1.5],
+ ["end", -0.7],
+ ["start", 0.5],
+ ["start", -1],
+ ] as const) {
+ const plan = planOf("b", "ripple", edge);
+ const changes = byKey(applyTrimDelta(plan, clampTrimDelta(plan, delta)));
+ expect(changes.b.start + changes.b.duration).toBeCloseTo(changes.c.start, 6);
+ }
+ });
+
+ it("stops the out point at the end of the available source media", () => {
+ // b: in point 3 of a 12s source ⇒ 9s of media left, 2s already used.
+ expect(resolveTrimDeltaBounds(planOf("b", "ripple", "end")).maxDelta).toBeCloseTo(7, 6);
+ expect(clampTrimDelta(planOf("b", "ripple", "end"), 99)).toBeCloseTo(7, 6);
+ });
+
+ it("lets a source-free clip extend without limit", () => {
+ expect(resolveTrimDeltaBounds(planOf("c", "ripple", "end")).maxDelta).toBe(
+ Number.POSITIVE_INFINITY,
+ );
+ });
+
+ it("never shrinks a clip past the minimum duration", () => {
+ const plan = planOf("b", "ripple", "end");
+ const changes = byKey(applyTrimDelta(plan, clampTrimDelta(plan, -99)));
+ expect(changes.b.duration).toBeCloseTo(0.1, 6);
+ });
+
+ it("cannot rewind the in point past the start of the source media", () => {
+ // b's in point is 3s ⇒ 3s of head to reveal.
+ expect(clampTrimDelta(planOf("b", "ripple", "start"), -99)).toBeCloseTo(-3, 6);
+ });
+
+ it("scales the in-point shift by the clip's playback rate", () => {
+ const lane: TrimClip[] = [
+ {
+ key: "x",
+ start: 0,
+ duration: 4,
+ playbackStart: 4,
+ playbackRate: 2,
+ sourceDuration: 20,
+ sourceWindow: true,
+ },
+ ];
+ const changes = byKey(applyTrimDelta(planOf("x", "ripple", "start", lane), 1));
+ expect(changes.x).toMatchObject({ start: 0, duration: 3, playbackStart: 6 });
+ });
+});
+
+describe("roll", () => {
+ it("moves the edit point without moving anything downstream", () => {
+ const changes = byKey(applyTrimDelta(planOf("a", "roll", "end"), 1));
+ expect(changes.a).toMatchObject({ start: 0, duration: 5 });
+ expect(changes.b).toMatchObject({ start: 5, duration: 1, playbackStart: 4 });
+ expect(changes.a.start + changes.a.duration).toBeCloseTo(changes.b.start, 6);
+ expect(changes.b.start + changes.b.duration).toBeCloseTo(6, 6); // c never moves
+ });
+
+ it("is bounded by the outgoing clip's remaining media and the incoming clip's head", () => {
+ // a: 4s used of 10s ⇒ 6s of tail. b: 2s long, 0.1s floor ⇒ 1.9s it can give up.
+ expect(resolveTrimDeltaBounds(planOf("a", "roll", "end")).maxDelta).toBeCloseTo(1.9, 6);
+ // Rolling left: a must keep 0.1s; b has 3s of head to reveal.
+ expect(resolveTrimDeltaBounds(planOf("a", "roll", "end")).minDelta).toBeCloseTo(-3, 6);
+ });
+});
+
+describe("slip", () => {
+ it("moves the source window only — position and duration are untouched", () => {
+ const changes = byKey(applyTrimDelta(planOf("b", "slip"), 1));
+ expect(changes.b).toEqual({ key: "b", start: 4, duration: 2, playbackStart: 2 });
+ });
+
+ it("is bounded by both ends of the source media", () => {
+ // b: in point 3 (3s of head), 12s source with 2s used from 3 ⇒ 7s of tail.
+ const bounds = resolveTrimDeltaBounds(planOf("b", "slip"));
+ expect(bounds.maxDelta).toBeCloseTo(3, 6);
+ expect(bounds.minDelta).toBeCloseTo(-7, 6);
+ });
+});
+
+describe("slide", () => {
+ it("moves the clip while the neighbours absorb it", () => {
+ const changes = byKey(applyTrimDelta(planOf("b", "slide"), 1));
+ expect(changes.a).toMatchObject({ start: 0, duration: 5 });
+ expect(changes.b).toMatchObject({ start: 5, duration: 2 });
+ expect(changes.c).toMatchObject({ start: 7, duration: 2 });
+ // Total lane length is unchanged and the lane stays butted.
+ expect(changes.a.start + changes.a.duration).toBeCloseTo(changes.b.start, 6);
+ expect(changes.b.start + changes.b.duration).toBeCloseTo(changes.c.start, 6);
+ expect(changes.c.start + changes.c.duration).toBeCloseTo(9, 6);
+ });
+
+ it("is bounded by the previous clip's media and the next clip's minimum duration", () => {
+ const bounds = resolveTrimDeltaBounds(planOf("b", "slide"));
+ expect(bounds.maxDelta).toBeCloseTo(2.9, 6); // c: 3s − 0.1s floor
+ expect(bounds.minDelta).toBeCloseTo(-3.9, 6); // a: 4s − 0.1s floor
+ });
+
+ it("keeps a clip with no previous neighbour at or after the lane floor", () => {
+ expect(clampTrimDelta(planOf("a", "slide"), -99)).toBe(0);
+ });
+});
+
+describe("gesture plumbing helpers", () => {
+ it("reports zero delta rather than inverting an exhausted clamp", () => {
+ const lane: TrimClip[] = [{ key: "tiny", start: 0, duration: 0.05 }];
+ expect(clampTrimDelta(planOf("tiny", "ripple", "end", lane), 5)).toBeCloseTo(5, 6);
+ expect(clampTrimDelta(planOf("tiny", "ripple", "end", lane), -5)).toBeCloseTo(0.05, 6);
+ });
+
+ it("produces no changes at all for a zero delta", () => {
+ expect(applyTrimDelta(planOf("b", "ripple", "end"), 0)).toEqual([]);
+ });
+
+ it("anchors the snap on the edge that actually moves", () => {
+ expect(trimSnapAnchor(planOf("b", "ripple", "end"))).toEqual({ time: 6, sign: 1 });
+ expect(trimSnapAnchor(planOf("b", "ripple", "start"))).toEqual({ time: 6, sign: -1 });
+ expect(trimSnapAnchor(planOf("a", "roll", "end"))).toEqual({ time: 4, sign: 1 });
+ expect(trimSnapAnchor(planOf("b", "slide"))).toEqual({ time: 4, sign: 1 });
+ expect(trimSnapAnchor(planOf("b", "slip"))).toBeNull();
+ });
+
+ it("lists every clip a plan may rewrite so the snap pass can ignore them", () => {
+ expect([...trimPlanKeys(planOf("b", "ripple", "end"))].sort()).toEqual(["b", "c"]);
+ expect([...trimPlanKeys(planOf("b", "slide"))].sort()).toEqual(["a", "b", "c"]);
+ });
+});
diff --git a/packages/studio/src/player/components/timelineTrimOps.ts b/packages/studio/src/player/components/timelineTrimOps.ts
new file mode 100644
index 0000000000..7282c6baf2
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrimOps.ts
@@ -0,0 +1,351 @@
+import { roundToCenti } from "../../utils/rounding";
+import { resolveTimelineMinDuration } from "./timelineGroupEditing";
+
+/**
+ * Pure math for the four NLE trim operations (ripple / roll / slip / slide).
+ *
+ * All four are LANE-SCOPED: they read and rewrite only the clips on the grabbed
+ * clip's own display lane, matching the existing lane-scoped gap tooling
+ * (see timelineGaps.ts). Cross-lane sync-lock is deliberately out of scope.
+ *
+ * Shape of the module: `resolveTrimPlan` decides WHICH clips an operation
+ * touches (returning null when the gesture is impossible — e.g. a roll with no
+ * neighbour across the edit point), `resolveTrimDeltaBounds` says how far the
+ * gesture may travel, and `applyTrimDelta` produces the per-clip timing patches.
+ * Keeping the three separate is what lets the preview clamp live and the commit
+ * reuse the very same numbers.
+ *
+ * Conventions (verified against Final Cut Pro / Premiere Pro semantics):
+ * - Ripple: the trimmed clip keeps its START; its duration changes and every
+ * later clip on the lane shifts by the same amount, so the lane never gains a
+ * gap or an overlap and the composition gets longer/shorter by the trim.
+ * - Roll: the shared edit point between two adjacent clips moves; one grows by
+ * exactly what the other loses, so nothing downstream moves.
+ * - Slip: the clip's source in/out move together; its position and duration on
+ * the lane are untouched, so nothing else moves.
+ * - Slide: the clip moves in time; the previous clip's out point and the next
+ * clip's in point absorb the move, so nothing downstream moves.
+ */
+
+export type TimelineTrimMode = "ripple" | "roll" | "slip" | "slide";
+export type TimelineTrimEdge = "start" | "end";
+
+/** Adjacency tolerance, in seconds — mirrors timelineGaps' epsilon. */
+const TRIM_ADJACENCY_EPSILON_S = 1e-3;
+
+/** The minimal timing view of a clip the trim math needs. */
+export interface TrimClip {
+ key: string;
+ start: number;
+ duration: number;
+ playbackStart?: number;
+ playbackRate?: number;
+ sourceDuration?: number;
+ /**
+ * Whether the clip samples a source that can be re-pointed (media, or a
+ * sub-composition). False for generated pixels — text, shapes, plain
+ * elements: they have no in point, so slipping one is meaningless and
+ * writing an in point onto one is noise. The store reports `playbackStart: 0`
+ * for every element regardless, which is why this cannot be inferred here.
+ */
+ sourceWindow?: boolean;
+}
+
+export type TrimPlan =
+ | { mode: "ripple"; edge: TimelineTrimEdge; grabbed: TrimClip; followers: TrimClip[] }
+ | { mode: "roll"; left: TrimClip; right: TrimClip }
+ | { mode: "slip"; grabbed: TrimClip }
+ | { mode: "slide"; grabbed: TrimClip; prev: TrimClip | null; next: TrimClip | null };
+
+/**
+ * One clip's post-trim timing. An absent `playbackStart` means "leave this
+ * clip's in point exactly as it is" — the persist then writes no in-point
+ * attribute at all, which is what keeps a rippled neighbour indistinguishable
+ * from the same clip moved by hand.
+ */
+export interface TrimChange {
+ key: string;
+ start: number;
+ duration: number;
+ playbackStart?: number;
+}
+
+export interface TrimDeltaBounds {
+ minDelta: number;
+ maxDelta: number;
+}
+
+const rateOf = (clip: TrimClip): number => Math.max(0.1, clip.playbackRate ?? 1);
+const endOf = (clip: TrimClip): number => clip.start + clip.duration;
+
+/**
+ * How much LATER the clip's out point may be pushed before it runs out of source
+ * media, in timeline seconds. Infinite for clips with no source (text, shapes).
+ */
+function outPointHeadroom(clip: TrimClip): number {
+ if (clip.sourceDuration == null || !Number.isFinite(clip.sourceDuration)) {
+ return Number.POSITIVE_INFINITY;
+ }
+ return (clip.sourceDuration - (clip.playbackStart ?? 0)) / rateOf(clip) - clip.duration;
+}
+
+/**
+ * How much EARLIER the clip's in point may be pulled before it runs out of
+ * source media, in timeline seconds. Infinite for clips with no in point at all
+ * (`playbackStart` undefined ⇒ nothing to rewind).
+ */
+function inPointHeadroom(clip: TrimClip): number {
+ return clip.playbackStart != null ? clip.playbackStart / rateOf(clip) : Number.POSITIVE_INFINITY;
+}
+
+/** Lane clips sorted by start; key breaks ties so the order is deterministic. */
+function sortedLane(lane: readonly TrimClip[]): TrimClip[] {
+ return [...lane].sort((a, b) => a.start - b.start || a.key.localeCompare(b.key));
+}
+
+/**
+ * Resolve which clips a trim gesture touches, or null when the gesture cannot
+ * run: a roll with no clip across the edit point, a slip on a clip with no
+ * source window, or a grabbed key that is not on the lane.
+ */
+export function resolveTrimPlan(
+ lane: readonly TrimClip[],
+ grabbedKey: string,
+ mode: TimelineTrimMode,
+ edge: TimelineTrimEdge,
+ epsilon: number = TRIM_ADJACENCY_EPSILON_S,
+): TrimPlan | null {
+ const clips = sortedLane(lane);
+ const index = clips.findIndex((c) => c.key === grabbedKey);
+ if (index < 0) return null;
+ const grabbed = clips[index]!;
+
+ if (mode === "ripple") {
+ // Everything that starts at or after the grabbed clip's out point rides the
+ // trim. A clip that merely overlaps (spill lane) is left alone: shifting it
+ // would change an overlap the author chose.
+ const followers = clips.filter((c) => c.start >= endOf(grabbed) - epsilon);
+ return { mode, edge, grabbed, followers };
+ }
+
+ if (mode === "slip") {
+ // Slipping moves the source window; generated pixels have no window to move.
+ if (!grabbed.sourceWindow) return null;
+ return { mode, grabbed };
+ }
+
+ const { prev, next } = buttedNeighbours(clips, index, epsilon);
+
+ if (mode === "roll") {
+ // The edit point is the grabbed EDGE; rolling needs a clip butted against it.
+ const pair = edge === "start" ? { left: prev, right: grabbed } : { left: grabbed, right: next };
+ return pair.left && pair.right ? { mode, left: pair.left, right: pair.right } : null;
+ }
+
+ // Slide: only ADJACENT neighbours absorb the move. A neighbour separated by a
+ // gap is not rewritten — the gap absorbs the slide instead, which is what the
+ // author sees and the least surprising thing to do.
+ return { mode, grabbed, prev, next };
+}
+
+/**
+ * The lane neighbours BUTTED against the clip at `index` — null when a gap
+ * separates them, because a gap means there is no shared edit point to move.
+ */
+function buttedNeighbours(
+ clips: readonly TrimClip[],
+ index: number,
+ epsilon: number,
+): { prev: TrimClip | null; next: TrimClip | null } {
+ const clip = clips[index]!;
+ const before = clips[index - 1];
+ const after = clips[index + 1];
+ return {
+ prev: before && Math.abs(endOf(before) - clip.start) <= epsilon ? before : null,
+ next: after && Math.abs(after.start - endOf(clip)) <= epsilon ? after : null,
+ };
+}
+
+/** The delta range the plan can absorb, before rounding. Unbounded ⇒ ±Infinity. */
+export function resolveTrimDeltaBounds(
+ plan: TrimPlan,
+ minDuration: number = resolveTimelineMinDuration(),
+ laneFloor = 0,
+): TrimDeltaBounds {
+ switch (plan.mode) {
+ case "ripple": {
+ const { grabbed, followers, edge } = plan;
+ // Followers ride the trim; none of them may be pushed before the floor.
+ const followerSlack = followers.length
+ ? Math.min(...followers.map((f) => f.start)) - laneFloor
+ : Number.POSITIVE_INFINITY;
+ if (edge === "end") {
+ return {
+ minDelta: Math.max(minDuration - grabbed.duration, -followerSlack),
+ maxDelta: outPointHeadroom(grabbed),
+ };
+ }
+ // Start edge: +delta trims the head (shorter clip, lane pulls left).
+ return {
+ minDelta: -inPointHeadroom(grabbed),
+ maxDelta: Math.min(grabbed.duration - minDuration, followerSlack),
+ };
+ }
+ case "roll": {
+ const { left, right } = plan;
+ return {
+ minDelta: Math.max(minDuration - left.duration, -inPointHeadroom(right)),
+ maxDelta: Math.min(outPointHeadroom(left), right.duration - minDuration),
+ };
+ }
+ case "slip": {
+ // +delta drags the source strip right ⇒ EARLIER material ⇒ in point falls.
+ const { grabbed } = plan;
+ return { minDelta: -outPointHeadroom(grabbed), maxDelta: inPointHeadroom(grabbed) };
+ }
+ case "slide": {
+ const { grabbed, prev, next } = plan;
+ const minDelta = Math.max(
+ prev ? minDuration - prev.duration : laneFloor - grabbed.start,
+ next ? -inPointHeadroom(next) : Number.NEGATIVE_INFINITY,
+ );
+ const maxDelta = Math.min(
+ prev ? outPointHeadroom(prev) : Number.POSITIVE_INFINITY,
+ next ? next.duration - minDuration : Number.POSITIVE_INFINITY,
+ );
+ return { minDelta, maxDelta };
+ }
+ }
+}
+
+/** Clamp a raw pointer delta into the plan's bounds and round it to centiseconds. */
+export function clampTrimDelta(
+ plan: TrimPlan,
+ rawDelta: number,
+ minDuration: number = resolveTimelineMinDuration(),
+ laneFloor = 0,
+): number {
+ const { minDelta, maxDelta } = resolveTrimDeltaBounds(plan, minDuration, laneFloor);
+ // An exhausted plan (maxDelta < minDelta, e.g. a clip already below minDuration)
+ // must not invert the clamp into a forced move — freeze it instead.
+ if (maxDelta < minDelta) return 0;
+ return roundToCenti(Math.min(Math.max(rawDelta, minDelta), maxDelta));
+}
+
+/**
+ * A clip trimmed at its head: the start and duration absorb the delta, and the
+ * in point follows it — but only on a clip that HAS an in point. On generated
+ * pixels there is no source to re-point, so the change carries no
+ * `playbackStart` and the persist leaves the attribute off the element.
+ */
+function trimmedHead(clip: TrimClip, delta: number): TrimChange {
+ return {
+ key: clip.key,
+ start: roundToCenti(clip.start + delta),
+ duration: roundToCenti(clip.duration - delta),
+ playbackStart: clip.sourceWindow
+ ? roundToCenti(Math.max(0, (clip.playbackStart ?? 0) + delta * rateOf(clip)))
+ : undefined,
+ };
+}
+
+/** A clip trimmed at its out point: its in point is untouched, so it is absent. */
+const trimmedTail = (clip: TrimClip, delta: number): TrimChange => ({
+ key: clip.key,
+ start: roundToCenti(clip.start),
+ duration: roundToCenti(clip.duration + delta),
+});
+
+/**
+ * A clip that only travels. `playbackStart` is deliberately absent, not copied:
+ * these clips are being MOVED, and the move path never writes an in point.
+ * Emitting the store's default 0 here would stamp `data-playback-start` onto
+ * clips a plain drag leaves alone.
+ */
+const shifted = (clip: TrimClip, delta: number): TrimChange => ({
+ key: clip.key,
+ start: roundToCenti(clip.start + delta),
+ duration: roundToCenti(clip.duration),
+});
+
+/**
+ * The per-clip timing patches a plan produces at `delta` (already clamped by
+ * {@link clampTrimDelta}). Only clips whose timing actually moves are returned.
+ */
+export function applyTrimDelta(plan: TrimPlan, delta: number): TrimChange[] {
+ if (delta === 0) return [];
+ switch (plan.mode) {
+ case "ripple": {
+ const { grabbed, followers, edge } = plan;
+ // Head trim keeps the clip's START (that is what makes it a ripple rather
+ // than a plain trim: the lane closes behind the edit, it does not gap).
+ const trimmed: TrimChange =
+ edge === "end"
+ ? trimmedTail(grabbed, delta)
+ : { ...trimmedHead(grabbed, delta), start: roundToCenti(grabbed.start) };
+ const laneShift = edge === "end" ? delta : -delta;
+ return [trimmed, ...followers.map((f) => shifted(f, laneShift))];
+ }
+ case "roll":
+ return [trimmedTail(plan.left, delta), trimmedHead(plan.right, delta)];
+ case "slip": {
+ const { grabbed } = plan;
+ return [
+ {
+ key: grabbed.key,
+ start: roundToCenti(grabbed.start),
+ duration: roundToCenti(grabbed.duration),
+ playbackStart: roundToCenti(
+ Math.max(0, (grabbed.playbackStart ?? 0) - delta * rateOf(grabbed)),
+ ),
+ },
+ ];
+ }
+ case "slide": {
+ const { grabbed, prev, next } = plan;
+ const changes: TrimChange[] = [shifted(grabbed, delta)];
+ if (prev) changes.unshift(trimmedTail(prev, delta));
+ if (next) changes.push(trimmedHead(next, delta));
+ return changes;
+ }
+ }
+}
+
+/**
+ * The lane-space edge the gesture actually moves, so the snap pass has one thing
+ * to land on the grid: `edgeTime = time + sign * delta`, and inversely
+ * `delta = sign * (snappedEdgeTime - time)`.
+ *
+ * For a head ripple the grabbed (left) edge does NOT move — the clip's OUT point
+ * and everything after it does — so that is what snaps, with an inverted sign.
+ * Slip returns null: it moves the source window, not a lane edge, so there is
+ * nothing on the timeline grid for it to snap to.
+ */
+export function trimSnapAnchor(plan: TrimPlan): { time: number; sign: 1 | -1 } | null {
+ switch (plan.mode) {
+ case "ripple":
+ return { time: endOf(plan.grabbed), sign: plan.edge === "end" ? 1 : -1 };
+ case "roll":
+ return { time: endOf(plan.left), sign: 1 };
+ case "slip":
+ return null;
+ case "slide":
+ return { time: plan.grabbed.start, sign: 1 };
+ }
+}
+
+/** Every clip key a plan may rewrite — the set the snap pass must ignore. */
+export function trimPlanKeys(plan: TrimPlan): Set {
+ switch (plan.mode) {
+ case "ripple":
+ return new Set([plan.grabbed.key, ...plan.followers.map((f) => f.key)]);
+ case "roll":
+ return new Set([plan.left.key, plan.right.key]);
+ case "slip":
+ return new Set([plan.grabbed.key]);
+ case "slide":
+ return new Set(
+ [plan.grabbed.key, plan.prev?.key, plan.next?.key].filter((k): k is string => k != null),
+ );
+ }
+}
diff --git a/packages/studio/src/player/components/timelineTrimSession.test.ts b/packages/studio/src/player/components/timelineTrimSession.test.ts
new file mode 100644
index 0000000000..b429dfbc08
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrimSession.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import {
+ applyTimelineTrimPreview,
+ buildTimelineTrimSession,
+ canStartTimelineTrim,
+} from "./timelineTrimSession";
+
+function el(id: string, over: Partial = {}): TimelineElement {
+ return { id, key: id, tag: "video", start: 0, duration: 2, track: 0, domId: id, ...over };
+}
+
+/** a(0-2) and b(2-3) butted on track 0; c(0-4) sits alone on track 1. */
+const A = el("a", { start: 0, duration: 2 });
+const B = el("b", { start: 2, duration: 3, playbackStart: 1, sourceDuration: 20 });
+const C = el("c", { start: 0, duration: 4, track: 1 });
+const ELEMENTS = [A, B, C];
+
+const NO_SNAP = { playheadTime: null, beatTimes: [], snapEnabled: false };
+
+describe("canStartTimelineTrim", () => {
+ it("accepts a ripple on any editable clip", () => {
+ expect(canStartTimelineTrim(A, "ripple", "end", ELEMENTS)).toBe(true);
+ });
+
+ it("refuses a roll into a lane that has no clip across the cut", () => {
+ expect(canStartTimelineTrim(A, "roll", "end", ELEMENTS)).toBe(true);
+ expect(canStartTimelineTrim(B, "roll", "end", ELEMENTS)).toBe(false);
+ // c is alone on its own lane — no edit point on either side.
+ expect(canStartTimelineTrim(C, "roll", "start", ELEMENTS)).toBe(false);
+ });
+
+ it("refuses a slip on generated pixels, which have no source to re-point", () => {
+ // The store reports playbackStart: 0 for a div too, so the refusal has to
+ // come from the element's kind — not from the number being absent.
+ const div = el("text", { tag: "div", start: 0, duration: 4, playbackStart: 0, track: 2 });
+ expect(div.playbackStart).toBe(0);
+ expect(canStartTimelineTrim(div, "slip", "end", [div])).toBe(false);
+ expect(canStartTimelineTrim(B, "slip", "end", ELEMENTS)).toBe(true);
+ });
+
+ it("refuses when a clip the operation would rewrite is locked", () => {
+ const locked = [A, { ...B, timelineLocked: true }];
+ // The ripple would have to shift b, and b cannot be moved.
+ expect(canStartTimelineTrim(A, "ripple", "end", locked)).toBe(false);
+ // Trimming b's own edge is likewise refused.
+ expect(canStartTimelineTrim(A, "roll", "end", locked)).toBe(false);
+ });
+
+ it("only considers clips on the grabbed clip's own lane", () => {
+ // c is at 0-4 on lane 1 and overlaps both lane-0 clips in time; it must not
+ // become a ripple follower or a roll partner.
+ const session = buildTimelineTrimSession(A, "ripple", "end", {
+ elements: ELEMENTS,
+ ...NO_SNAP,
+ });
+ expect(session?.members.map((m) => m.key)).toEqual(["a", "b"]);
+ });
+});
+
+describe("applyTimelineTrimPreview", () => {
+ const session = () =>
+ buildTimelineTrimSession(A, "ripple", "end", { elements: ELEMENTS, ...NO_SNAP })!;
+
+ it("projects the grabbed change and every follower", () => {
+ const s = session();
+ const grabbed = applyTimelineTrimPreview(s, 0.5, 100);
+ expect(grabbed).toMatchObject({ key: "a", duration: 2.5 });
+ expect(s.changes.map((c) => [c.key, c.start])).toEqual([
+ ["a", 0],
+ ["b", 2.5],
+ ]);
+ expect(s.hasChanged).toBe(true);
+ });
+
+ it("reports no change for a delta that moves nothing", () => {
+ const s = session();
+ expect(applyTimelineTrimPreview(s, 0, 100)).toBeUndefined();
+ expect(s.hasChanged).toBe(false);
+ });
+
+ it("carries the live element on every change so the commit can persist it", () => {
+ const s = session();
+ applyTimelineTrimPreview(s, 0.5, 100);
+ expect(s.changes.map((c) => c.element.id)).toEqual(["a", "b"]);
+ });
+
+ it("leaves the in point off a clip that only moves, exactly as a drag would", () => {
+ const s = session();
+ applyTimelineTrimPreview(s, 0.5, 100);
+ // b only travels: writing an in point here would stamp an attribute that a
+ // plain drag of the same clip never writes.
+ expect(s.changes.find((c) => c.key === "b")?.playbackStart).toBeUndefined();
+ // a is trimmed at its out point — its in point is untouched too.
+ expect(s.changes.find((c) => c.key === "a")?.playbackStart).toBeUndefined();
+ });
+
+ it("snaps the moving edge to the playhead, ignoring the clips that ride along", () => {
+ // Playhead at 2.6s; a's out point starts at 2 and the drag asks for +0.4.
+ const s = buildTimelineTrimSession(A, "ripple", "end", {
+ elements: ELEMENTS,
+ playheadTime: 2.6,
+ beatTimes: [],
+ snapEnabled: true,
+ })!;
+ // b's own edges (2 and 5) are excluded — they move with the trim.
+ expect(s.trim.snapTargets.map((t) => t.time)).toEqual([0, 2.6, 4]);
+ const grabbed = applyTimelineTrimPreview(s, 0.55, 100);
+ expect(grabbed?.duration).toBe(2.6);
+ });
+});
diff --git a/packages/studio/src/player/components/timelineTrimSession.ts b/packages/studio/src/player/components/timelineTrimSession.ts
new file mode 100644
index 0000000000..be9211857f
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrimSession.ts
@@ -0,0 +1,295 @@
+import type { TimelineElement } from "../store/playerStore";
+import { getTimelineEditCapabilities } from "./timelineEditCapabilities";
+import { laneGapFloor } from "./timelineGaps";
+import {
+ resolveTimelineMinDuration,
+ type TimelineGroupResizeChange,
+ type TimelineGroupResizeMember,
+ type TimelineGroupResizeSession,
+} from "./timelineGroupEditing";
+import {
+ collectTimelineSnapTargets,
+ snapTimelineTime,
+ TIMELINE_SNAP_PX,
+ type TimelineSnapTarget,
+} from "./timelineSnapping";
+import { isMusicTrack } from "../../utils/timelineInspector";
+import {
+ applyTrimDelta,
+ clampTrimDelta,
+ resolveTrimPlan,
+ trimPlanKeys,
+ trimSnapAnchor,
+ type TimelineTrimEdge,
+ type TimelineTrimMode,
+ type TrimClip,
+ type TrimPlan,
+} from "./timelineTrimOps";
+
+/**
+ * Gesture layer for the four trim tools: turns store elements into the pure
+ * plan {@link timelineTrimOps} works on, and a pointer x into per-clip changes.
+ *
+ * A trim session IS a {@link TimelineGroupResizeSession} — it carries the same
+ * members / changes / hasChanged triple — so the whole downstream pipeline
+ * (projection rendering, escape-cancel, atomic commit through
+ * `commitTimelineGroupResize`) is reused verbatim. Only the preview math
+ * differs, and that is what the extra `trim` field selects.
+ */
+
+export interface TimelineTrimSession extends TimelineGroupResizeSession {
+ trim: {
+ mode: TimelineTrimMode;
+ plan: TrimPlan;
+ laneFloor: number;
+ /**
+ * Snap targets frozen at gesture start, with every clip the plan may rewrite
+ * removed: those clips ride the trim, so snapping the moving edge onto one of
+ * them would be snapping to itself.
+ */
+ snapTargets: TimelineSnapTarget[];
+ };
+}
+
+function isTimelineTrimSession(
+ session: TimelineGroupResizeSession | null,
+): session is TimelineTrimSession {
+ return session != null && "trim" in session;
+}
+
+const keyOf = (element: TimelineElement): string => element.key ?? element.id;
+
+/**
+ * Whether the clip samples a re-pointable source. The store reports
+ * `playbackStart: 0` for every element (the runtime manifest defaults it), so
+ * an in point cannot be inferred from its presence — this is the one place that
+ * decides it, from the element's kind and its media metadata.
+ */
+function hasSourceWindow(element: TimelineElement): boolean {
+ if (element.kind === "composition" || element.compositionSrc) return true;
+ if (element.playbackStartAttr != null) return true;
+ if (element.sourceDuration != null && Number.isFinite(element.sourceDuration)) return true;
+ return ["video", "audio"].includes(element.tag.toLowerCase());
+}
+
+const toTrimClip = (element: TimelineElement): TrimClip => ({
+ key: keyOf(element),
+ start: element.start,
+ duration: element.duration,
+ playbackStart: element.playbackStart,
+ playbackRate: element.playbackRate,
+ sourceDuration: element.sourceDuration,
+ sourceWindow: hasSourceWindow(element),
+});
+
+/** What a plan asks of each clip it touches: a pure move, or a retime of an edge. */
+type TrimRequirement = "move" | "trim-start" | "trim-end" | "trim-both";
+
+function trimRequirements(plan: TrimPlan): Map {
+ const required = new Map();
+ switch (plan.mode) {
+ case "ripple":
+ required.set(plan.grabbed.key, plan.edge === "start" ? "trim-start" : "trim-end");
+ for (const follower of plan.followers) required.set(follower.key, "move");
+ break;
+ case "roll":
+ required.set(plan.left.key, "trim-end");
+ required.set(plan.right.key, "trim-start");
+ break;
+ case "slip":
+ // The in AND out points both move, even though neither lane edge does.
+ required.set(plan.grabbed.key, "trim-both");
+ break;
+ case "slide":
+ required.set(plan.grabbed.key, "move");
+ if (plan.prev) required.set(plan.prev.key, "trim-end");
+ if (plan.next) required.set(plan.next.key, "trim-start");
+ break;
+ }
+ return required;
+}
+
+function satisfiesRequirement(element: TimelineElement, requirement: TrimRequirement): boolean {
+ const caps = getTimelineEditCapabilities(element);
+ switch (requirement) {
+ case "move":
+ return caps.canMove;
+ case "trim-start":
+ return caps.canTrimStart;
+ case "trim-end":
+ return caps.canTrimEnd;
+ case "trim-both":
+ return caps.canTrimStart && caps.canTrimEnd;
+ }
+}
+
+interface TrimSessionShape {
+ plan: TrimPlan;
+ members: TimelineGroupResizeMember[];
+ laneFloor: number;
+}
+
+/**
+ * The clips a trim gesture would rewrite, or null when it cannot run: the
+ * operation has no valid shape on this lane (see `resolveTrimPlan`), or one of
+ * the clips it would rewrite is locked / implicitly timed. Refusal is
+ * all-or-nothing — a trim never half-applies and leaves the lane inconsistent.
+ */
+function resolveTrimSessionShape(
+ grabbed: TimelineElement,
+ mode: TimelineTrimMode,
+ edge: TimelineTrimEdge,
+ elements: readonly TimelineElement[],
+): TrimSessionShape | null {
+ const laneElements = elements.filter((element) => element.track === grabbed.track);
+ const plan = resolveTrimPlan(laneElements.map(toTrimClip), keyOf(grabbed), mode, edge);
+ if (!plan) return null;
+
+ const required = trimRequirements(plan);
+ const members: TimelineGroupResizeMember[] = [];
+ for (const element of laneElements) {
+ const requirement = required.get(keyOf(element));
+ if (!requirement) continue;
+ if (!satisfiesRequirement(element, requirement)) return null;
+ members.push({
+ element,
+ key: keyOf(element),
+ start: element.start,
+ duration: element.duration,
+ playbackStart: element.playbackStart,
+ playbackRate: element.playbackRate,
+ });
+ }
+ return { plan, members, laneFloor: laneGapFloor(laneElements) };
+}
+
+/**
+ * Whether the tool can act on this clip at all — read at pointerdown so a
+ * refused gesture (a roll with nothing across the cut, a slip on generated
+ * pixels) never starts and reports itself instead of silently doing nothing.
+ */
+export function canStartTimelineTrim(
+ grabbed: TimelineElement,
+ mode: TimelineTrimMode,
+ edge: TimelineTrimEdge,
+ elements: readonly TimelineElement[],
+): boolean {
+ return resolveTrimSessionShape(grabbed, mode, edge, elements) != null;
+}
+
+export interface TrimSessionContext {
+ elements: readonly TimelineElement[];
+ playheadTime: number | null;
+ beatTimes: readonly number[];
+ snapEnabled: boolean;
+}
+
+/**
+ * Reuse the in-flight session when it still describes this gesture, otherwise
+ * open a fresh one. Sessions are opened lazily on the first pointer movement,
+ * so the identity check is what keeps a second gesture from inheriting the
+ * first one's frozen plan and snap grid.
+ */
+export function reuseOrOpenTrimSession(
+ current: TimelineGroupResizeSession | null,
+ grabbed: TimelineElement,
+ mode: TimelineTrimMode,
+ edge: TimelineTrimEdge,
+ ctx: TrimSessionContext,
+): TimelineTrimSession | null {
+ const describesThisGesture =
+ isTimelineTrimSession(current) &&
+ current.grabbedKey === keyOf(grabbed) &&
+ current.edge === edge &&
+ current.trim.mode === mode;
+ return describesThisGesture
+ ? (current as TimelineTrimSession)
+ : buildTimelineTrimSession(grabbed, mode, edge, ctx);
+}
+
+/** Open a trim session, freezing its snap grid for the whole gesture. */
+export function buildTimelineTrimSession(
+ grabbed: TimelineElement,
+ mode: TimelineTrimMode,
+ edge: TimelineTrimEdge,
+ ctx: TrimSessionContext,
+): TimelineTrimSession | null {
+ const shape = resolveTrimSessionShape(grabbed, mode, edge, ctx.elements);
+ if (!shape) return null;
+
+ const planKeys = trimPlanKeys(shape.plan);
+ return {
+ grabbedKey: keyOf(grabbed),
+ edge,
+ members: shape.members,
+ changes: [],
+ hasChanged: false,
+ trim: {
+ mode,
+ plan: shape.plan,
+ laneFloor: shape.laneFloor,
+ snapTargets: ctx.snapEnabled
+ ? collectTimelineSnapTargets({
+ elements: ctx.elements.filter((element) => !planKeys.has(keyOf(element))),
+ playheadTime: ctx.playheadTime,
+ // The music track defines the beats, so it must not snap to them.
+ beatTimes: isMusicTrack(grabbed) ? [] : ctx.beatTimes,
+ })
+ : [],
+ },
+ };
+}
+
+/**
+ * Snap the gesture's moving edge to the frozen target grid and report the delta
+ * that lands it there. Slip has no lane edge to snap (see `trimSnapAnchor`), so
+ * its delta passes through untouched.
+ */
+function snapTrimDelta(session: TimelineTrimSession, rawDelta: number, pps: number): number {
+ const anchor = trimSnapAnchor(session.trim.plan);
+ const { snapTargets } = session.trim;
+ if (!anchor || snapTargets.length === 0) return rawDelta;
+ const snapped = snapTimelineTime(
+ anchor.time + anchor.sign * rawDelta,
+ snapTargets,
+ TIMELINE_SNAP_PX / Math.max(pps, 1),
+ );
+ return snapped.target ? anchor.sign * (snapped.time - anchor.time) : rawDelta;
+}
+
+/**
+ * Fold a pointer delta (in seconds) into the session and return the grabbed
+ * clip's own change, so the caller can render it from the resize state exactly
+ * as the group-resize path does. Mutates `changes` / `hasChanged` in place.
+ */
+export function applyTimelineTrimPreview(
+ session: TimelineTrimSession,
+ rawDeltaSeconds: number,
+ pps: number,
+): TimelineGroupResizeChange | undefined {
+ const { plan, laneFloor } = session.trim;
+ const delta = clampTrimDelta(
+ plan,
+ snapTrimDelta(session, rawDeltaSeconds, pps),
+ resolveTimelineMinDuration(),
+ laneFloor,
+ );
+ const byKey = new Map(session.members.map((member) => [member.key, member]));
+ session.changes = applyTrimDelta(plan, delta).flatMap((change) => {
+ const member = byKey.get(change.key);
+ // A plan only ever names clips the builder snapshotted; the guard keeps a
+ // future plan shape from persisting a change with no element behind it.
+ return member ? [{ ...change, element: member.element }] : [];
+ });
+ session.hasChanged = session.changes.some((change) => {
+ const member = byKey.get(change.key)!;
+ // An absent playbackStart means "unchanged" (see TrimChange), so it is
+ // compared only when the change actually carries one.
+ return (
+ change.start !== member.start ||
+ change.duration !== member.duration ||
+ (change.playbackStart != null && change.playbackStart !== member.playbackStart)
+ );
+ });
+ return session.changes.find((change) => change.key === session.grabbedKey);
+}
diff --git a/packages/studio/src/player/components/timelineTrimTools.ts b/packages/studio/src/player/components/timelineTrimTools.ts
new file mode 100644
index 0000000000..3a5cecca69
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrimTools.ts
@@ -0,0 +1,78 @@
+import type { TimelineTrimMode } from "./timelineTrimOps";
+
+/**
+ * The tools the timeline surface can be in. Select and razor predate the trim
+ * tools; the four trim modes join the same union so exactly one tool is active
+ * at a time and the store needs no second flag.
+ */
+export type TimelineTool = "select" | "razor" | TimelineTrimMode;
+
+/** The trim operation a tool selection implies, or null for select / razor. */
+export function activeTrimMode(tool: TimelineTool): TimelineTrimMode | null {
+ return tool === "select" || tool === "razor" ? null : tool;
+}
+
+/**
+ * One place that names the trim tools: the toolbar buttons, the keyboard
+ * handler and the shortcuts panel all read this, so a relabelled tool or a
+ * rebound key can never disagree with itself across three surfaces.
+ */
+export interface TimelineTrimToolSpec {
+ mode: TimelineTrimMode;
+ label: string;
+ /** Display form of the shortcut, e.g. "⇧T". */
+ shortcut: string;
+ /** One line on what the tool does to the lane, shown in the tooltip. */
+ hint: string;
+}
+
+export const TIMELINE_TRIM_TOOLS: readonly TimelineTrimToolSpec[] = [
+ {
+ mode: "ripple",
+ label: "Ripple trim",
+ shortcut: "T",
+ hint: "Drag a clip edge. Every later clip on the track follows, so no gap opens.",
+ },
+ {
+ mode: "roll",
+ label: "Roll edit",
+ shortcut: "⇧T",
+ hint: "Drag the cut between two clips. One grows by exactly what the other gives up.",
+ },
+ {
+ mode: "slip",
+ label: "Slip",
+ shortcut: "Y",
+ hint: "Drag inside a clip to slide the media behind it. The clip stays put, a different part of the source plays.",
+ },
+ {
+ mode: "slide",
+ label: "Slide",
+ shortcut: "⇧Y",
+ hint: "Drag a clip along the track. Its neighbours absorb the move.",
+ },
+];
+
+/**
+ * Keyboard bindings, paired by what the tool acts on: T/⇧T move an edit point,
+ * Y/⇧Y move the media inside one. Keyed by `"shift+"`-prefixed lowercase key.
+ */
+export const TRIM_TOOL_KEYS: Readonly> = {
+ t: "ripple",
+ "shift+t": "roll",
+ y: "slip",
+ "shift+y": "slide",
+};
+
+/**
+ * Which tool grabs which part of a clip. Ripple and roll act on an edit point,
+ * so they live on the trim handles; slip and slide re-time the whole clip, so
+ * their handle is the clip body. One owner for that matrix, because the
+ * pointerdown path has to answer it for both surfaces.
+ */
+export function trimToolFor(tool: TimelineTool, surface: "edge" | "body"): TimelineTrimMode | null {
+ const mode = activeTrimMode(tool);
+ if (!mode) return null;
+ const belongsOnEdge = mode === "ripple" || mode === "roll";
+ return belongsOnEdge === (surface === "edge") ? mode : null;
+}
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
index 7aae044a72..3da4c85622 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
+++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx
@@ -6,6 +6,7 @@ import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
+import type { TimelineTrimMode } from "./timelineTrimOps";
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -105,12 +106,18 @@ function renderResizeHarness(
started: false,
};
},
- startResize(element: TimelineElement, edge: "start" | "end", pointerId = 0) {
+ startResize(
+ element: TimelineElement,
+ edge: "start" | "end",
+ pointerId = 0,
+ trimMode?: TimelineTrimMode,
+ ) {
act(() => {
apply({
pointerId,
element,
edge,
+ trimMode,
originClientX: 0,
previewStart: element.start,
previewDuration: element.duration,
@@ -441,3 +448,103 @@ describe("useTimelineClipDrag — multi-select group resize (restored)", () => {
h.unmount();
});
});
+
+/**
+ * Trim tools drive the SAME resize gesture: `trimMode` swaps the preview math,
+ * and the projection then rides the group-resize commit unchanged. Lane under
+ * test: a(0-2), b(2-5, in point 1s), c(5-6) butted together, plus d alone on
+ * lane 1 to prove a trim never reaches across lanes.
+ */
+function startTrim(mode: TimelineTrimMode, grabbed: "a" | "b" | "c", edge: "start" | "end") {
+ const a = el("a", { start: 0, duration: 2 });
+ const b = el("b", { start: 2, duration: 3, playbackStart: 1, sourceDuration: 20 });
+ const c = el("c", { start: 5, duration: 1 });
+ const d = el("d", { start: 0, duration: 6, track: 1 });
+ const h = renderResizeHarness([a, b, c, d], []);
+ h.startResize({ a, b, c }[grabbed], edge, 0, mode);
+ return { a, b, c, d, h };
+}
+
+function persistedTrim(h: ReturnType) {
+ const changes: Array<{ element: TimelineElement; start: number; duration: number }> =
+ h.onResizeElements.mock.calls[0]?.[0] ?? [];
+ return changes.map((change) => [change.element.id, change.start, change.duration]);
+}
+
+describe("useTimelineClipDrag — trim tools", () => {
+ it("ripples an out point: the clip grows and the rest of the lane follows", async () => {
+ const { h } = startTrim("ripple", "a", "end");
+ h.movePointer(50); // +0.5s at 100 pps
+ await h.dropPointer();
+
+ expect(h.onResizeElements).toHaveBeenCalledTimes(1);
+ expect(persistedTrim(h)).toEqual([
+ ["a", 0, 2.5],
+ ["b", 2.5, 3],
+ ["c", 5.5, 1],
+ ]);
+ expect(h.storeById("d").start).toBe(0); // another lane is never rippled
+ h.unmount();
+ });
+
+ it("ripples an in point: the clip keeps its start and the lane closes behind it", async () => {
+ const { h } = startTrim("ripple", "b", "start");
+ h.movePointer(50);
+ await h.dropPointer();
+
+ expect(persistedTrim(h)).toEqual([
+ ["b", 2, 2.5],
+ ["c", 4.5, 1],
+ ]);
+ expect(h.onResizeElements.mock.calls[0][0][0].playbackStart).toBe(1.5);
+ h.unmount();
+ });
+
+ it("rolls an edit point without moving anything downstream", async () => {
+ const { h } = startTrim("roll", "a", "end");
+ h.movePointer(50);
+ await h.dropPointer();
+
+ expect(persistedTrim(h)).toEqual([
+ ["a", 0, 2.5],
+ ["b", 2.5, 2.5],
+ ]);
+ expect(h.storeById("c").start).toBe(5);
+ h.unmount();
+ });
+
+ it("slips the source window and leaves the lane untouched", async () => {
+ const { h } = startTrim("slip", "b", "end");
+ h.movePointer(50);
+ await h.dropPointer();
+
+ expect(persistedTrim(h)).toEqual([["b", 2, 3]]);
+ expect(h.onResizeElements.mock.calls[0][0][0].playbackStart).toBe(0.5);
+ h.unmount();
+ });
+
+ it("slides a clip while its neighbours absorb the move", async () => {
+ const { h } = startTrim("slide", "b", "end");
+ h.movePointer(50);
+ await h.dropPointer();
+
+ expect(persistedTrim(h)).toEqual([
+ ["a", 0, 2.5],
+ ["b", 2.5, 3],
+ ["c", 5.5, 0.5],
+ ]);
+ h.unmount();
+ });
+
+ it("Escape abandons a trim without persisting or touching the store", () => {
+ const { h } = startTrim("ripple", "a", "end");
+ h.movePointer(50);
+ expect(h.getResizeProjection()).toHaveLength(3);
+
+ h.pressEscape();
+ expect(h.getResizeProjection()).toHaveLength(0);
+ expect(h.storeById("b").start).toBe(2);
+ expect(h.onResizeElements).not.toHaveBeenCalled();
+ h.unmount();
+ });
+});
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index ea44dda075..ce7cf2424e 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -15,6 +15,7 @@ import { collectTimelineSnapTargets, type TimelineSnapTarget } from "./timelineS
import type { StackingPatch } from "./timelineStackingSync";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import {
+ compensateResizeScroll,
computeDragPreview,
computeResizePreview,
previewGroupResize,
@@ -27,6 +28,8 @@ import type {
} from "./timelineClipDragTypes";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import type { TimelineRowGeometry } from "./timelineLayout";
+import type { TimelineTrimMode } from "./timelineTrimOps";
+import { applyTimelineTrimPreview, reuseOrOpenTrimSession } from "./timelineTrimSession";
import {
mountTimelineClipDragGestureLifecycle,
type TimelineGestureKind,
@@ -282,20 +285,75 @@ export function useTimelineClipDrag({
[scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
);
+ /**
+ * Trim-tool branch of the resize gesture (ripple / roll / slip / slide). The
+ * session is opened lazily on first movement — like the group-resize session —
+ * and, being a group-resize session, rides the very same projection, cancel and
+ * commit plumbing from there on.
+ */
+ const applyTrimPointer = useCallback(
+ (
+ resize: ResizingClipState,
+ mode: TimelineTrimMode,
+ clientX: number,
+ setResizeState: (v: ResizePreviewResult & Pick) => void,
+ ) => {
+ const { originScrollLeft, effectiveClientX } = compensateResizeScroll(
+ resize,
+ clientX,
+ scrollRef.current,
+ );
+ const pps = Math.max(ppsRef.current, 1e-6);
+ const session = reuseOrOpenTrimSession(
+ groupResizeRef.current,
+ resize.element,
+ mode,
+ resize.edge,
+ {
+ elements: elementsRef.current,
+ playheadTime: usePlayerStore.getState().currentTime,
+ beatTimes: snapContextRef.current.beatTimes,
+ snapEnabled: snapContextRef.current.enabled,
+ },
+ );
+ groupResizeRef.current = session;
+
+ // A refused gesture (pointerdown already reported why) holds the clip at
+ // its authored timing rather than falling back to a plain trim.
+ const grabbed = session
+ ? applyTimelineTrimPreview(session, (effectiveClientX - resize.originClientX) / pps, pps)
+ : undefined;
+ setResizeState({
+ originScrollLeft,
+ previewStart: grabbed?.start ?? resize.element.start,
+ previewDuration: grabbed?.duration ?? resize.element.duration,
+ previewPlaybackStart: grabbed?.playbackStart ?? resize.element.playbackStart,
+ groupPreview: session?.changes,
+ });
+ },
+ [scrollRef, ppsRef],
+ );
+
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
// branch and the edge auto-scroll stepper (re-runs as content scrolls under a
// stationary pointer). computeResizePreview is pure; here we only apply state.
const applyResizePointer = useCallback(
(resize: ResizingClipState, clientX: number) => {
+ const setResizeState = (v: ResizePreviewResult) =>
+ publishResizingClip(
+ resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
+ );
+
+ if (resize.trimMode) {
+ applyTrimPointer(resize, resize.trimMode, clientX, setResizeState);
+ return;
+ }
+
const next = computeResizePreview(resize, clientX, {
scroll: scrollRef.current,
pps: ppsRef.current,
buildSnapTargets,
});
- const setResizeState = (v: ResizePreviewResult) =>
- publishResizingClip(
- resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null,
- );
// Group resize: a capability-clean multi-selection resizes rigidly by one
// shared, member-clamped delta (legacy main 36413da7f). The grabbed clip
@@ -327,7 +385,7 @@ export function useTimelineClipDrag({
}
previewGroupResize(session, next, setResizeState);
},
- [scrollRef, ppsRef, buildSnapTargets, publishResizingClip],
+ [scrollRef, ppsRef, buildSnapTargets, publishResizingClip, applyTrimPointer],
);
const applyResizePointerRef = useRef(applyResizePointer);
applyResizePointerRef.current = applyResizePointer;
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 6f7bca0bf8..beb87d608c 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -24,7 +24,7 @@ import type { TimelineElement } from "./timelineElement";
export type { TimelineElement };
export type ZoomMode = "fit" | "manual";
-type TimelineTool = "select" | "razor";
+import type { TimelineTool } from "../components/timelineTrimTools";
export interface SelectElementOptions {
preserveSet?: boolean;