+ {insertRow !== null && (
+
+ + New track, before track {insertRow + 1}
+
+ )}
+ );
const rowBackground = theme.rowBackground;
const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
selectedElementId,
@@ -343,20 +359,6 @@ export function TimelineLanes({
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
)}
- {isPendingTrack && (
-
- New track
-
- )}
{
// fallow-ignore-next-line complexity
renderElements.map((el) => {
diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts
index bfaf084270..1f840e2ef5 100644
--- a/packages/studio/src/player/components/timelineCallbacks.ts
+++ b/packages/studio/src/player/components/timelineCallbacks.ts
@@ -1,8 +1,10 @@
// fallow-ignore-file code-duplication
-// fallow-ignore-file dead-code
import type { TimelineEditOutcome } from "../../hooks/timelineEditPermission";
import type { TimelineElement } from "../store/playerStore";
-import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
+import type {
+ TimelineMoveOperation,
+ TimelineAtomicMoveUpdates,
+} from "../../hooks/timelineMoveAdapter";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
import type { PropertyGroupName } from "@hyperframes/core/gsap-parser";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
@@ -50,7 +52,7 @@ export type TimelineLinkEdit =
export interface TimelineEditCallbacks {
onMoveElement?: (
element: TimelineElement,
- updates: Pick
,
+ updates: TimelineAtomicMoveUpdates,
) => Promise | void;
/** Atomic multi-clip move (single undo) for main-track ripple + track-insert.
* `coalesceKey` (drag-commit gesture id) merges the move history entry with a
@@ -58,7 +60,7 @@ export interface TimelineEditCallbacks {
* widens that entry's fold window when a server round-trip separates the
* gesture's records (per-gesture-unique keys keep the fold gesture-scoped). */
onMoveElements?: (
- edits: Array<{ element: TimelineElement; updates: Pick }>,
+ edits: Array<{ element: TimelineElement; updates: TimelineAtomicMoveUpdates }>,
coalesceKey?: string,
operation?: TimelineMoveOperation,
coalesceMs?: number,
diff --git a/packages/studio/src/player/components/timelineClipDragCommit.test.ts b/packages/studio/src/player/components/timelineClipDragCommit.test.ts
index f025796b65..c5c7cc2b32 100644
--- a/packages/studio/src/player/components/timelineClipDragCommit.test.ts
+++ b/packages/studio/src/player/components/timelineClipDragCommit.test.ts
@@ -1483,6 +1483,57 @@ describe("persistMoveEdits: a nested row dropped before its host", () => {
});
describe("persistMoveEdits convergence", () => {
+ it.each([
+ { successor: "timing", detach: false, expectedGroup: "G" },
+ { successor: "detach", detach: true, expectedGroup: undefined },
+ ])(
+ "preserves newer $successor while rolling back a refused detach",
+ async ({ detach, expectedGroup }) => {
+ let current: TimelineElement = { ...el("grouped", 0, 0, 4), audioGroup: "G" };
+ let rejectDetach!: (error: Error) => void;
+ let resolveSuccessor!: () => void;
+ const refused = new Promise((_resolve, reject) => {
+ rejectDetach = reject;
+ });
+ const successor = new Promise((resolve) => {
+ resolveSuccessor = resolve;
+ });
+ const updateElement = (_key: string, updates: Partial) => {
+ current = { ...current, ...updates };
+ };
+ const deps = { elements: [current], trackOrder: [0, 1], updateElement };
+ const first = persistMoveEdits(
+ [{ element: current, updates: { start: 0, track: 1, audioGroup: null } }],
+ { ...deps, onMoveElements: () => refused },
+ undefined,
+ "track-insert",
+ );
+ expect(current.audioGroup).toBeUndefined();
+ const second = persistMoveEdits(
+ [
+ {
+ element: current,
+ updates: { start: 5, track: 1, ...(detach ? { audioGroup: null } : {}) },
+ },
+ ],
+ { ...deps, onMoveElements: () => successor },
+ );
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ rejectDetach(new Error("save refused"));
+ await expect(first).resolves.toBe(false);
+ expect(current).toMatchObject({ start: 5, track: 1 });
+ expect(current.audioGroup).toBe(expectedGroup);
+ resolveSuccessor();
+ await expect(second).resolves.toBe(true);
+ expect(current).toMatchObject({ start: 5, track: 1 });
+ expect(current.audioGroup).toBe(expectedGroup);
+ } finally {
+ consoleError.mockRestore();
+ }
+ },
+ );
+
it("reasserts a saved lane after a stale runtime sync", async () => {
const clip = { ...el("headline", 2, 0.5, 4.9), authoredTrack: 2 };
let releaseSave: (() => void) | undefined;
diff --git a/packages/studio/src/player/components/timelineClipDragCommit.ts b/packages/studio/src/player/components/timelineClipDragCommit.ts
index e0d6325ad1..44be26c90d 100644
--- a/packages/studio/src/player/components/timelineClipDragCommit.ts
+++ b/packages/studio/src/player/components/timelineClipDragCommit.ts
@@ -6,12 +6,17 @@ import type { DraggedClipState } from "./useTimelineClipDrag";
import type { ZMirrorLaneMove } from "./timelineZMirror";
import { classifyZone } from "./timelineZones";
import { layoutAfterTrackInsert } from "./timelineDragLanding";
+import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import { computeStackingPatches, type StackingPatch } from "./timelineStackingSync";
import {
canMoveTimelineElement as canMoveElement,
resolveExpandedHostAlias,
} from "./timelineAuthoredMoveTarget";
-import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter";
+import type {
+ TimelineMoveOperation,
+ TimelineAtomicMoveUpdates,
+ TimelineAtomicMoveEdit,
+} from "../../hooks/timelineMoveAdapter";
import {
beginTimelineOptimisticGesture,
isLatestTimelineOptimisticGesture,
@@ -21,10 +26,8 @@ import { refreshAfterDurableLaneMove } from "./timelineLaneMoveRefresh";
import { authoredTrackForLane } from "./timelineAuthoredTrack";
import { resolveGroupMovers } from "./timelineMultiDragPreview";
-type StartTrack = Pick;
-export interface TimelineMoveEdit {
- element: TimelineElement;
- updates: StartTrack;
+type StartTrack = TimelineAtomicMoveUpdates;
+export interface TimelineMoveEdit extends TimelineAtomicMoveEdit {
/**
* File-space track override for the persist. The store's `updates.track` is a
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
@@ -37,6 +40,7 @@ export interface TimelineMoveEdit {
export interface DragCommitDeps {
elements: TimelineElement[];
trackOrder: number[];
+ trackInsertLayout?: TimelineTrackInsertLayout;
updateElement: (key: string, updates: Partial) => void;
/** Single-clip, SDK-cutover-aware persist (pure time-moves keep this path). */
onMoveElement?: (element: TimelineElement, updates: StartTrack) => Promise | void;
@@ -85,6 +89,17 @@ const round3 = (v: number) => Math.round(v * 1000) / 1000;
// One deterministic coalesce key shared by both records in a lane-change gesture.
let laneChangeGestureSeq = 0;
+function moveStoreUpdates(edit: TimelineMoveEdit) {
+ const writtenTrack =
+ edit.persistTrack ??
+ (edit.updates.track !== edit.element.track ? edit.updates.track : undefined);
+ const { audioGroup, ...timing } = edit.updates;
+ return {
+ timing: writtenTrack == null ? timing : { ...timing, authoredTrack: writtenTrack },
+ detach: audioGroup === null,
+ };
+}
+
/**
* Optimistically apply + persist a batch of moves with rollback on failure.
*
@@ -123,26 +138,42 @@ export function persistMoveEdits(
}
const prev = edits.map((e) => ({
key: keyOf(e.element),
- start: e.element.start,
- track: e.element.track,
- authoredTrack: e.element.authoredTrack,
+ updates: {
+ start: e.element.start,
+ track: e.element.track,
+ authoredTrack: e.element.authoredTrack,
+ ...(e.updates.audioGroup === null ? { audioGroup: e.element.audioGroup } : {}),
+ },
}));
const revision = beginTimelineOptimisticGesture(
updateElement,
edits.map((edit) => keyOf(edit.element)),
);
+ const membershipRevision = beginTimelineOptimisticGesture(
+ updateElement,
+ edits.filter((edit) => edit.updates.audioGroup === null).map((edit) => keyOf(edit.element)),
+ "membership",
+ );
// The file write below targets `persistTrack` (authored space) when supplied,
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
// that written value into the store's `authoredTrack` so a SECOND drag before
// any reload resolves authored tracks from what the file now says, not stale
// pre-edit data. Pure time-moves leave authoredTrack untouched.
- const applyEdit = (e: TimelineMoveEdit) => {
- const writtenTrack =
- e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
- updateElement(
- keyOf(e.element),
- writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
- );
+ const applyEdit = (e: TimelineMoveEdit, reassert = false) => {
+ const { timing, detach } = moveStoreUpdates(e);
+ const key = keyOf(e.element);
+ const updates: Partial = {};
+ if (!reassert || isLatestTimelineOptimisticGesture(updateElement, revision, key)) {
+ Object.assign(updates, timing);
+ }
+ if (
+ detach &&
+ (!reassert ||
+ isLatestTimelineOptimisticGesture(updateElement, membershipRevision, key, "membership"))
+ ) {
+ updates.audioGroup = undefined;
+ }
+ if (Object.keys(updates).length) updateElement(key, updates);
};
for (const e of edits) applyEdit(e);
// The store above gets DISPLAY lanes; the file below gets the authored-space
@@ -161,17 +192,21 @@ export function persistMoveEdits(
// restore the preview manifest's pre-gesture lane. Reassert the durable
// result after persistence, but only while this remains the latest
// optimistic gesture so an older save can never clobber a newer drag.
- for (const e of edits) {
- const key = keyOf(e.element);
- if (isLatestTimelineOptimisticGesture(updateElement, revision, key)) applyEdit(e);
- }
+ for (const e of edits) applyEdit(e, true);
return true;
},
(error) => {
for (const p of prev) {
+ const { audioGroup, ...timing } = p.updates;
+ const updates: Partial = {};
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
- updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
+ Object.assign(updates, timing);
}
+ if (
+ isLatestTimelineOptimisticGesture(updateElement, membershipRevision, p.key, "membership")
+ )
+ updates.audioGroup = audioGroup;
+ if (Object.keys(updates).length) updateElement(p.key, updates);
}
console.error("[Timeline] Failed to persist clip edits", error);
return false;
@@ -370,7 +405,14 @@ function buildTrackInsertEdits(
let start = src.start;
if (normKey === editKey) start = previewStart;
else if (multi?.keys.has(normKey)) start = multi.movedStart(src);
- edits.push({ element: src, updates: { start, track: norm.track } });
+ edits.push({
+ element: src,
+ updates: {
+ start,
+ track: norm.track,
+ ...(normKey === editKey && src.audioGroup ? { audioGroup: null } : {}),
+ },
+ });
}
return { candidate, edits };
}
diff --git a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
index 7f6cb33d18..eec421f1a0 100644
--- a/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
+++ b/packages/studio/src/player/components/timelineClipDragGestureLifecycle.ts
@@ -1,3 +1,4 @@
+import type { TimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { resolveTimelineDragEscape } from "./timelineEditing";
import { commitDraggedClipMove } from "./timelineClipDragCommit";
@@ -37,6 +38,8 @@ interface TimelineGestureCommit {
type UpdateElement = ReturnType["updateElement"];
interface TimelineClipDragGestureLifecycleInput {
+ commitGestureRef?: RefObject<() => void>;
+ trackInsertLayoutRef?: RefObject;
lifecycleRef: RefObject;
sessionEpochRef: RefObject;
cancelGestureRef: RefObject<
@@ -81,6 +84,8 @@ interface TimelineClipDragGestureLifecycleInput {
export function mountTimelineClipDragGestureLifecycle({
// The explicit destructuring mirrors the single call site's dependency object by design.
// fallow-ignore-next-line code-duplication
+ commitGestureRef,
+ trackInsertLayoutRef,
lifecycleRef,
sessionEpochRef,
cancelGestureRef,
@@ -120,6 +125,7 @@ export function mountTimelineClipDragGestureLifecycle({
};
const pointerMatchesGesture = (event: PointerEvent): boolean => {
+ if (draggedClipRef.current?.pointerId === null) return false;
const pointerId = lifecycleRef.current.pointerId;
return pointerId === null || event.pointerId === pointerId;
};
@@ -293,6 +299,7 @@ export function mountTimelineClipDragGestureLifecycle({
commitDraggedClipMove(drag, {
elements: elementsRef.current,
trackOrder: trackOrderRef.current,
+ trackInsertLayout: trackInsertLayoutRef?.current,
updateElement,
onMoveElement: onMoveElementRef.current,
onMoveElements: onMoveElementsRef.current,
@@ -315,10 +322,12 @@ export function mountTimelineClipDragGestureLifecycle({
);
};
- const claimActiveGesture = (event: PointerEvent): TimelineGestureCommit | "ignored" | null => {
+ const claimActiveGesture = (
+ event: PointerEvent | null,
+ ): TimelineGestureCommit | "ignored" | null => {
const lifecycle = lifecycleRef.current;
if (lifecycle.phase !== "active") return null;
- if (!pointerMatchesGesture(event)) return "ignored";
+ if (event && !pointerMatchesGesture(event)) return "ignored";
if (lifecycle.sessionEpoch !== sessionEpochRef.current) {
cancelGesture();
return "ignored";
@@ -358,6 +367,12 @@ export function mountTimelineClipDragGestureLifecycle({
}
};
+ if (commitGestureRef)
+ commitGestureRef.current = () => {
+ const claimed = claimActiveGesture(null);
+ if (claimed && claimed !== "ignored") commitClaimedGesture(claimed);
+ };
+
const handleWindowPointerUp = (event: PointerEvent) => {
if (releasedOutsideWindow(event)) return handleWindowPointerCancel(event);
const claimed = claimActiveGesture(event);
@@ -411,6 +426,7 @@ export function mountTimelineClipDragGestureLifecycle({
return () => {
cancelGesture({ updateReact: false });
cancelGestureRef.current = () => false;
+ if (commitGestureRef) commitGestureRef.current = () => {};
window.removeEventListener("pointermove", handleWindowPointerMove);
window.removeEventListener("pointerup", handleWindowPointerUp);
window.removeEventListener("pointercancel", handleWindowPointerCancel);
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.test.ts b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
index 1b0b18b8f4..b149eba3dc 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.test.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.test.ts
@@ -7,8 +7,18 @@ import {
type DragPreviewContext,
} from "./timelineClipDragPreview";
import type { DraggedClipState } from "./timelineClipDragTypes";
-import { commitDraggedClipMove, persistMoveEdits } from "./timelineClipDragCommit";
-import { LANE_H, RULER_H, TRACKS_TOP_PAD, TRACK_H } from "./timelineLayout";
+import {
+ commitDraggedClipMove,
+ persistMoveEdits,
+ type TimelineMoveEdit,
+} from "./timelineClipDragCommit";
+import {
+ LANE_H,
+ RULER_H,
+ TRACKS_TOP_PAD,
+ TRACK_H,
+ createTimelineRowGeometry,
+} from "./timelineLayout";
import { isMultiDragPassenger } from "./timelineMultiDragPreview";
import { resolveMultiDragPreview } from "./timelineProviderStateBuilders";
@@ -110,6 +120,20 @@ function horizontalDrag(
}
describe("computeDragPreview — plain horizontal drag never arms a phantom insert (BUG 1)", () => {
+ it("opens the physical seam below a zero-padding ruler and keeps it armed inside the new lane", () => {
+ const geometry = createTimelineRowGeometry([0, 1, 2], [104, 48, 48], { top: 0 });
+ const context = { ...ctx(geometry.rowHeights), rowGeometry: geometry };
+ const { drag, clientX } = horizontalDrag(moodboard, 0.5, 2);
+ const top = computeDragPreview(drag, clientX, 24, context);
+ expect(top.insertRow).toBe(0);
+ const between = computeDragPreview(drag, clientX, 128, context);
+ expect(between.insertRow).toBe(1);
+ const inside = computeDragPreview(between, clientX, 150, context);
+ expect(inside.insertRow).toBe(1);
+ const below = computeDragPreview(inside, clientX, 202, context);
+ expect(below.insertRow).toBeNull();
+ });
+
it("dragging v-moodboard +2s while grabbing its clip body keeps it a pure time move", () => {
const { drag, clientX, clientY } = horizontalDrag(moodboard, 0.5, 2);
const next = computeDragPreview(drag, clientX, clientY, ctx());
@@ -470,11 +494,10 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
expect(committedStart(ghost, tag, rows)).toBe(10);
});
- it("the edge of a row is still that row, not a new track", () => {
+ it("a clip aimed at a seam opens a new track at that boundary", () => {
for (const edge of [0.02, 0.98, 1.02]) {
const ghost = preview(tag, rows, 2, edge);
- expect(ghost.insertRow).toBeNull();
- expect(ghost.previewTrack).toBe(Math.floor(edge));
+ expect(ghost.insertRow).toBe(Math.round(edge));
}
});
});
@@ -550,6 +573,23 @@ describe("computeDragPreview — the ghost start is the committed start", () =>
});
describe("computeDragPreview — a group move keeps its shape", () => {
+ function commitPreview(
+ ghost: DraggedClipState,
+ elements: TimelineElement[],
+ selectedKeys: ReadonlySet,
+ ) {
+ const onMoveElements = vi.fn<(edits: TimelineMoveEdit[]) => void>();
+ commitDraggedClipMove(ghost, {
+ elements,
+ trackOrder: [0, 1, 2],
+ updateElement: vi.fn(),
+ onMoveElement: vi.fn(),
+ onMoveElements,
+ selectedKeys,
+ });
+ return onMoveElements.mock.calls[0][0];
+ }
+
it("never bumps the grabbed clip further left than the group's 0 limit", () => {
const a = clip("a", 1, 0.5, 1, 1);
const b = clip("b", 2, 7, 2, 1);
@@ -563,19 +603,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
selectedKeys,
});
expect(ghost).toMatchObject({ previewTrack: 0, insertRow: null, previewStart: 10 });
- const onMoveElements = vi.fn();
- commitDraggedClipMove(ghost, {
- elements,
- trackOrder: [0, 1, 2],
- updateElement: vi.fn(),
- onMoveElement: vi.fn(),
- onMoveElements,
- selectedKeys,
- });
- const edits = onMoveElements.mock.calls[0][0] as Array<{
- element: TimelineElement;
- updates: { start: number };
- }>;
+ const edits = commitPreview(ghost, elements, selectedKeys);
const moved = Object.fromEntries(
edits.map((e) => [e.element.id, e.updates.start - e.element.start]),
);
@@ -595,19 +623,7 @@ describe("computeDragPreview — a group move keeps its shape", () => {
...ctx(undefined, elements),
selectedKeys,
});
- const onMoveElements = vi.fn();
- commitDraggedClipMove(ghost, {
- elements,
- trackOrder: [0, 1, 2],
- updateElement: vi.fn(),
- onMoveElement: vi.fn(),
- onMoveElements,
- selectedKeys,
- });
- const edits = onMoveElements.mock.calls[0][0] as Array<{
- element: TimelineElement;
- updates: { start: number; track: number };
- }>;
+ const edits = commitPreview(ghost, elements, selectedKeys);
return Object.fromEntries(edits.map((e) => [e.element.id, e.updates]));
}
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts
index 8275c78b95..d6e6693e8d 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.ts
@@ -6,7 +6,12 @@ import {
} from "./timelineGroupEditing";
import type { TimelineElement } from "../store/playerStore";
import { clampToHostStart } from "../store/timelineElement";
-import { getTimelineRowFromY } from "./timelineLayout";
+import {
+ CLIP_Y,
+ TRACK_H,
+ getTimelineRowGeometry,
+ type TimelineRowGeometry,
+} from "./timelineLayout";
import { isMusicTrack, isAudioTimelineElement } from "../../utils/timelineInspector";
import {
TIMELINE_SNAP_PX,
@@ -38,6 +43,9 @@ export interface DragPreviewContext {
duration: number;
trackOrder: number[];
rowHeights?: readonly number[];
+ rowGeometry?: TimelineRowGeometry;
+ allowedInsertRows?: ReadonlySet;
+ groupTracks?: ReadonlyMap;
elements: TimelineElement[];
selectedKeys: ReadonlySet;
buildSnapTargets: BuildSnapTargets;
@@ -50,6 +58,33 @@ export interface DragPreviewContext {
audioTracks?: ReadonlySet;
}
+export function createKeyboardClipDrag(
+ element: TimelineElement,
+ insertRow: number,
+ scrollLeft: number,
+ scrollTop: number,
+): DraggedClipState {
+ return {
+ pointerId: null,
+ element,
+ insertRow,
+ started: true,
+ previewStart: element.start,
+ previewTrack: element.track,
+ desiredTrack: element.track,
+ originClientX: 0,
+ originClientY: 0,
+ originScrollLeft: scrollLeft,
+ originScrollTop: scrollTop,
+ pointerClientX: 0,
+ pointerClientY: 0,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ snapTime: null,
+ snapType: null,
+ };
+}
+
/** Content-space position for the stable viewport drag actor. */
export function getTimelineDragOverlayPosition(
drag: DraggedClipState,
@@ -73,22 +108,50 @@ function resolveDragMaxStart(scroll: HTMLDivElement | null, pps: number, duratio
return Math.max(duration, scroll && pps > 0 ? scroll.scrollWidth / pps : duration);
}
+const INSERT_SEAM_PX = CLIP_Y;
+
+function physicalInsertRow(
+ rowFloat: number,
+ y: number,
+ ctx: Pick,
+ geometry: TimelineRowGeometry,
+) {
+ const boundary = Math.round(rowFloat);
+ const nearSeam = Math.abs(y - geometry.getRowTop(boundary)) <= INSERT_SEAM_PX;
+ const row = nearSeam
+ ? Math.max(0, Math.min(ctx.trackOrder.length, boundary))
+ : resolveInsertRow(rowFloat, ctx.trackOrder.length);
+ if (row !== null && ctx.allowedInsertRows && !ctx.allowedInsertRows.has(row)) return null;
+ return row;
+}
+
+function dragRowAim(
+ drag: DraggedClipState,
+ clientY: number,
+ ctx: DragPreviewContext,
+ geometry: TimelineRowGeometry,
+) {
+ let y = clientY - (ctx.scroll?.getBoundingClientRect().top ?? 0) + (ctx.scroll?.scrollTop ?? 0);
+ if (drag.started && drag.insertRow !== null) {
+ const top = geometry.getRowTop(drag.insertRow);
+ if (y >= top - INSERT_SEAM_PX && y <= top + TRACK_H + INSERT_SEAM_PX)
+ return { rowFloat: drag.insertRow, insertRow: drag.insertRow };
+ if (y > top + TRACK_H) y -= TRACK_H;
+ }
+ const rowFloat = geometry.getRowFromY(y);
+ return { rowFloat, insertRow: physicalInsertRow(rowFloat, y, ctx, geometry) };
+}
+
/** The drop decision for the pointer's row (see resolveZoneDropPlacement). */
function resolveDropPlacement(
drag: DraggedClipState,
- clientY: number,
+ aim: ReturnType,
previewStart: number,
desiredTrack: number,
ctx: DragPreviewContext,
group: GroupDrag,
): { track: number; insertRow: number | null; start: number } {
- const { scroll, trackOrder, rowHeights, elements } = ctx;
- const rowFloat = scroll
- ? getTimelineRowFromY(
- clientY - scroll.getBoundingClientRect().top + scroll.scrollTop,
- rowHeights,
- )
- : 0;
+ const { trackOrder, elements } = ctx;
const dragKey = drag.element.key ?? drag.element.id;
const audioTracks =
ctx.audioTracks ?? new Set(elements.filter(isAudioTimelineElement).map((e) => e.track));
@@ -97,7 +160,9 @@ function resolveDropPlacement(
audioTracks,
elements: group.obstacles,
desiredTrack,
- deliberateInsertRow: resolveInsertRow(rowFloat, trackOrder.length),
+ deliberateInsertRow: aim.insertRow,
+ allowedInsertRows: ctx.allowedInsertRows,
+ groupTracks: ctx.groupTracks,
start: previewStart,
duration: drag.element.duration,
dragKey,
@@ -144,13 +209,12 @@ export function computeDragPreview(
): DraggedClipState {
const { scroll, pps, duration, trackOrder, buildSnapTargets } = ctx;
const dragMaxStart = resolveDragMaxStart(scroll, pps, duration);
- const scrollTop = scroll?.scrollTop ?? drag.originScrollTop;
const scrollRectTop = scroll?.getBoundingClientRect().top ?? 0;
- const originRow = getTimelineRowFromY(
- drag.originClientY - scrollRectTop + drag.originScrollTop,
- ctx.rowHeights,
- );
- const currentRow = getTimelineRowFromY(clientY - scrollRectTop + scrollTop, ctx.rowHeights);
+ const geometry =
+ ctx.rowGeometry ?? getTimelineRowGeometry(ctx.rowHeights ?? ctx.trackOrder.map(() => TRACK_H));
+ const originRow = geometry.getRowFromY(drag.originClientY - scrollRectTop + drag.originScrollTop);
+ const aim = dragRowAim(drag, clientY, ctx, geometry);
+ const currentRow = aim.rowFloat;
// resolveTimelineMove's vertical axis is row indices, which is why the pointer
// and scroll pixels are folded into originRow/currentRow above.
const nextMove = resolveTimelineMove(
@@ -196,7 +260,7 @@ export function computeDragPreview(
const origin = drag.element.start;
const floored = Math.max(snap.start, group.floor);
const previewStart = origin + Math.max(shift.min, Math.min(floored - origin, shift.max));
- const placement = resolveDropPlacement(drag, clientY, previewStart, nextMove.track, ctx, group);
+ const placement = resolveDropPlacement(drag, aim, previewStart, nextMove.track, ctx, group);
const placedShift = placement.start - origin;
if (placedShift < shift.min - SHIFT_EPSILON_S || placedShift > shift.max + SHIFT_EPSILON_S) {
return { ...drag, started: true };
diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts
index 4ef2801b67..92d8126840 100644
--- a/packages/studio/src/player/components/timelineClipDragTypes.ts
+++ b/packages/studio/src/player/components/timelineClipDragTypes.ts
@@ -4,8 +4,8 @@ import type { BlockedTimelineEditIntent } from "./timelineEditing";
/* ── Shared clip-drag state types ───────────────────────────────── */
export interface DraggedClipState {
- /** Pointer captured by the stable viewport coordinator for this gesture. */
- pointerId: number;
+ /** Pointer captured by the viewport coordinator; null for a keyboard pickup. */
+ pointerId: number | null;
element: TimelineElement;
originClientX: number;
originClientY: number;
@@ -26,7 +26,7 @@ export interface DraggedClipState {
/**
* When non-null, the drop inserts a NEW track at this visual row boundary
* (0 = above the top lane, trackOrder.length = below the bottom) instead of
- * landing on previewTrack. Drives the insertion-line indicator.
+ * landing on previewTrack. Drives the opened preview lane.
*/
insertRow: number | null;
/** Snap target the clip will land on, for the guide highlight. */
diff --git a/packages/studio/src/player/components/timelineCollision.ts b/packages/studio/src/player/components/timelineCollision.ts
index 25faaf8ea7..62c995abe2 100644
--- a/packages/studio/src/player/components/timelineCollision.ts
+++ b/packages/studio/src/player/components/timelineCollision.ts
@@ -1,5 +1,17 @@
import type { TimelineElement } from "../store/playerStore";
+/** Audio member tracks mark the audio zone, including members hidden by a group header. */
+export function timelineAudioRow(
+ order: number[],
+ audioTracks: ReadonlySet,
+ groupTracks?: ReadonlyMap,
+): number {
+ return order.findIndex(
+ (track) =>
+ audioTracks.has(track) || groupTracks?.get(track)?.some((member) => audioTracks.has(member)),
+ );
+}
+
/**
* Keep a landing track inside the dragged clip's kind-zone: visual clips stay in
* the rows ABOVE the first audio lane; audio clips stay AT/BELOW it. Prevents a
@@ -31,7 +43,10 @@ export function isInsertAllowedForZone(
insertRow: number,
audioRow: number,
isAudio: boolean,
+ allowedRows?: ReadonlySet,
): boolean {
+ if (allowedRows && !allowedRows.has(insertRow)) return false;
+ while (audioRow > 0 && allowedRows && !allowedRows.has(audioRow)) audioRow--;
if (audioRow < 0) return true;
return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
}
@@ -52,9 +67,8 @@ function outOfRangeZoneInsertRow(
audioRow: number,
desired: number,
): number {
- // No lane of this kind yet: fall to the split (audioRow) or the very top.
- // A visual-only timeline has audioRow -1 (top); an all-audio one has it at 0.
- if (zoneTracks.length === 0) return audioRow < 0 ? 0 : audioRow;
+ // An empty audio zone opens below visual rows; an empty visual zone opens above audio.
+ if (zoneTracks.length === 0) return audioRow < 0 ? order.length : audioRow;
// zoneTracks preserves `order` sequence, so its ends map to the zone boundary
// rows: above the zone's min lane → its top boundary, else its bottom.
const zoneTop = order.indexOf(zoneTracks[0]);
@@ -104,14 +118,31 @@ export function resolveNearestFreeStart(
return clear ? origin : best;
}
-// Where a dragged clip lands: on the aimed row of its kind, at the nearest free time there. Only an aim outside all
-// rows, or into a kind with no row yet, opens a track (`insertRow`).
+function groupHeaderAim(
+ input: Parameters[0],
+):
+ | { kind: "ordinary" | "member"; track: number }
+ | { kind: "origin"; track: number; start: number } {
+ const members = input.groupTracks?.get(input.desiredTrack);
+ if (!members) return { kind: "ordinary", track: input.desiredTrack };
+ const memberTracks = new Set(members);
+ const member = input.order.find(
+ (track) => memberTracks.has(track) && input.audioTracks.has(track) === input.isAudio,
+ );
+ if (member !== undefined) return { kind: "member", track: member };
+ if (!input.origin) throw new Error("Group-header drop requires the clip origin");
+ return { kind: "origin", ...input.origin };
+}
+
+// A deliberate seam opens a track; other aims land at the nearest free time on a row of the clip's kind.
export function resolveZoneDropPlacement(input: {
order: number[];
audioTracks: ReadonlySet;
elements: TimelineElement[];
desiredTrack: number;
deliberateInsertRow: number | null;
+ allowedInsertRows?: ReadonlySet;
+ groupTracks?: ReadonlyMap;
start: number;
duration: number;
dragKey: string;
@@ -123,24 +154,33 @@ export function resolveZoneDropPlacement(input: {
}): { track: number; insertRow: number | null; start: number } {
const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
const { start, duration, dragKey, isAudio, minStart } = input;
- const audioRow = order.findIndex((t) => audioTracks.has(t));
+ const audioRow = timelineAudioRow(order, audioTracks, input.groupTracks);
if (
deliberateInsertRow !== null &&
- isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
+ isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio, input.allowedInsertRows)
) {
return { track: desiredTrack, insertRow: deliberateInsertRow, start };
}
- const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
- const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
+ const aim = groupHeaderAim(input);
+ if (aim.kind === "origin") return { track: aim.track, start: aim.start, insertRow: null };
+ const firstVisibleAudioRow = order.findIndex((track) => audioTracks.has(track));
+ const zoneBoundary = isAudio ? firstVisibleAudioRow : audioRow;
+ const desired =
+ aim.kind === "member" ? aim.track : clampTrackToZone(aim.track, order, zoneBoundary, isAudio);
+ const zoneTracks = order.filter(
+ (t) => !input.groupTracks?.has(t) && audioTracks.has(t) === isAudio,
+ );
// Only when the aim is outside the rows, or the clip's zone has no row yet.
if (!zoneTracks.includes(desired)) {
const desiredRow = order.indexOf(desired);
- const insertRow =
- desiredRow < 0
+ let insertRow =
+ desiredRow < 0 || zoneTracks.length === 0
? outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired)
: desiredRow + 1;
+ while (insertRow > 0 && input.allowedInsertRows && !input.allowedInsertRows.has(insertRow))
+ insertRow--;
return { track: desired, insertRow, start };
}
const origin = input.origin?.track === desired ? input.origin.start : null;
diff --git a/packages/studio/src/player/components/timelineDragLanding.ts b/packages/studio/src/player/components/timelineDragLanding.ts
index 845434e6ca..1fcc243c2e 100644
--- a/packages/studio/src/player/components/timelineDragLanding.ts
+++ b/packages/studio/src/player/components/timelineDragLanding.ts
@@ -24,7 +24,7 @@ export function layoutAfterTrackInsert(
keys: ReadonlySet;
movedStart: (e: TimelineElement) => number;
} | null,
- deps: Pick,
+ deps: Pick,
): {
normalized: TimelineElement[];
targetTrack: number;
@@ -37,18 +37,32 @@ export function layoutAfterTrackInsert(
const writableZone = classifyZone(element);
const writable = (src: TimelineElement): boolean =>
sameSourceFile(src, element) && classifyZone(src) === writableZone;
- const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
+ let topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort(
(a, b) => a - b,
);
- const topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
- const topologyTargetTrack = insertTrackValue(topologyOrder, topologyInsertRow);
+ let topologyInsertRow = topologyOrder.filter((track) => track < targetTrack).length;
+ const grouped = deps.trackInsertLayout;
+ let ranks: Map | undefined;
+ if (grouped) {
+ const writableTracks = new Set(topologyOrder);
+ topologyOrder = grouped.trackOrder.filter((track) => writableTracks.has(track));
+ topologyInsertRow = grouped.trackOrder
+ .slice(0, grouped.topologyRows[insertRow])
+ .filter((track) => writableTracks.has(track)).length;
+ ranks = new Map(topologyOrder.map((track, index) => [track, index]));
+ }
+ const topologyTargetTrack = ranks
+ ? topologyInsertRow - 0.5
+ : insertTrackValue(topologyOrder, topologyInsertRow);
const normalized = normalizeToZones(
elements.filter(writable).map((e) => {
if (keyOf(e) === editKey) {
return { ...e, start: previewStart, track: topologyTargetTrack };
}
- if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
- return e;
+ const track = ranks ? ranks.get(e.track) : e.track;
+ if (track === undefined) throw new Error("Writable track missing from insertion topology");
+ const start = multi?.keys.has(keyOf(e)) ? multi.movedStart(e) : e.start;
+ return track === e.track && start === e.start ? e : { ...e, start, track };
}),
);
return { normalized, targetTrack, writable };
diff --git a/packages/studio/src/player/components/timelineMountFixtures.tsx b/packages/studio/src/player/components/timelineMountFixtures.tsx
new file mode 100644
index 0000000000..b5b92f2edc
--- /dev/null
+++ b/packages/studio/src/player/components/timelineMountFixtures.tsx
@@ -0,0 +1,65 @@
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { Timeline } from "./Timeline";
+import type { TimelineProps } from "./TimelineTypes";
+import { usePlayerStore } from "../store/playerStore";
+import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
+
+export async function mountThreeTrackTimeline(props: TimelineProps = {}) {
+ usePlayerStore.setState({
+ duration: 10,
+ currentTime: 0,
+ timelineReady: true,
+ gsapAnimations: new Map(),
+ selectedElementId: null,
+ selectedElementIds: new Set(),
+ elements: [0, 1, 2].map((track) => ({
+ id: `c${track}`,
+ key: `c${track}`,
+ domId: `c${track}`,
+ tag: "div",
+ start: 0,
+ duration: 2,
+ track,
+ })),
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ await act(async () => root.render( ));
+ const viewport = host.querySelector("[data-timeline-scroll-viewport]")!;
+ const ghost = () => host.querySelector('[data-testid="timeline-ghost-lane"]');
+ return { host, root, viewport, ghost };
+}
+
+export function dragTimelineFixtureAsset(target: EventTarget, type: string, clientY: number): void {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperties(event, {
+ clientX: { value: 400 },
+ clientY: { value: clientY },
+ dataTransfer: {
+ value: {
+ types: [TIMELINE_ASSET_MIME],
+ files: [],
+ dropEffect: "none",
+ getData: (mime: string) => (mime === TIMELINE_ASSET_MIME ? '{"path":"a.png"}' : ""),
+ },
+ },
+ });
+ act(() => void target.dispatchEvent(event));
+}
+
+export function pointerTimelineFixture(target: EventTarget, type: string, clientY: number): void {
+ act(
+ () =>
+ void target.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ button: 0,
+ pointerId: 1,
+ clientX: 400,
+ clientY,
+ }),
+ ),
+ );
+}
diff --git a/packages/studio/src/player/components/timelineOptimisticRevision.ts b/packages/studio/src/player/components/timelineOptimisticRevision.ts
index 5b4f9d7a2d..9716c9362c 100644
--- a/packages/studio/src/player/components/timelineOptimisticRevision.ts
+++ b/packages/studio/src/player/components/timelineOptimisticRevision.ts
@@ -1,16 +1,23 @@
import type { TimelineElement } from "../store/playerStore";
type UpdateElement = (key: string, updates: Partial) => void;
-const revisionsByUpdater = new WeakMap>();
+type RevisionScope = "timing" | "membership";
+const revisionsByUpdater = new WeakMap>>();
export function beginTimelineOptimisticGesture(
updateElement: UpdateElement,
keys: readonly string[],
+ scope: RevisionScope = "timing",
): Map {
- let revisions = revisionsByUpdater.get(updateElement);
+ let scopes = revisionsByUpdater.get(updateElement);
+ if (!scopes) {
+ scopes = new Map();
+ revisionsByUpdater.set(updateElement, scopes);
+ }
+ let revisions = scopes.get(scope);
if (!revisions) {
revisions = new Map();
- revisionsByUpdater.set(updateElement, revisions);
+ scopes.set(scope, revisions);
}
const gesture = new Map();
for (const key of keys) {
@@ -25,8 +32,12 @@ export function isLatestTimelineOptimisticGesture(
updateElement: UpdateElement,
gesture: ReadonlyMap,
key: string,
+ scope: RevisionScope = "timing",
): boolean {
- return revisionsByUpdater.get(updateElement)?.get(key) === gesture.get(key);
+ return (
+ gesture.has(key) &&
+ revisionsByUpdater.get(updateElement)?.get(scope)?.get(key) === gesture.get(key)
+ );
}
export function rollbackLatestTimelineOptimisticGesture(
diff --git a/packages/studio/src/player/components/timelineTrackInsertLayout.test.ts b/packages/studio/src/player/components/timelineTrackInsertLayout.test.ts
new file mode 100644
index 0000000000..e5baa7086b
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrackInsertLayout.test.ts
@@ -0,0 +1,249 @@
+import { expect, it, vi } from "vitest";
+import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
+import { computeDragPreview } from "./timelineClipDragPreview";
+import { commitDraggedClipMove } from "./timelineClipDragCommit";
+import { timelineAudioRow, resolveZoneDropPlacement } from "./timelineCollision";
+import { createTimelineRowGeometry } from "./timelineLayout";
+import type { TimelineElement } from "../store/playerStore";
+import type { DraggedClipState } from "./timelineClipDragTypes";
+
+it.each([
+ { order: [0, 0.5, 1, 3, 2, 4], seam: 4 },
+ { order: [0, 0.5, 2, 4], seam: 2 },
+])(
+ "inserts below an expanded or collapsed group block in displayed order ($seam)",
+ async ({ order, seam }) => {
+ const layout = buildTimelineTrackInsertLayout(order, [
+ { anchorKey: 0.5, memberTracks: [1, 3] },
+ ]);
+ const elements: TimelineElement[] = [0, 1, 2, 3, 4].map((track) => ({
+ id: `clip-${track}`,
+ domId: `clip-${track}`,
+ tag: "audio",
+ start: 0,
+ duration: 1,
+ track,
+ audioGroup: track === 1 || track === 3 ? "group" : undefined,
+ }));
+ elements.push({ id: "tail", domId: "tail", tag: "audio", start: 3, duration: 1, track: 4 });
+ const hero = elements[4];
+ const drag: DraggedClipState = {
+ pointerId: 1,
+ element: hero,
+ originClientX: 100,
+ originClientY: 48 + order.indexOf(hero.track) * 48,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: 100,
+ pointerClientY: 48 + order.indexOf(hero.track) * 48,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: 0,
+ previewTrack: 4,
+ desiredTrack: 4,
+ insertRow: null,
+ snapTime: null,
+ snapType: null,
+ started: true,
+ };
+ const geometry = createTimelineRowGeometry(
+ order,
+ order.map(() => 48),
+ { top: 0 },
+ );
+ const context = {
+ scroll: null,
+ pps: 100,
+ duration: 10,
+ trackOrder: order,
+ rowGeometry: geometry,
+ elements,
+ selectedKeys: new Set(),
+ buildSnapTargets: () => [],
+ allowedInsertRows: layout.allowedRows,
+ groupTracks: layout.groupTracks,
+ };
+ const ontoHeader = computeDragPreview(drag, 100, geometry.getRowTop(1) + 24, context);
+ expect(ontoHeader.insertRow).toBeNull();
+ expect(ontoHeader.previewTrack).toBe(order.length === 6 ? 1 : 4);
+ if (order.length === 6) {
+ expect(layout.allowedRows.has(2)).toBe(false);
+ expect(layout.allowedRows.has(3)).toBe(false);
+ const ontoMember = computeDragPreview(drag, 100, 130, context);
+ expect(ontoMember.insertRow).toBeNull();
+ expect(ontoMember.previewTrack).toBe(1);
+ }
+ const preview = computeDragPreview(drag, 100, geometry.getRowTop(seam), context);
+ expect(preview.insertRow).toBe(seam);
+ const moved = new Map(elements.map((element) => [element.id, element]));
+ const persist = vi.fn();
+ commitDraggedClipMove(preview, {
+ elements,
+ trackOrder: order,
+ trackInsertLayout: layout,
+ updateElement: (id, updates) => {
+ moved.set(id, { ...moved.get(id)!, ...updates });
+ },
+ onMoveElements: persist,
+ });
+ await Promise.resolve();
+ expect(persist).toHaveBeenCalledOnce();
+ expect(moved.get("clip-1")?.track).toBe(1);
+ expect(moved.get("clip-3")?.track).toBe(2);
+ expect(moved.get("clip-4")?.track).toBe(3);
+ expect(moved.get("clip-2")?.track).toBe(4);
+ },
+);
+
+it.each([{ order: [0, 0.5] }, { order: [0, 0.5, 2] }])(
+ "keeps a collapsed audio group at the zone boundary ($order)",
+ ({ order }) => {
+ const layout = buildTimelineTrackInsertLayout(order, [
+ { anchorKey: 0.5, memberTracks: [1, 3] },
+ ]);
+ expect(timelineAudioRow(order, new Set([1, 3, 2]), layout.groupTracks)).toBe(1);
+ },
+);
+
+it.each([
+ { insertRow: 0, target: 0 },
+ { insertRow: null, target: 2 },
+])(
+ "detaches only new-track drops, retaining member-track behavior ($insertRow)",
+ async ({ insertRow, target }) => {
+ const hero: TimelineElement = {
+ id: "hero",
+ domId: "hero",
+ tag: "audio",
+ start: 0,
+ duration: 1,
+ track: 1,
+ audioGroup: "voice",
+ };
+ const member: TimelineElement = {
+ id: "member",
+ domId: "member",
+ tag: "audio",
+ start: 2,
+ duration: 1,
+ track: 2,
+ audioGroup: "voice",
+ };
+ const elements = [hero, member];
+ const order = [0.5, 1, 2];
+ const layout = buildTimelineTrackInsertLayout(order, [
+ { anchorKey: 0.5, memberTracks: [1, 2] },
+ ]);
+ const moved = new Map(elements.map((element) => [element.id, element]));
+ const persist = vi.fn();
+ commitDraggedClipMove(
+ {
+ pointerId: null,
+ element: hero,
+ insertRow,
+ started: true,
+ originClientX: 0,
+ originClientY: 0,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: 0,
+ pointerClientY: 0,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: 0,
+ previewTrack: target,
+ desiredTrack: target,
+ snapTime: null,
+ snapType: null,
+ },
+ {
+ elements,
+ trackOrder: order,
+ trackInsertLayout: layout,
+ onMoveElements: persist,
+ updateElement: (id, updates) => {
+ moved.set(id, { ...moved.get(id)!, ...updates });
+ },
+ },
+ );
+ await Promise.resolve();
+ expect(moved.get("hero")?.audioGroup).toBe(insertRow === null ? "voice" : undefined);
+ expect(moved.get("hero")?.track).toBe(target);
+ expect(moved.get("member")?.audioGroup).toBe("voice");
+ const edit = persist.mock.calls[0][0].find(
+ (edit: { element: TimelineElement }) => edit.element.id === "hero",
+ );
+ expect(edit.updates.audioGroup).toBe(insertRow === null ? undefined : null);
+ },
+);
+
+it("creates the first visual lane above an all-audio timeline", () => {
+ expect(
+ resolveZoneDropPlacement({
+ order: [0, 1],
+ audioTracks: new Set([0, 1]),
+ elements: [],
+ desiredTrack: 0,
+ deliberateInsertRow: null,
+ start: 2,
+ duration: 1,
+ dragKey: "new-visual",
+ isAudio: false,
+ }),
+ ).toEqual({ track: 0, insertRow: 0, start: 2 });
+});
+
+it("keeps audible-video groups in the visual zone for header drops and edge inserts", () => {
+ const order = [0, 0.5, 1, 2];
+ const layout = buildTimelineTrackInsertLayout(order, [{ anchorKey: 0.5, memberTracks: [1] }]);
+ const input = {
+ order,
+ audioTracks: new Set(),
+ groupTracks: layout.groupTracks,
+ allowedInsertRows: layout.allowedRows,
+ elements: [],
+ desiredTrack: 0.5,
+ deliberateInsertRow: null,
+ start: 2,
+ duration: 1,
+ dragKey: "video",
+ isAudio: false,
+ origin: { track: 2, start: 2 },
+ };
+ expect(timelineAudioRow(order, input.audioTracks, layout.groupTracks)).toBe(-1);
+ expect(resolveZoneDropPlacement(input)).toEqual({ track: 1, insertRow: null, start: 2 });
+ expect(resolveZoneDropPlacement({ ...input, deliberateInsertRow: 3 })).toEqual({
+ track: 0.5,
+ insertRow: 3,
+ start: 2,
+ });
+});
+
+it.each([
+ { isAudio: false, target: 1 },
+ { isAudio: true, target: 3 },
+])(
+ "maps a mixed-kind group header to its first visible member of the clip kind ($isAudio)",
+ ({ isAudio, target }) => {
+ const order = [0, 0.5, 1, 3, 2];
+ const layout = buildTimelineTrackInsertLayout(order, [
+ { anchorKey: 0.5, memberTracks: [1, 3] },
+ ]);
+ expect(
+ resolveZoneDropPlacement({
+ order,
+ audioTracks: new Set([3]),
+ groupTracks: layout.groupTracks,
+ allowedInsertRows: layout.allowedRows,
+ elements: [],
+ desiredTrack: 0.5,
+ deliberateInsertRow: null,
+ start: 2,
+ duration: 1,
+ dragKey: "clip",
+ isAudio,
+ origin: { track: 2, start: 2 },
+ }),
+ ).toEqual({ track: target, insertRow: null, start: 2 });
+ },
+);
diff --git a/packages/studio/src/player/components/timelineTrackInsertLayout.ts b/packages/studio/src/player/components/timelineTrackInsertLayout.ts
new file mode 100644
index 0000000000..2e771422b5
--- /dev/null
+++ b/packages/studio/src/player/components/timelineTrackInsertLayout.ts
@@ -0,0 +1,71 @@
+import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
+import { timelineAudioRow, isInsertAllowedForZone } from "./timelineCollision";
+
+export function timelineTrackOrderChanged(
+ previous: readonly number[],
+ current: readonly number[],
+): boolean {
+ return current.length !== previous.length || current.some((key, row) => key !== previous[row]);
+}
+
+export interface TimelineTrackInsertLayout {
+ allowedRows: ReadonlySet;
+ trackOrder: number[];
+ topologyRows: number[];
+ groupTracks: ReadonlyMap;
+}
+
+export function keyboardPickupInsertRow(
+ row: number,
+ allowed: ReadonlySet | undefined,
+): number {
+ while (row > 0 && allowed && !allowed.has(row)) row--;
+ return row;
+}
+
+export function nextKeyboardInsertRow(input: {
+ current: number;
+ step: -1 | 1;
+ order: number[];
+ layout: TimelineTrackInsertLayout | undefined;
+ audioTracks: ReadonlySet;
+ isAudio: boolean;
+}): number | null {
+ const { current, step, order, layout, audioTracks, isAudio } = input;
+ let row = Math.max(0, Math.min(order.length, current + step));
+ const allowed = layout?.allowedRows;
+ while (allowed?.has(row) === false) {
+ row += step;
+ if (row < 0 || row > order.length) return null;
+ }
+ const audioRow = timelineAudioRow(order, audioTracks, layout?.groupTracks);
+ return isInsertAllowedForZone(row, audioRow, isAudio, allowed) ? row : null;
+}
+
+/** Groups own one contiguous insertion block, including their collapsed members. */
+export function buildTimelineTrackInsertLayout(
+ order: number[],
+ groups: readonly Pick[],
+): TimelineTrackInsertLayout {
+ const allowedRows = new Set(Array.from({ length: order.length + 1 }, (_, row) => row));
+ const anchors = new Map(groups.map((group) => [group.anchorKey, group.memberTracks]));
+ const positions = new Map(order.map((key, row) => [key, row]));
+ for (const group of groups) {
+ const anchor = positions.get(group.anchorKey);
+ if (anchor === undefined) continue;
+ const end = Math.max(anchor, ...group.memberTracks.map((key) => positions.get(key) ?? anchor));
+ for (let row = anchor + 1; row <= end; row++) allowedRows.delete(row);
+ }
+ const tracks = new Set();
+ const topologyRows = [0];
+ for (const key of order) {
+ for (const track of anchors.get(key) ?? [key]) tracks.add(track);
+ topologyRows.push(tracks.size);
+ }
+ return {
+ allowedRows,
+ trackOrder: [...tracks],
+ topologyRows,
+ groupTracks: anchors,
+ };
+}
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.keyboard.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.keyboard.test.tsx
new file mode 100644
index 0000000000..5c3587f98e
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineClipDrag.keyboard.test.tsx
@@ -0,0 +1,163 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, expect, it, vi } from "vitest";
+import { useTimelineClipDrag } from "./useTimelineClipDrag";
+import { createTimelineRowGeometry } from "./timelineLayout";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import { configureTimelineTestViewport } from "./timelineTestViewport";
+import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+afterEach(() => {
+ usePlayerStore.getState().reset();
+ document.body.replaceChildren();
+});
+
+function mount(order = [0, 1, 2]) {
+ const elements: TimelineElement[] = [
+ { id: "top", tag: "div", start: 0, duration: 3, track: 0 },
+ { id: "hero", domId: "hero", tag: "div", start: 2, duration: 3, track: 1 },
+ { id: "tail", tag: "div", start: 6, duration: 3, track: 1 },
+ { id: "bottom", tag: "div", start: 0, duration: 3, track: 2 },
+ ];
+ const groups = order.includes(0.5) ? [{ anchorKey: 0.5, memberTracks: [1, 3] }] : [];
+ if (groups.length) {
+ for (const element of elements) element.tag = "audio";
+ elements[1].audioGroup = "voice";
+ elements[2].audioGroup = "voice";
+ elements.push({
+ id: "member",
+ tag: "audio",
+ start: 0,
+ duration: 2,
+ track: 3,
+ audioGroup: "voice",
+ });
+ elements.push({ id: "extra", tag: "audio", start: 0, duration: 2, track: 4 });
+ }
+ for (const element of elements) element.domId = element.id;
+ usePlayerStore.getState().setElements(elements);
+ const scroll = document.createElement("div");
+ configureTimelineTestViewport(scroll, 400);
+ const clip = document.createElement("div");
+ clip.dataset.elId = "hero";
+ clip.dataset.timelineFocusId = "clip-hero";
+ clip.tabIndex = 0;
+ scroll.append(clip);
+ document.body.append(scroll);
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ const onMoveElements = vi.fn();
+ const trackOrderRef = { current: order };
+ const trackInsertLayoutRef = { current: buildTimelineTrackInsertLayout(order, groups) };
+ let state: ReturnType;
+ function Probe() {
+ state = useTimelineClipDrag({
+ scrollRef: { current: scroll },
+ ppsRef: { current: 100 },
+ durationRef: { current: 12 },
+ trackOrderRef,
+ trackInsertLayoutRef,
+ rowGeometryRef: {
+ current: createTimelineRowGeometry(
+ trackOrderRef.current,
+ trackOrderRef.current.map(() => 48),
+ { top: 0 },
+ ),
+ },
+ onMoveElement: vi.fn(),
+ onMoveElements,
+ setShowPopover: () => {},
+ setRangeSelectionRef: { current: null },
+ });
+ return null;
+ }
+ act(() => root.render( ));
+ clip.focus();
+ const key = (key: string) =>
+ act(() => {
+ clip.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
+ });
+ return {
+ key,
+ clip,
+ onMoveElements,
+ root,
+ elements,
+ changeOrder(next: number[]) {
+ trackOrderRef.current = next;
+ trackInsertLayoutRef.current = buildTimelineTrackInsertLayout(next, groups);
+ act(() => root.render( ));
+ },
+ get drag() {
+ return state.draggedClip;
+ },
+ };
+}
+
+function expectCanceledPickup(view: ReturnType) {
+ expect(view.drag).toBeNull();
+ expect(view.onMoveElements).not.toHaveBeenCalled();
+ expect(usePlayerStore.getState().elements).toEqual(view.elements);
+}
+
+it("cancels pickup when collapsing a group changes the displayed seam", () => {
+ const view = mount([0, 0.5, 1, 3, 2, 4]);
+ view.key(" ");
+ view.key("ArrowDown");
+ expect(view.drag?.insertRow).toBe(4);
+ view.changeOrder([0, 0.5, 2, 4]);
+ expect(view.drag).toBeNull();
+ view.key("Enter");
+ expectCanceledPickup(view);
+ act(() => view.root.unmount());
+});
+
+it("picks up with Space, chooses a seam, then commits at that index once with Enter", async () => {
+ const view = mount();
+ view.key(" ");
+ expect(view.drag).toMatchObject({
+ pointerId: null,
+ started: true,
+ insertRow: 1,
+ previewStart: 2,
+ });
+ view.key("ArrowUp");
+ expect(view.drag?.insertRow).toBe(0);
+ await act(async () => view.key("Enter"));
+ expect(view.drag).toBeNull();
+ expect(view.onMoveElements).toHaveBeenCalledTimes(1);
+ const moved = usePlayerStore.getState().elements;
+ expect(moved.find((e) => e.id === "hero")?.track).toBe(0);
+ expect(moved.find((e) => e.id === "top")?.track).toBe(1);
+ view.key("Enter");
+ expect(view.onMoveElements).toHaveBeenCalledTimes(1);
+ act(() => view.root.unmount());
+});
+
+it("Escape cancels the opened lane without moving a clip or adding a track", () => {
+ const view = mount();
+ view.key(" ");
+ view.key("ArrowDown");
+ expect(view.drag?.insertRow).toBe(2);
+ view.key("Escape");
+ expectCanceledPickup(view);
+ act(() => view.root.unmount());
+});
+
+it("leaves Space on a nested fade slider to the slider", () => {
+ const view = mount();
+ const slider = document.createElement("div");
+ slider.setAttribute("role", "slider");
+ slider.tabIndex = 0;
+ view.clip.append(slider);
+ slider.focus();
+ const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true });
+ act(() => {
+ slider.dispatchEvent(event);
+ });
+ expect(view.drag).toBeNull();
+ expect(event.defaultPrevented).toBe(false);
+ act(() => view.root.unmount());
+});
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index 65772912a5..3e4610e7ce 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -16,6 +16,7 @@ import type { StackingPatch } from "./timelineStackingSync";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import {
computeDragPreview,
+ createKeyboardClipDrag,
computeResizePreview,
trimPreviewTime,
previewGroupResize,
@@ -29,6 +30,19 @@ import type {
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { dropMisalignedTrimPartners, linkedGestureKeys } from "./audioClipLink";
import { isLinkedSelectionOn } from "../../utils/linkedClipPreferences";
+import { useTimelineClipCapabilities } from "./timelineReadOnly";
+import { timelineClipFocusId } from "./timelineNavigationIdentity";
+import {
+ handleClipPickupKeyboardEvent,
+ scrollKeyboardInsertRow,
+ timelineKeyboardEventTarget,
+} from "./useTimelineKeyboardActor";
+import {
+ timelineTrackOrderChanged,
+ nextKeyboardInsertRow,
+ keyboardPickupInsertRow,
+ type TimelineTrackInsertLayout,
+} from "./timelineTrackInsertLayout";
import type { TimelineRowGeometry } from "./timelineLayout";
import {
mountTimelineClipDragGestureLifecycle,
@@ -50,17 +64,10 @@ interface UseTimelineClipDragInput {
ppsRef: React.RefObject;
durationRef: React.RefObject;
trackOrderRef: React.RefObject;
+ trackInsertLayoutRef?: React.RefObject;
rowGeometryRef?: React.RefObject;
- onMoveElement?: (
- element: TimelineElement,
- updates: Pick,
- ) => Promise | void;
- onMoveElements?: (
- edits: Array<{
- element: TimelineElement;
- updates: Pick;
- }>,
- ) => Promise | void;
+ onMoveElement?: TimelineEditCallbacks["onMoveElement"];
+ onMoveElements?: TimelineEditCallbacks["onMoveElements"];
onResizeElement?: (
element: TimelineElement,
updates: Pick,
@@ -92,6 +99,7 @@ export function useTimelineClipDrag({
ppsRef,
durationRef,
trackOrderRef,
+ trackInsertLayoutRef,
rowGeometryRef,
onMoveElement,
onMoveElements,
@@ -107,6 +115,8 @@ export function useTimelineClipDrag({
refreshAfterLaneMove,
sessionEpoch = 0,
}: UseTimelineClipDragInput) {
+ const getClipCapabilities = useTimelineClipCapabilities();
+ const commitGestureRef = useRef<() => void>(() => {});
const updateElement = usePlayerStore((s) => s.updateElement);
const rawBeatTimes = usePlayerStore((s) => s.beatAnalysis?.beatTimes ?? EMPTY_BEAT_TIMES);
const rawBeatStrengths = usePlayerStore((s) => s.beatAnalysis?.beatStrengths ?? EMPTY_BEAT_TIMES);
@@ -207,7 +217,7 @@ export function useTimelineClipDrag({
const cancelGestureRef = useRef<
(options?: { updateReact?: boolean; suppressClick?: boolean }) => boolean
>(() => false);
- const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number) => {
+ const beginGesture = useCallback((kind: TimelineGestureKind, pointerId: number | null) => {
if (lifecycleRef.current.phase === "active") cancelGestureRef.current();
lifecycleRef.current = {
kind,
@@ -310,14 +320,25 @@ export function useTimelineClipDrag({
pps: ppsRef.current,
duration: durationRef.current,
trackOrder: trackOrderRef.current,
+ allowedInsertRows: trackInsertLayoutRef?.current?.allowedRows,
+ groupTracks: trackInsertLayoutRef?.current?.groupTracks,
rowHeights: rowGeometryRef?.current.rowHeights,
+ rowGeometry: rowGeometryRef?.current,
elements: elementsRef.current,
selectedKeys: gestureSelectedKeysRef.current,
buildSnapTargets,
audioTracks: dragAudioTracksRef.current,
});
},
- [scrollRef, ppsRef, durationRef, trackOrderRef, rowGeometryRef, buildSnapTargets],
+ [
+ scrollRef,
+ ppsRef,
+ durationRef,
+ trackOrderRef,
+ trackInsertLayoutRef,
+ rowGeometryRef,
+ buildSnapTargets,
+ ],
);
// Recompute the trim preview for a pointer x. Shared by the pointermove resize
@@ -445,6 +466,8 @@ export function useTimelineClipDrag({
useMountEffect(() =>
mountTimelineClipDragGestureLifecycle({
+ trackInsertLayoutRef,
+ commitGestureRef,
onStackingPatchesRef,
refreshAfterLaneMoveRef,
readZIndexRef,
@@ -479,10 +502,91 @@ export function useTimelineClipDrag({
}),
);
+ useEffect(() => {
+ const movePickup = (drag: DraggedClipState, step: -1 | 1, viewport: HTMLDivElement | null) => {
+ const audioTracks = getTimelineElementIndexes(elementsRef.current).audioTracks;
+ const row = nextKeyboardInsertRow({
+ current: drag.insertRow ?? 0,
+ step,
+ order: trackOrderRef.current,
+ layout: trackInsertLayoutRef?.current,
+ audioTracks,
+ isAudio: audioTracks.has(drag.element.track),
+ });
+ if (row === null) return;
+ publishDraggedClip({ ...drag, insertRow: row });
+ if (viewport && rowGeometryRef)
+ scrollKeyboardInsertRow(viewport, rowGeometryRef.current, row);
+ };
+ const activePickup = (
+ event: KeyboardEvent,
+ drag: DraggedClipState,
+ viewport: HTMLDivElement | null,
+ ) => {
+ handleClipPickupKeyboardEvent(event, {
+ move: (step) => movePickup(drag, step, viewport),
+ commit: commitGestureRef.current,
+ cancel: cancelGestureRef.current,
+ focus: () =>
+ usePlayerStore
+ .getState()
+ .requestTimelineFocus(timelineClipFocusId(drag.element.key ?? drag.element.id)),
+ });
+ };
+ const pickup = (event: KeyboardEvent, viewport: HTMLDivElement) => {
+ const target = timelineKeyboardEventTarget(event.target, viewport);
+ const key = target?.dataset.elId;
+ if (key === undefined || !onMoveElementRef.current) return;
+ const element = getTimelineElementIndexes(elementsRef.current).byKey.get(key);
+ if (!element || !getClipCapabilities(element).canMove) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ setShowPopover(false);
+ setRangeSelectionRef.current?.(null);
+ const row = keyboardPickupInsertRow(
+ trackOrderRef.current.indexOf(element.track),
+ trackInsertLayoutRef?.current?.allowedRows,
+ );
+ setDraggedClip(createKeyboardClipDrag(element, row, viewport.scrollLeft, viewport.scrollTop));
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ const drag = draggedClipRef.current;
+ const viewport = scrollRef.current;
+ if (drag) {
+ if (drag.pointerId === null) activePickup(event, drag, viewport);
+ return;
+ }
+ if (resizingClipRef.current) return;
+ if (event.key !== " " || event.repeat) return;
+ if (viewport) pickup(event, viewport);
+ };
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, [
+ getClipCapabilities,
+ publishDraggedClip,
+ rowGeometryRef,
+ scrollRef,
+ setDraggedClip,
+ setRangeSelectionRef,
+ setShowPopover,
+ trackOrderRef,
+ trackInsertLayoutRef,
+ ]);
+
useEffect(() => {
cancelGestureRef.current();
}, [sessionEpoch]);
+ const previousOrderRef = useRef(trackOrderRef.current);
+ useEffect(() => {
+ const order = trackOrderRef.current;
+ const previous = previousOrderRef.current;
+ previousOrderRef.current = order;
+ if (draggedClipRef.current && timelineTrackOrderChanged(previous, order))
+ cancelGestureRef.current();
+ });
+
return {
draggedClip,
setDraggedClip,
diff --git a/packages/studio/src/player/components/useTimelineDisplayLayout.test.tsx b/packages/studio/src/player/components/useTimelineDisplayLayout.test.tsx
new file mode 100644
index 0000000000..0a20d7c339
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineDisplayLayout.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it } from "vitest";
+import { useTimelineDisplayLayout } from "./useTimelineTrackLayout";
+import { createTimelineRowGeometry } from "./timelineLayout";
+import type { DraggedClipState } from "./timelineClipDragTypes";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+it("opens one full lane at the insertion index without changing committed tracks", () => {
+ const order = [7, 3, 9];
+ const geometry = createTimelineRowGeometry(order, [48, 104, 48], { top: 0 });
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ const drag: DraggedClipState = {
+ pointerId: 1,
+ element: { id: "clip", tag: "div", start: 1, duration: 2, track: 9 },
+ originClientX: 0,
+ originClientY: 0,
+ originScrollLeft: 0,
+ originScrollTop: 0,
+ pointerClientX: 0,
+ pointerClientY: 0,
+ pointerOffsetX: 0,
+ pointerOffsetY: 0,
+ previewStart: 1,
+ previewTrack: 3,
+ insertRow: 1,
+ snapTime: null,
+ snapType: null,
+ started: true,
+ };
+ function Probe({ active }: { active: DraggedClipState | null }) {
+ const layout = useTimelineDisplayLayout(active, order, geometry);
+ return (
+
+ {JSON.stringify({
+ order: layout.displayTrackOrder,
+ pinned: layout.draggedRowKey,
+ heights: layout.displayRowHeights,
+ below: layout.rowGeometry.getRowTop(2),
+ })}
+
+ );
+ }
+ act(() => root.render( ));
+ const seen = JSON.parse(host.textContent!);
+ expect(seen.order).toHaveLength(4);
+ expect(seen.pinned).toBe(-1);
+ expect(seen.order.filter((key: number) => order.includes(key))).toEqual(order);
+ expect(order.includes(seen.order[1])).toBe(false);
+ expect(seen.heights).toEqual([48, 48, 104, 48]);
+ expect(seen.below).toBe(120);
+ expect(order).toEqual([7, 3, 9]);
+ act(() => root.render( ));
+ expect(JSON.parse(host.textContent!).order).toEqual(order);
+ act(() => root.unmount());
+});
diff --git a/packages/studio/src/player/components/useTimelineKeyboardActor.ts b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
index f1ba9e341e..43d0524a5d 100644
--- a/packages/studio/src/player/components/useTimelineKeyboardActor.ts
+++ b/packages/studio/src/player/components/useTimelineKeyboardActor.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo, type FocusEvent, type KeyboardEvent, type RefObject } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
-import type { TimelineRowGeometry } from "./timelineLayout";
+import { RULER_H, TRACK_H, type TimelineRowGeometry } from "./timelineLayout";
import {
isTimelineNavigationKey,
locateTimelineLogicalTarget,
@@ -17,14 +17,92 @@ interface TimelineKeyboardActorInput {
onDrillDown?: (element: TimelineElement) => void;
}
-function eventTarget(event: FocusEvent | KeyboardEvent): HTMLElement | null {
- if (!(event.target instanceof Element)) return null;
+type ClipPickupAction =
+ | { kind: "move"; step: -1 | 1 }
+ | { kind: "commit" }
+ | { kind: "cancel" }
+ | { kind: "focus" }
+ | { kind: "consume" };
+
+function clipPickupKeyboardAction(event: {
+ key: string;
+ repeat: boolean;
+}): ClipPickupAction | null {
+ switch (event.key) {
+ case "ArrowUp":
+ return { kind: "move", step: -1 };
+ case "ArrowDown":
+ return { kind: "move", step: 1 };
+ case "Enter":
+ return { kind: event.repeat ? "focus" : "commit" };
+ case "Escape":
+ return { kind: "cancel" };
+ case " ":
+ return { kind: "consume" };
+ default:
+ return null;
+ }
+}
+
+export function handleClipPickupKeyboardEvent(
+ event: globalThis.KeyboardEvent,
+ actions: {
+ move: (step: -1 | 1) => void;
+ commit: () => void;
+ cancel: () => void;
+ focus: () => void;
+ },
+): void {
+ const action = clipPickupKeyboardAction(event);
+ if (!action) return;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ switch (action.kind) {
+ case "move":
+ actions.move(action.step);
+ break;
+ case "commit":
+ actions.commit();
+ actions.focus();
+ break;
+ case "cancel":
+ actions.cancel();
+ actions.focus();
+ break;
+ case "focus":
+ actions.focus();
+ break;
+ case "consume":
+ break;
+ default: {
+ const unreachable: never = action;
+ throw new Error(`Unknown pickup action: ${unreachable}`);
+ }
+ }
+}
+
+export function scrollKeyboardInsertRow(
+ viewport: HTMLDivElement,
+ geometry: TimelineRowGeometry,
+ row: number,
+): void {
+ const top = geometry.getRowTop(row);
+ if (top < viewport.scrollTop + RULER_H) viewport.scrollTop = Math.max(0, top - RULER_H);
+ else if (top + TRACK_H > viewport.scrollTop + viewport.clientHeight)
+ viewport.scrollTop = top + TRACK_H - viewport.clientHeight;
+}
+
+export function timelineKeyboardEventTarget(
+ target: EventTarget | null,
+ container: HTMLElement,
+): HTMLElement | null {
+ if (!(target instanceof Element)) return null;
// Header actions stay native Tab stops because they have no row-level shortcut.
// The nearest interactive ancestor wins so their events never masquerade as row events.
- const target = event.target.closest(
+ const nearest = target.closest(
"button, input, select, textarea, a[href], [contenteditable], [role='slider'], [data-timeline-focus-id]",
);
- return target?.dataset.timelineFocusId && event.currentTarget.contains(target) ? target : null;
+ return nearest?.dataset.timelineFocusId && container.contains(nearest) ? nearest : null;
}
function viewportPageSize(
@@ -87,7 +165,8 @@ export function useTimelineKeyboardActor({
const onFocus = useCallback(
(event: FocusEvent) => {
- const id = eventTarget(event)?.dataset.timelineFocusId;
+ const id = timelineKeyboardEventTarget(event.target, event.currentTarget)?.dataset
+ .timelineFocusId;
if (id && id !== focusedTargetId) usePlayerStore.getState().requestTimelineFocus(id);
},
// ponytail: This closure must see the current id or coordinator-driven focus bumps the nonce twice.
@@ -98,7 +177,7 @@ export function useTimelineKeyboardActor({
// One handler owns navigation, context-menu, and disclosure keyboard semantics.
// fallow-ignore-next-line complexity
(event: KeyboardEvent) => {
- const targetElement = eventTarget(event);
+ const targetElement = timelineKeyboardEventTarget(event.target, event.currentTarget);
const id = targetElement?.dataset.timelineFocusId;
if (!targetElement || !id) return;
const located = locateTimelineLogicalTarget(logicalRows, id);
diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx
index d5c9d461a5..4843eef65e 100644
--- a/packages/studio/src/player/components/useTimelineProviderState.tsx
+++ b/packages/studio/src/player/components/useTimelineProviderState.tsx
@@ -155,6 +155,7 @@ export function useTimelineProviderState({
trackStyles,
trackOrder,
trackOrderRef,
+ trackInsertLayoutRef,
laneCounts,
rowGeometry,
rowGeometryRef,
@@ -213,6 +214,7 @@ export function useTimelineProviderState({
ppsRef,
durationRef,
trackOrderRef,
+ trackInsertLayoutRef,
rowGeometryRef,
onMoveElement: pinnedOnMoveElement,
onMoveElements: pinnedOnMoveElements,
@@ -288,7 +290,7 @@ export function useTimelineProviderState({
allowHorizontal: zoomMode === "manual",
viewport,
sessionEpoch,
- draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
+ draggedRowKey: displayLayout.draggedRowKey,
resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.test.tsx b/packages/studio/src/player/components/useTimelineRowVirtualization.test.tsx
new file mode 100644
index 0000000000..6cface235a
--- /dev/null
+++ b/packages/studio/src/player/components/useTimelineRowVirtualization.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it } from "vitest";
+import { createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout";
+import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+it("leaves the viewport still when a live preview lane is removed before the committed row arrives", () => {
+ const scroll = document.createElement("div");
+ scroll.scrollTop = 160;
+ const scrollRef = { current: scroll };
+ const lastScrollLeftRef = { current: 0 };
+ const host = document.createElement("div");
+ const root = createRoot(host);
+ const normal = createTimelineRowGeometry([0, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48]);
+ const preview = createTimelineRowGeometry([0, -1, 1, 2, 3, 4, 5], [48, 48, 48, 48, 48, 48, 48]);
+ const committed = createTimelineRowGeometry([0, 1, 2, 3, 4, 5, 6], [48, 48, 48, 48, 48, 48, 48]);
+ function Probe({
+ geometry,
+ draggedRowKey,
+ }: {
+ geometry: TimelineRowGeometry;
+ draggedRowKey?: number;
+ }) {
+ useTimelineRowVirtualization({
+ scrollRef,
+ rowGeometry: geometry,
+ draggedRowKey,
+ sessionEpoch: 0,
+ viewport: {
+ scrollTop: 160,
+ scrollLeft: 0,
+ clientWidth: 800,
+ clientHeight: 200,
+ scrollWidth: 800,
+ scrollHeight: 600,
+ isScrolling: false,
+ },
+ elements: [],
+ selectedElementId: null,
+ lastScrollLeftRef,
+ syncScrollViewport: () => {},
+ });
+ return null;
+ }
+ act(() => root.render( ));
+ act(() => root.render( ));
+ expect(scroll.scrollTop).toBe(160);
+ act(() => root.render( ));
+ expect(scroll.scrollTop).toBe(160);
+ act(() => root.render( ));
+ expect(scroll.scrollTop).toBe(160);
+ act(() => root.unmount());
+});
diff --git a/packages/studio/src/player/components/useTimelineRowVirtualization.ts b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
index 73c23e639c..c5209f612d 100644
--- a/packages/studio/src/player/components/useTimelineRowVirtualization.ts
+++ b/packages/studio/src/player/components/useTimelineRowVirtualization.ts
@@ -103,6 +103,7 @@ export function useTimelineRowVirtualization({
});
const previousLayoutRef = useRef(rowGeometry);
+ const previousDraggedRowKeyRef = useRef(draggedRowKey);
const previousSessionEpochRef = useRef(sessionEpoch);
useLayoutEffect(() => {
const scroll = scrollRef.current;
@@ -120,7 +121,7 @@ export function useTimelineRowVirtualization({
previousGeometry,
rowGeometry,
scroll.scrollTop,
- draggedRowKey !== undefined,
+ draggedRowKey !== undefined || previousDraggedRowKeyRef.current !== undefined,
);
if (nextScrollTop !== scroll.scrollTop) {
scroll.scrollTop = nextScrollTop;
@@ -128,6 +129,7 @@ export function useTimelineRowVirtualization({
}
}
previousLayoutRef.current = rowGeometry;
+ previousDraggedRowKeyRef.current = draggedRowKey;
}, [draggedRowKey, lastScrollLeftRef, rowGeometry, scrollRef, sessionEpoch, syncScrollViewport]);
return {
diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts
index 29da97de6f..ab70f1cd25 100644
--- a/packages/studio/src/player/components/useTimelineTrackLayout.ts
+++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts
@@ -6,6 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { DraggedClipState } from "./timelineClipDragTypes";
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
+import { buildTimelineTrackInsertLayout } from "./timelineTrackInsertLayout";
import {
TRACK_H,
createTimelineRowGeometry,
@@ -246,6 +247,12 @@ export function useTimelineTrackLayout(
useTimelineTrackDerivations(expandedElements);
const trackOrderRef = useRef(trackOrder);
trackOrderRef.current = trackOrder;
+ const trackInsertLayout = useMemo(
+ () => (groups.length > 0 ? buildTimelineTrackInsertLayout(trackOrder, groups) : undefined),
+ [trackOrder, groups],
+ );
+ const trackInsertLayoutRef = useRef(trackInsertLayout);
+ trackInsertLayoutRef.current = trackInsertLayout;
const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights(
tracks,
gsapAnimations,
@@ -260,6 +267,7 @@ export function useTimelineTrackLayout(
trackStyles,
trackOrder,
trackOrderRef,
+ trackInsertLayoutRef,
laneCounts,
rowGeometry,
rowGeometryRef,
@@ -283,27 +291,40 @@ function useDisplayRowHeights(
);
}
+function insertPreviewTrack(trackOrder: number[], preview: number) {
+ const result: number[] = [];
+ let inserted = false;
+ for (const key of trackOrder) {
+ if (!inserted && Number.isInteger(key) && key > preview) {
+ result.push(preview);
+ inserted = true;
+ }
+ result.push(key);
+ }
+ if (!inserted) result.push(preview);
+ return result;
+}
+
function useDisplayTrackOrder(draggedClip: DraggedClipState | null, trackOrder: number[]) {
+ const started = draggedClip?.started === true;
+ const insertRow = draggedClip?.insertRow ?? null;
+ const previewTrack = draggedClip?.previewTrack;
return useMemo(() => {
- if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder;
+ if (!started || previewTrack === undefined) return trackOrder;
+ if (insertRow !== null) {
+ const pendingKey = trackOrder.reduce((lowest, key) => Math.min(lowest, key), 0) - 1;
+ const displayOrder = [...trackOrder];
+ displayOrder.splice(insertRow, 0, pendingKey);
+ return displayOrder;
+ }
+ if (trackOrder.includes(previewTrack)) return trackOrder;
// A group's members sit out of raw numeric order (pulled under their
// anchor row), so a plain numeric sort here would undo that grouping the
// moment a clip drags onto a brand-new track. Insert the new preview
// track only relative to other REAL (integer) tracks, leaving any
// fractional group-anchor keys exactly where grouping placed them.
- const preview = draggedClip.previewTrack;
- const result: number[] = [];
- let inserted = false;
- for (const key of trackOrder) {
- if (!inserted && Number.isInteger(key) && key > preview) {
- result.push(preview);
- inserted = true;
- }
- result.push(key);
- }
- if (!inserted) result.push(preview);
- return result;
- }, [draggedClip, trackOrder]);
+ return insertPreviewTrack(trackOrder, previewTrack);
+ }, [started, insertRow, previewTrack, trackOrder]);
}
export function useTimelineDisplayLayout(
@@ -317,7 +338,15 @@ export function useTimelineDisplayLayout(
() => createTimelineRowGeometry(displayTrackOrder, displayRowHeights, rowGeometry.padding),
[displayTrackOrder, displayRowHeights, rowGeometry.padding],
);
+ let draggedRowKey: number | undefined;
+ if (draggedClip?.started) {
+ draggedRowKey =
+ draggedClip.insertRow !== null
+ ? displayRowGeometry.rowKeys[draggedClip.insertRow]
+ : draggedClip.previewTrack;
+ }
return {
+ draggedRowKey,
displayTrackOrder,
displayRowHeights: displayRowGeometry.rowHeights,
rowGeometry: displayRowGeometry,
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
index 1f0df8de61..aa70c5b503 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.test.ts
@@ -24,4 +24,11 @@ describe("timelineElementsChanged", () => {
const linked = { ...clip, link: "lk-1" };
expect(timelineElementsChanged([linked], [clip])).toBe(true);
});
+
+ it("sees restored group membership with identical timing and lane", () => {
+ const grouped = { ...clip, audioGroup: "G" };
+ expect(timelineElementsChanged([clip], [grouped])).toBe(true);
+ expect(timelineElementsChanged([grouped], [clip])).toBe(true);
+ expect(timelineElementsChanged([grouped], [{ ...grouped }])).toBe(false);
+ });
});
diff --git a/packages/studio/src/player/hooks/timelinePlayerSync.ts b/packages/studio/src/player/hooks/timelinePlayerSync.ts
index 4bba7f1572..071a1579af 100644
--- a/packages/studio/src/player/hooks/timelinePlayerSync.ts
+++ b/packages/studio/src/player/hooks/timelinePlayerSync.ts
@@ -11,6 +11,7 @@ const RENDERED_FIELDS: readonly (keyof TimelineElement)[] = [
"volume",
"playbackRate",
"hidden",
+ "audioGroup",
"audioGroupVolume",
"audioGroupHidden",
"fadeIn",
diff --git a/packages/studio/src/player/lib/thumbnailPageLifecycle.test.ts b/packages/studio/src/player/lib/thumbnailPageLifecycle.test.ts
new file mode 100644
index 0000000000..b8366f875d
--- /dev/null
+++ b/packages/studio/src/player/lib/thumbnailPageLifecycle.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from "vitest";
+import { bindThumbnailPageLifecycle } from "./thumbnailPageLifecycle";
+import {
+ ThumbnailScheduler,
+ type ThumbnailRequest,
+ type ThumbnailLoadedResult,
+} from "./thumbnailScheduler";
+
+function mountLifetime() {
+ const scheduler = new ThumbnailScheduler();
+ const page = new EventTarget();
+ const document = Object.assign(new EventTarget(), { hidden: false });
+ const unbind = bindThumbnailPageLifecycle(page, document, scheduler);
+ return { scheduler, page, document, unbind };
+}
+
+describe("thumbnail document lifetime", () => {
+ it("cancels every active thumbnail kind on pagehide and resumes retained leases", async () => {
+ const { scheduler, page, unbind } = mountLifetime();
+ const signals: AbortSignal[] = [];
+ const kinds = ["composition", "image", "video", "waveform"] as const;
+ const requests = kinds.map((kind): ThumbnailRequest => {
+ let attempts = 0;
+ return {
+ key: kind,
+ projectId: "scene",
+ sessionEpoch: 0,
+ kind,
+ priority: "visible",
+ load: (signal: AbortSignal) => {
+ signals.push(signal);
+ attempts++;
+ if (attempts > 1)
+ return Promise.resolve({
+ value: { kind: "image", url: kind, aspect: 1 },
+ weight: 1,
+ });
+ return new Promise((_, reject) =>
+ signal.addEventListener("abort", () =>
+ reject(new DOMException("Aborted", "AbortError")),
+ ),
+ );
+ },
+ };
+ });
+ const leases = requests.map((request) => scheduler.acquire(request, () => {}));
+ expect(signals).toHaveLength(4);
+ page.dispatchEvent(new Event("pagehide"));
+ expect(signals.map((signal) => signal.aborted)).toEqual([true, true, true, true]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
+ "queued",
+ "queued",
+ "queued",
+ "queued",
+ ]);
+ page.dispatchEvent(new Event("pageshow"));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(signals).toHaveLength(8);
+ expect(requests.map((request) => scheduler.getSnapshot(request).status)).toEqual([
+ "ready",
+ "ready",
+ "ready",
+ "ready",
+ ]);
+ leases.forEach((lease) => lease.release());
+ unbind();
+ });
+
+ it("holds new jobs while away and preserves a preview reload hold on pageshow", async () => {
+ const { scheduler, page, document, unbind } = mountLifetime();
+ const started: string[] = [];
+ const make = (kind: "composition" | "image"): ThumbnailRequest => ({
+ key: kind,
+ projectId: "scene",
+ kind,
+ priority: "visible",
+ load: async () => {
+ started.push(kind);
+ return { value: { kind: "image", url: kind, aspect: 1 }, weight: 1 };
+ },
+ });
+ scheduler.setPreviewReloading(true);
+ page.dispatchEvent(new Event("pagehide"));
+ const leases = [make("composition"), make("image")].map((request) =>
+ scheduler.acquire(request, () => {}),
+ );
+ expect(started).toEqual([]);
+ document.hidden = true;
+ page.dispatchEvent(new Event("pageshow"));
+ expect(started).toEqual([]);
+ document.hidden = false;
+ document.dispatchEvent(new Event("visibilitychange"));
+ expect(started).toEqual(["image"]);
+ scheduler.setPreviewReloading(false);
+ expect(started).toEqual(["image", "composition"]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ leases.forEach((lease) => lease.release());
+ unbind();
+ });
+
+ it("removes the lifecycle listeners when unbound", () => {
+ const { scheduler, page, unbind } = mountLifetime();
+ unbind();
+ page.dispatchEvent(new Event("pagehide"));
+ let started = false;
+ const lease = scheduler.acquire(
+ {
+ key: "poster",
+ projectId: "scene",
+ kind: "image",
+ priority: "visible",
+ load: async () => {
+ started = true;
+ return { value: { kind: "image", url: "poster", aspect: 1 }, weight: 1 };
+ },
+ },
+ () => {},
+ );
+ expect(started).toBe(true);
+ lease.release();
+ });
+});
diff --git a/packages/studio/src/player/lib/thumbnailPageLifecycle.ts b/packages/studio/src/player/lib/thumbnailPageLifecycle.ts
new file mode 100644
index 0000000000..109c22ca70
--- /dev/null
+++ b/packages/studio/src/player/lib/thumbnailPageLifecycle.ts
@@ -0,0 +1,27 @@
+import { thumbnailScheduler, type ThumbnailScheduler } from "./thumbnailScheduler";
+
+export function bindThumbnailPageLifecycle(
+ page: EventTarget,
+ document: EventTarget & { readonly hidden: boolean },
+ scheduler: ThumbnailScheduler = thumbnailScheduler,
+): () => void {
+ let left = false;
+ const sync = () => scheduler.setPageHidden(left || document.hidden);
+ const hide = () => {
+ left = true;
+ sync();
+ };
+ const show = () => {
+ left = false;
+ sync();
+ };
+ page.addEventListener("pagehide", hide);
+ page.addEventListener("pageshow", show);
+ document.addEventListener("visibilitychange", sync);
+ sync();
+ return () => {
+ page.removeEventListener("pagehide", hide);
+ page.removeEventListener("pageshow", show);
+ document.removeEventListener("visibilitychange", sync);
+ };
+}
diff --git a/packages/studio/src/player/lib/thumbnailScheduler.ts b/packages/studio/src/player/lib/thumbnailScheduler.ts
index 5c2cbfbd5c..be379ce082 100644
--- a/packages/studio/src/player/lib/thumbnailScheduler.ts
+++ b/packages/studio/src/player/lib/thumbnailScheduler.ts
@@ -117,6 +117,7 @@ export class ThumbnailScheduler {
private nextSequence = 1;
private scrolling = false;
private previewReloading = false;
+ private pageHidden = false;
private cacheBytes = 0;
private waveformCacheBytes = 0;
private readonly activeByBucket = { video: 0, composition: 0, general: 0 };
@@ -226,11 +227,27 @@ export class ThumbnailScheduler {
}
for (const entry of this.entries.values()) {
if (entry.state !== "loading" || entry.request.kind !== "composition") continue;
- entry.preempted = true;
- entry.controller?.abort();
+ this.preempt(entry);
}
}
+ setPageHidden(hidden: boolean): void {
+ if (this.pageHidden === hidden) return;
+ this.pageHidden = hidden;
+ if (!hidden) {
+ this.pump();
+ return;
+ }
+ for (const entry of this.entries.values()) {
+ if (entry.state === "loading") this.preempt(entry);
+ }
+ }
+
+ private preempt(entry: ThumbnailEntry): void {
+ entry.preempted = true;
+ entry.controller?.abort();
+ }
+
/** Evict project cache entries that are no longer owned by mounted consumers. */
invalidateProject(projectId: string): void {
for (const [key, entry] of this.entries) {
@@ -269,6 +286,7 @@ export class ThumbnailScheduler {
}
private pump(): void {
+ if (this.pageHidden) return;
const queued = Array.from(this.entries.values())
.filter((entry) => entry.state === "queued" && entry.leases.size > 0)
.sort((left, right) => {
diff --git a/packages/studio/tests/e2e/fixtures/ghost-lane/groups.html b/packages/studio/tests/e2e/fixtures/ghost-lane/groups.html
new file mode 100644
index 0000000000..89816420d7
--- /dev/null
+++ b/packages/studio/tests/e2e/fixtures/ghost-lane/groups.html
@@ -0,0 +1,44 @@
+
+
+
+
+ Grouped track drop fixture
+
+
+
+
+
+
+
+
+
diff --git a/packages/studio/tests/e2e/fixtures/ghost-lane/hyperframes.json b/packages/studio/tests/e2e/fixtures/ghost-lane/hyperframes.json
new file mode 100644
index 0000000000..a8a6bd951b
--- /dev/null
+++ b/packages/studio/tests/e2e/fixtures/ghost-lane/hyperframes.json
@@ -0,0 +1 @@
+{ "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json" }
diff --git a/packages/studio/tests/e2e/fixtures/ghost-lane/index.html b/packages/studio/tests/e2e/fixtures/ghost-lane/index.html
new file mode 100644
index 0000000000..c09ee4d13a
--- /dev/null
+++ b/packages/studio/tests/e2e/fixtures/ghost-lane/index.html
@@ -0,0 +1,151 @@
+
+
+
+
+ New track fixture
+
+
+
+
+
+ Opening title
+
+
+ Product walkthrough final approved cut with alternate opening and captions
+
+
+ Detail shot
+
+
+ Supporting graphics
+
+
+ Closing title
+
+
+ Music title
+
+
+ Voiceover title
+
+
+ Lower thirds
+
+
+ Screen recording
+
+
+ Logo animation
+
+
+ Second cut
+
+
+
+
diff --git a/packages/studio/tests/e2e/thumbnail-save.mjs b/packages/studio/tests/e2e/thumbnail-save.mjs
new file mode 100644
index 0000000000..c4d0a40dfd
--- /dev/null
+++ b/packages/studio/tests/e2e/thumbnail-save.mjs
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { createServer, request } from "node:http";
+import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { launchStudioChrome } from "./chrome-executable.mjs";
+
+const backend = new URL(process.env.STUDIO_URL ?? "http://127.0.0.1:5190");
+assert.equal(backend.protocol, "http:", "The local regression proxy requires HTTP");
+const studio = fileURLToPath(new URL("../../", import.meta.url));
+const fixture = join(dirname(fileURLToPath(import.meta.url)), "fixtures/ghost-lane");
+const projects = [];
+const held = new Map();
+const proxy = createServer((incoming, response) => {
+ const url = new URL(incoming.url ?? "/", backend);
+ // Cache-only poster reads never enter the server's render queue.
+ if (
+ /\/api\/projects\/[^/]+\/thumbnail\//.test(url.pathname) &&
+ url.searchParams.get("cached") !== "1"
+ ) {
+ held.set(response, incoming.url);
+ response.once("close", () => held.delete(response));
+ return;
+ }
+ forward(incoming, response, url.pathname + url.search);
+});
+function forward(incoming, response, path) {
+ const outgoing = request(
+ backend,
+ {
+ path,
+ method: incoming.method,
+ headers: incoming.headers,
+ },
+ (upstream) => {
+ response.writeHead(upstream.statusCode ?? 502, upstream.headers);
+ upstream.pipe(response);
+ },
+ );
+ outgoing.on("error", () => {
+ if (!response.destroyed) {
+ response.writeHead(502);
+ response.end("Upstream unavailable");
+ }
+ });
+ response.once("close", () => {
+ if (!response.writableEnded) outgoing.destroy();
+ });
+ incoming.pipe(outgoing);
+}
+const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+async function waitFor(predicate, message, bound = 15_000) {
+ const deadline = Date.now() + bound;
+ while (!predicate()) {
+ assert(Date.now() < deadline, message);
+ await pause(25);
+ }
+}
+const heldFor = (project) =>
+ [...held.values()].filter((path) => path.includes(`/projects/${project}/`)).length;
+const clip =
+ '[data-clip][data-el-id$="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"]';
+let browser;
+try {
+ await new Promise((resolve) => proxy.listen(0, "127.0.0.1", resolve));
+ const address = proxy.address();
+ assert(address && typeof address !== "string");
+ const proxyStatus = await new Promise((resolve, reject) => {
+ const probe = request(
+ {
+ hostname: "127.0.0.1",
+ port: address.port,
+ path: "http://127.0.0.1:1/",
+ signal: AbortSignal.timeout(5000),
+ },
+ (response) => {
+ response.resume();
+ response.once("error", reject);
+ response.once("end", () => resolve(response.statusCode));
+ },
+ );
+ probe.once("error", reject);
+ probe.end();
+ });
+ assert.equal(proxyStatus, 200, "An absolute request target cannot replace the Studio backend");
+ ({ browser } = await launchStudioChrome());
+ let previous;
+ let page;
+ for (let index = 0; index < 3; index++) {
+ const id = `thumbnail-lifetime-${process.pid}-${index}`;
+ const dir = join(studio, "data/projects", id);
+ mkdirSync(dir, { recursive: true });
+ projects.push({ id, dir });
+ for (const name of ["index.html", "hyperframes.json"])
+ copyFileSync(join(fixture, name), join(dir, name));
+ page = await browser.newPage();
+ await page.bringToFront();
+ if (previous) {
+ await previous.waitForFunction(() => document.hidden, { timeout: 5000 });
+ await previous.evaluate(() => new Promise((resolve) => setTimeout(resolve, 0)));
+ const client = await previous.createCDPSession();
+ // Background documents can freeze their timers while requests still hold sockets.
+ await client.send("Page.setWebLifecycleState", { state: "frozen" });
+ }
+ await page.setViewport({ width: 1440, height: 900 });
+ await page.goto(`http://127.0.0.1:${address.port}/#project/${id}`, {
+ waitUntil: "domcontentloaded",
+ timeout: 60_000,
+ });
+ await page.waitForFunction(
+ (selector) =>
+ document.querySelector(selector)?.closest("[data-timeline-row]")?.dataset.timelineRow ===
+ "1",
+ { timeout: 30_000 },
+ clip,
+ );
+ await waitFor(() => heldFor(id) >= 2, "Each project starts real held thumbnail requests");
+ previous = page;
+ }
+ const box = await (await page.$(clip)).boundingBox();
+ const view = await (await page.$("[data-timeline-scroll-viewport]")).boundingBox();
+ const row = await (await page.$('[data-timeline-row="2"]')).boundingBox();
+ assert(box && view && row);
+ await page.mouse.move(box.x + Math.min(40, box.width / 2), box.y + box.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(Math.min(view.x + view.width - 120, box.x + 60), row.y, { steps: 8 });
+ await page.waitForFunction(
+ () =>
+ document
+ .querySelector("[data-timeline-new-track-lane]")
+ ?.getAttribute("data-timeline-new-track-lane") === "2",
+ { timeout: 5000 },
+ );
+ const dropped = Date.now();
+ await page.mouse.up();
+ const saved = join(projects[2].dir, "index.html");
+ await waitFor(() => {
+ const tag = readFileSync(saved, "utf8").match(
+ /<[^>]*\bid="Product-walkthrough-final-approved-cut-with-alternate-opening-and-captions"[^>]*>/,
+ )?.[0];
+ return /\bdata-track-index="2"/.test(tag ?? "");
+ }, "Dropped clip persists within 15 seconds with old-project thumbnails in flight");
+ assert.equal(heldFor(projects[0].id), 0, "First hidden document releases its thumbnail sockets");
+ assert.equal(heldFor(projects[1].id), 0, "Second hidden document releases its thumbnail sockets");
+ assert(
+ heldFor(projects[2].id) >= 2,
+ "Current document still has unfinished thumbnails at the save",
+ );
+ console.log(
+ JSON.stringify({
+ persistedMs: Date.now() - dropped,
+ oldThumbnailRequests: 0,
+ currentThumbnailRequests: heldFor(projects[2].id),
+ saveBoundMs: 15_000,
+ }),
+ );
+} finally {
+ await browser?.close();
+ proxy.closeAllConnections();
+ await new Promise((resolve) => proxy.close(resolve));
+ for (const { dir } of projects) rmSync(dir, { recursive: true, force: true });
+}
diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts
index c5c1ed9045..e8a0eca911 100644
--- a/packages/studio/vite.config.ts
+++ b/packages/studio/vite.config.ts
@@ -11,6 +11,7 @@ import {
} from "node:fs";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
+import { bindNodeRequestSignal } from "./vite.request-signal.js";
import { watch } from "chokidar";
import { createProjectSignatureCache, createViteAdapter } from "./vite.adapter";
import { previewConfigPayload } from "./vite.preview-config";
@@ -226,6 +227,7 @@ function devProjectApi(): Plugin {
// API middleware
server.middlewares.use(async (req, res, next) => {
if (!req.url?.startsWith("/api/")) return next();
+ const requestSignal = bindNodeRequestSignal(res);
try {
const api = await getApi();
const url = new URL(req.url, `http://${req.headers.host}`);
@@ -243,6 +245,7 @@ function devProjectApi(): Plugin {
method: req.method,
headers,
body,
+ signal: requestSignal.signal,
});
const response = await api.fetch(fetchReq);
await bridgeHonoResponse(response, res);
@@ -252,6 +255,8 @@ function devProjectApi(): Plugin {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
+ } finally {
+ requestSignal.dispose();
}
});
diff --git a/packages/studio/vite.request-signal.test.ts b/packages/studio/vite.request-signal.test.ts
new file mode 100644
index 0000000000..13abcb1666
--- /dev/null
+++ b/packages/studio/vite.request-signal.test.ts
@@ -0,0 +1,66 @@
+import { createServer, get } from "node:http";
+import { describe, expect, it } from "vitest";
+import { bindNodeRequestSignal } from "./vite.request-signal";
+
+// A real connection is required: completing a request body is not a disconnect.
+describe("Studio dev request cancellation", () => {
+ it("aborts a held response when its client disconnects", async () => {
+ let received!: () => void;
+ let disconnected!: () => void;
+ const ready = new Promise((resolve) => {
+ received = resolve;
+ });
+ const closed = new Promise((resolve) => {
+ disconnected = resolve;
+ });
+ let signal: AbortSignal | undefined;
+ const server = createServer((_, response) => {
+ const request = bindNodeRequestSignal(response);
+ signal = request.signal;
+ signal.addEventListener(
+ "abort",
+ () => {
+ request.dispose();
+ disconnected();
+ },
+ { once: true },
+ );
+ received();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
+ const client = get(`http://127.0.0.1:${address.port}/thumbnail`);
+ client.on("error", (error) => expect(error).toMatchObject({ code: "ECONNRESET" }));
+ try {
+ await ready;
+ expect(signal?.aborted).toBe(false);
+ client.destroy();
+ await closed;
+ expect(signal?.aborted).toBe(true);
+ } finally {
+ client.destroy();
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ });
+
+ it("does not abort completed work after its listener is disposed", async () => {
+ let signal: AbortSignal | undefined;
+ const server = createServer((_, response) => {
+ const request = bindNodeRequestSignal(response);
+ signal = request.signal;
+ response.end("complete");
+ request.dispose();
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
+ try {
+ const response = await fetch(`http://127.0.0.1:${address.port}/thumbnail`);
+ expect(await response.text()).toBe("complete");
+ expect(signal?.aborted).toBe(false);
+ } finally {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ });
+});
diff --git a/packages/studio/vite.request-signal.ts b/packages/studio/vite.request-signal.ts
new file mode 100644
index 0000000000..e462d2b796
--- /dev/null
+++ b/packages/studio/vite.request-signal.ts
@@ -0,0 +1,11 @@
+import type { ServerResponse } from "node:http";
+
+export function bindNodeRequestSignal(response: ServerResponse): {
+ signal: AbortSignal;
+ dispose: () => void;
+} {
+ const controller = new AbortController();
+ const abort = () => controller.abort();
+ response.once("close", abort);
+ return { signal: controller.signal, dispose: () => response.off("close", abort) };
+}