diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index 32a6d5bc85..b56180bcf4 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -53,6 +53,19 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
+ // TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
+ // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
+ "packages/studio/src/components/nle/NLEContext.tsx",
+ "packages/studio/src/components/nle/PreviewPane.tsx",
+ "packages/studio/src/components/nle/AssetPreviewOverlay.tsx",
+ "packages/studio/src/player/components/TimelineOverlays.tsx",
+ "packages/studio/src/player/components/timelineClipChildren.tsx",
+ "packages/studio/src/player/components/timelineClipDragTypes.ts",
+ "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
+ "packages/studio/src/utils/assetPreviewStore.ts",
+ // TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
+ // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
+ "packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
@@ -90,6 +103,114 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
+ "exports": ["furthestClipEndFromDocument"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/hooks/timelineEditingHelpers.ts",
+ "exports": ["patchIframeRootDuration"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
+ "exports": ["DeleteConfirm"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/domEditOverlayGeometry.ts",
+ "exports": ["overlayCornersCentroid", "elementCornerOverlayPoints", "orientedOverlayRect"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/contexts/DomEditContext.tsx",
+ "exports": ["useDomEditActionsContextOptional"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/contexts/StudioContext.tsx",
+ "exports": ["useStudioShellContextOptional"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/domEditOverlayGestures.ts",
+ "exports": ["ResizeHandle"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/sidebar/assetHelpers.ts",
+ "exports": ["truncateMiddle", "formatDuration"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/utils/studioHelpers.ts",
+ "exports": ["resolveDroppedAssetDimensions"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/player/lib/timelineElementHelpers.ts",
+ "exports": ["furthestClipEndFromSource", "readTimelineElementZIndex"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/manualOffsetDrag.ts",
+ "exports": ["applyManualOffsetNudgeDraft", "applyManualOffsetNudgeCommit"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/player/components/timelineEditing.ts",
+ "exports": [
+ "applyTimelineAutoScrollStep",
+ "resolveTimelineAutoScrollLoopAction",
+ "resolveTimelineDragEscape",
+ "TimelineDragEscapeInput",
+ ],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/hooks/timelineEditingHelpers.ts",
+ "exports": [
+ "shiftGsapPositionsBatch",
+ "syncTimingEditPreview",
+ "GsapMutationOutcome",
+ "readGsapMutationScriptText",
+ ],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/utils/timelineAssetDrop.ts",
+ "exports": [
+ "setCompositionDurationToContent",
+ "extendCompositionDurationIfNeeded",
+ "fitTimelineAssetGeometry",
+ "resolveTimelineAssetCompositionSize",
+ ],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/player/components/timelineLayout.ts",
+ "exports": [
+ "getTimelineRowTop",
+ "getTimelineRowFromY",
+ "DRAG_EXTEND_MARGIN_PX",
+ "getTimelineFitPps",
+ "getTimelineDisplayContentWidth",
+ "TRACKS_TOP_PAD",
+ "MIN_TIMELINE_EXTENT_S",
+ ],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/domEditNudge.ts",
+ "exports": ["CANVAS_NUDGE_COMMIT_DEBOUNCE_MS"],
+ },
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
+ "exports": ["readEffectiveZIndex"],
+ },
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
@@ -308,6 +429,51 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/components/editor/domEditOverlayGeometry.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/components/nle/NLEContext.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/components/nle/NLELayout.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/components/nle/PreviewPane.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/components/sidebar/AssetContextMenu.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/hooks/timelineEditingHelpers.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/Timeline.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/TimelineCanvas.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/TimelineOverlays.tsx",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/timelineClipDragTypes.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/useTimelineClipDrag.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/components/useTimelineMarqueeSelection.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/hooks/useTimelinePlayer.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/hooks/useTimelinePlayerLoop.ts",
+ // TEMP(studio-dnd): coexistence-window clone (extraction vs still-live original);
+ // studio-dnd pr22 removes this with the final config.
+ "packages/studio/src/player/lib/timelineElementHelpers.ts",
// sourcePatcher.ts: pre-existing internal clones between the inline-style
// and attribute tag-patchers; only the PatchOperation type gained two
// optional fields here, but the line shift makes fallow re-flag them.
@@ -433,6 +599,22 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
+ // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
+ // removed by studio-dnd/pr22 when the final config lands.
+ "packages/studio/src/components/editor/domEditOverlayGeometry.ts",
+ "packages/studio/src/components/editor/manualOffsetDrag.ts",
+ "packages/studio/src/player/components/timelineEditing.ts",
+ "packages/studio/src/player/lib/timelineElementHelpers.ts",
+ // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
+ // removed by studio-dnd/pr22 when the final config lands.
+ "packages/studio/src/player/components/TimelineOverlays.tsx",
+ // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
+ // removed by studio-dnd/pr22 when the final config lands.
+ "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
+ "packages/studio/src/components/editor/CanvasContextMenu.tsx",
+ "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
+ "packages/studio/src/player/components/timelineCollision.test.ts",
+ "packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx
new file mode 100644
index 0000000000..9039863213
--- /dev/null
+++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
+import { CanvasContextMenu } from "./CanvasContextMenu";
+import type { DomEditSelection } from "./domEditing";
+
+installReactActEnvironment();
+
+let host: HTMLDivElement;
+let root: Root | null = null;
+
+beforeEach(() => {
+ host = document.createElement("div");
+ document.body.append(host);
+});
+
+afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ document.body.innerHTML = "";
+});
+
+function renderMenu(props: {
+ selection: DomEditSelection;
+ onApplyZIndex?: () => void;
+ onDelete?: (selection: DomEditSelection) => void;
+}) {
+ root = createRoot(host);
+ act(() => {
+ root!.render(
+ React.createElement(CanvasContextMenu, {
+ x: 10,
+ y: 10,
+ selection: props.selection,
+ onClose: () => {},
+ onApplyZIndex: props.onApplyZIndex,
+ onDelete: props.onDelete,
+ }),
+ );
+ });
+}
+
+/** All menu buttons live in the portal under document.body. */
+function menuButtons(): HTMLButtonElement[] {
+ return [...document.body.querySelectorAll("button")];
+}
+
+function hasDeleteItem(): boolean {
+ return menuButtons().some((b) => b.textContent?.includes("Delete"));
+}
+
+function zOrderButtons(): HTMLButtonElement[] {
+ return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
+}
+
+describe("CanvasContextMenu — handler gating", () => {
+ it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({
+ selection: makeSelection("Target", el),
+ onApplyZIndex: vi.fn(),
+ onDelete: vi.fn(),
+ });
+
+ expect(zOrderButtons()).toHaveLength(4);
+ expect(hasDeleteItem()).toBe(true);
+ // The divider only appears between the two groups.
+ expect(document.body.querySelector(".border-t")).not.toBeNull();
+ });
+
+ it("hides every item and does NOT render the menu when no handlers are present", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ // A z-index that a stray optimistic write would clobber — assert it is
+ // untouched, since the menu must not mutate the DOM without a persist path.
+ el.style.zIndex = "3";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el) });
+
+ // No menu opened at all — no buttons, no dead-end items, no DOM mutation.
+ expect(menuButtons()).toHaveLength(0);
+ expect(document.body.querySelector(".fixed.z-50")).toBeNull();
+ expect(el.style.zIndex).toBe("3");
+ });
+
+ it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
+
+ expect(zOrderButtons()).toHaveLength(4);
+ expect(hasDeleteItem()).toBe(false);
+ expect(document.body.querySelector(".border-t")).toBeNull();
+ });
+
+ it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
+
+ expect(zOrderButtons()).toHaveLength(0);
+ expect(hasDeleteItem()).toBe(true);
+ expect(document.body.querySelector(".border-t")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx
new file mode 100644
index 0000000000..a7a124128f
--- /dev/null
+++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx
@@ -0,0 +1,198 @@
+/**
+ * Right-click context menu for a selected canvas element.
+ *
+ * Mirrors the look, positioning, and dismiss behavior of
+ * player/components/ClipContextMenu.tsx — portaled to document.body,
+ * overflow-adjusted, dismissed on outside-click or Escape via
+ * useContextMenuDismiss.
+ *
+ * ── Wiring (z-order persistence) ─────────────────────────────────────────────
+ * Z-index changes are applied optimistically to the live iframe element(s) via
+ * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
+ * moving a target past an equal-z sibling can require renumbering the affected
+ * set). The patches are surfaced through the `onApplyZIndex` prop.
+ *
+ * The prop MUST be wired at the call site to route through the full persist
+ * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
+ * element carries its full selection identity; sibling elements are iframe DOM
+ * nodes, so their id / selector are derived from the node and they share the
+ * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
+ * ─────────────────────────────────────────────────────────────────────────────
+ */
+
+import { memo } from "react";
+import { createPortal } from "react-dom";
+import type { DomEditSelection } from "./domEditing";
+import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
+import {
+ isZOrderActionEnabled,
+ resolveZOrderChange,
+ type ZOrderPatch,
+} from "./canvasContextMenuZOrder";
+
+interface CanvasContextMenuProps {
+ /** Viewport x of the right-click event. */
+ x: number;
+ /** Viewport y of the right-click event. */
+ y: number;
+ selection: DomEditSelection;
+ onClose: () => void;
+ /**
+ * Called with the resolved z-order patch list after an optimistic DOM update.
+ * Each patch is an { element, zIndex } pair (the target and, when a renumber
+ * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
+ * module-level wiring comment).
+ */
+ onApplyZIndex?: (patches: ZOrderPatch[]) => void;
+ /**
+ * Delete the selected element. Wire to handleDomEditElementDelete from
+ * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
+ * Absent when the caller wires no delete persist path (e.g. a legacy mount):
+ * the Delete item is then hidden rather than shown as a silent no-op.
+ */
+ onDelete?: (selection: DomEditSelection) => void;
+}
+
+type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
+
+const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
+ { action: "bring-forward", label: "Bring forward" },
+ { action: "send-backward", label: "Send backward" },
+ { action: "bring-to-front", label: "Bring to front" },
+ { action: "send-to-back", label: "Send to back" },
+];
+
+export const CanvasContextMenu = memo(function CanvasContextMenu({
+ x,
+ y,
+ selection,
+ onClose,
+ onApplyZIndex,
+ onDelete,
+}: CanvasContextMenuProps) {
+ const menuRef = useContextMenuDismiss(onClose);
+
+ // Gate each item group on the presence of its persist handler. Without the
+ // handler the action can't be persisted, so showing it would be a dead-end:
+ // a z-write reverts on reload and Delete silently no-ops. Hide the group
+ // instead. If nothing is actionable (a legacy mount with no handlers at all),
+ // don't render the menu — an empty menu is itself a dead-end.
+ const hasZActions = Boolean(onApplyZIndex);
+ const hasDelete = Boolean(onDelete);
+ const hasDivider = hasZActions && hasDelete;
+
+ // Overflow correction — match ClipContextMenu approach. Only the rendered
+ // groups contribute height (keeps positioning correct when a group is hidden).
+ const menuWidth = 200;
+ const menuHeight =
+ 8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
+ const overflowY = y + menuHeight - window.innerHeight;
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
+
+ const el = selection.element;
+
+ function handleZAction(action: ZAction) {
+ // No persist handler → do NOT touch the live iframe DOM. An optimistic
+ // write with nothing to persist just reverts on the next reload.
+ if (!onApplyZIndex) return;
+ const patches = resolveZOrderChange(el, action);
+ if (patches === null) return;
+ // Optimistic update — visible immediately even before persist completes.
+ for (const patch of patches) {
+ patch.element.style.zIndex = String(patch.zIndex);
+ const view = patch.element.ownerDocument?.defaultView;
+ if (view && view.getComputedStyle(patch.element).position === "static") {
+ patch.element.style.position = "relative";
+ }
+ }
+ onApplyZIndex(patches);
+ onClose();
+ }
+
+ function handleDelete() {
+ if (!onDelete) return;
+ onDelete(selection);
+ onClose();
+ }
+
+ if (!hasZActions && !hasDelete) return null;
+
+ // The menu is portaled to document.body, but in the React tree it is still a
+ // child of the DomEditOverlay
. React synthetic events bubble through the
+ // REACT tree (not the DOM tree), so a click on any menu control would otherwise
+ // bubble into the overlay's onPointerDown / onMouseDown handlers — which
+ // preventDefault() to start a marquee and re-resolve the selection. That
+ // preventDefault cancels the button's own click and the item action never runs.
+ //
+ // Stop pointer/mouse propagation at the menu root so overlay gesture handlers
+ // never see these events, and drive the item actions on pointerDown (which
+ // fires before any outside-click / dismiss logic can unmount the menu).
+ const stopBubble = (e: React.SyntheticEvent) => {
+ e.stopPropagation();
+ };
+
+ return createPortal(
+
{
+ // Keep a right-click on the menu itself from re-opening / bubbling.
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ >
+ {hasZActions &&
+ Z_ACTIONS.map(({ action, label }) => {
+ const enabled = isZOrderActionEnabled(el, action);
+ return (
+
{
+ if (e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (enabled) handleZAction(action);
+ }}
+ >
+ {label}
+
+ );
+ })}
+
+ {hasDivider &&
}
+
+ {hasDelete && (
+
{
+ if (e.button !== 0) return;
+ e.preventDefault();
+ e.stopPropagation();
+ handleDelete();
+ }}
+ >
+ Delete
+ ⌫
+
+ )}
+
,
+ document.body,
+ );
+});
diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx
index c0adaef18c..8d89a3637d 100644
--- a/packages/studio/src/components/editor/DomEditOverlay.tsx
+++ b/packages/studio/src/components/editor/DomEditOverlay.tsx
@@ -73,6 +73,7 @@ interface DomEditOverlayProps {
onBoxSizeCommit: (
selection: DomEditSelection,
next: { width: number; height: number },
+ offset?: { x: number; y: number },
) => Promise
| void;
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise | void;
onStyleCommit?: (property: string, value: string) => Promise | void;
diff --git a/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts
new file mode 100644
index 0000000000..7e97d65d33
--- /dev/null
+++ b/packages/studio/src/components/editor/anchoredResizeReleaseShift.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ applyStudioBoxSize,
+ applyStudioPathOffset,
+ readStudioBoxSize,
+ reapplyPositionEditsAfterSeek,
+} from "./manualEditsDom";
+import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
+import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
+import type { PatchOperation } from "../../utils/sourcePatcher";
+import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
+
+/**
+ * Center-anchored corner resize (CapCut model): the element scales about its
+ * CENTER, which must stay planted across the whole gesture — including after
+ * release, on every corner and at any rotation.
+ *
+ * This file lands here with ONLY the persist round-trip test, which exercises the
+ * real apply → persist → reload symbols that already exist at this point in the
+ * stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
+ * drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
+ * the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
+ * lands later in the stack (with the canvas glue swap), so those two tests are added
+ * to this file at that point — importing the real production helper rather than a
+ * test-local copy, so they can never pass against a stand-in that drifts from the
+ * shipped math.
+ */
+
+afterEach(() => {
+ document.body.innerHTML = "";
+});
+
+/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
+ * inline-style / attribute application — i.e. what the persisted source carries
+ * when it is re-parsed into the DOM on the next preview load. */
+function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
+ for (const op of ops) {
+ if (op.type === "inline-style") {
+ if (op.value === null) el.style.removeProperty(op.property);
+ else el.style.setProperty(op.property, op.value);
+ } else if (op.type === "attribute") {
+ if (op.value === null) el.removeAttribute(op.property);
+ else el.setAttribute(op.property, op.value);
+ }
+ }
+}
+
+/** Net translate applied to an element, resolving the studio offset var()
+ * expression to its px value so we compare the actually-rendered translation. */
+function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
+ const raw = el.style.getPropertyValue("translate").trim();
+ if (!raw || raw === "none") return { x: 0, y: 0 };
+ const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
+ const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
+ const parts = splitTopLevelWhitespace(raw);
+ const parseAxis = (part: string, varVal: number): number => {
+ if (part && part.includes("--hf-studio-offset")) return varVal;
+ const n = Number.parseFloat(part);
+ return Number.isFinite(n) ? n : 0;
+ };
+ return {
+ x: parseAxis(parts[0] ?? "", vx),
+ y: parseAxis(parts[1] ?? "", vy),
+ };
+}
+
+describe("center-anchored corner resize — no shift after release", () => {
+ it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
+ // The committed offset flows through the real apply → persist → reload chain
+ // unchanged (this hop was proved clean; the shift is upstream in the anchor
+ // loop, tested with the gesture code, not in persistence).
+ const el = document.createElement("div");
+ el.style.setProperty("width", "200px");
+ el.style.setProperty("height", "100px");
+ document.body.appendChild(el);
+
+ const anchorDx = -30;
+ const anchorDy = -18;
+ const finalSize = { width: 240, height: 130 };
+
+ applyStudioBoxSize(el, finalSize);
+ const memberResult = createManualOffsetDragMember({
+ key: "k",
+ selection: { element: el } as never,
+ element: el,
+ rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
+ });
+ expect(memberResult.ok).toBe(true);
+ if (!memberResult.ok) return;
+
+ const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
+
+ applyStudioBoxSize(el, finalSize);
+ const patches = buildBoxSizePatches(el);
+ applyStudioPathOffset(el, finalOffset);
+ patches.push(...buildPathOffsetPatches(el));
+
+ expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
+
+ // Persist → fresh element re-parsed from source → reload re-stamp.
+ const reloaded = document.createElement("div");
+ reloaded.style.setProperty("width", "200px");
+ reloaded.style.setProperty("height", "100px");
+ document.body.appendChild(reloaded);
+ applyPatchesToElement(reloaded, patches);
+ reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
+
+ expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
+ expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
+ });
+});
diff --git a/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts
new file mode 100644
index 0000000000..a0ae6b4f71
--- /dev/null
+++ b/packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts
@@ -0,0 +1,435 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from "vitest";
+import {
+ isZOrderActionEnabled,
+ parseZIndex,
+ resolveZOrderChange,
+ type ZOrderAction,
+ type ZOrderPatch,
+} from "./canvasContextMenuZOrder";
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+//
+// In jsdom getBoundingClientRect returns all zeros, so the target rect is 0×0
+// and getOverlappingFamily returns the whole family — i.e. every sibling is
+// treated as overlapping. That makes forward/backward and front/back exercise
+// the same scoped set here, which is exactly what we want for order logic.
+
+function makeEl(id: string, zIndex?: string): HTMLElement {
+ const el = document.createElement("div");
+ el.id = id;
+ if (zIndex !== undefined) el.style.zIndex = zIndex;
+ return el;
+}
+
+/**
+ * Build a parent and append `[target, ...siblings]` in DOM order. Each spec is
+ * [id, z]. The target's z is `targetZ`; it is appended FIRST unless
+ * `targetLast` is set, in which case it is appended LAST (later in DOM).
+ */
+function makeFamily(
+ targetZ: string,
+ siblingSpecs: Array<[string, string]>,
+ opts: { targetLast?: boolean } = {},
+): { target: HTMLElement; parent: HTMLElement; byId: Record } {
+ const parent = document.createElement("div");
+ const target = makeEl("target", targetZ);
+ const siblings = siblingSpecs.map(([id, z]) => makeEl(id, z));
+ const byId: Record = { target };
+ for (const s of siblings) byId[s.id] = s;
+ if (opts.targetLast) {
+ for (const s of siblings) parent.appendChild(s);
+ parent.appendChild(target);
+ } else {
+ parent.appendChild(target);
+ for (const s of siblings) parent.appendChild(s);
+ }
+ return { target, parent, byId };
+}
+
+/** Resolve a z-order change and assert it produced patches (fails otherwise). */
+function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
+ const patches = resolveZOrderChange(target, action);
+ expect(patches).not.toBeNull();
+ if (!patches) throw new Error("expected z-order patches");
+ return patches;
+}
+
+/** Look up a patch for a given element id in a patch list. */
+function patchFor(patches: ZOrderPatch[], byId: Record, id: string) {
+ return patches.find((p) => p.element === byId[id]);
+}
+
+/** Apply patches, then return the render-ordered ids (bottom→top). */
+function renderOrderIds(
+ parent: HTMLElement,
+ byId: Record,
+ patches: ZOrderPatch[],
+): string[] {
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
+ const children = Array.from(parent.children) as HTMLElement[];
+ const withPos = children.map((el, domIndex) => ({
+ id: Object.keys(byId).find((k) => byId[k] === el) ?? el.id,
+ z: parseZIndex(el.style.zIndex || "0"),
+ domIndex,
+ }));
+ withPos.sort((a, b) => a.z - b.z || a.domIndex - b.domIndex);
+ return withPos.map((e) => e.id);
+}
+
+// ── parseZIndex ───────────────────────────────────────────────────────────────
+
+describe("parseZIndex", () => {
+ it("parses integers", () => {
+ expect(parseZIndex("5")).toBe(5);
+ expect(parseZIndex("0")).toBe(0);
+ expect(parseZIndex("-3")).toBe(-3);
+ });
+
+ it("treats 'auto' / null / undefined / empty as 0", () => {
+ expect(parseZIndex("auto")).toBe(0);
+ expect(parseZIndex(null)).toBe(0);
+ expect(parseZIndex(undefined)).toBe(0);
+ expect(parseZIndex("")).toBe(0);
+ });
+});
+
+// ── distinct-z fast path (single-element patch) ────────────────────────────────
+
+describe("resolveZOrderChange – distinct z values (fast path)", () => {
+ it("bring-to-front moves target above all with a single patch", () => {
+ const { target, byId } = makeFamily("2", [
+ ["a", "1"],
+ ["b", "5"],
+ ["c", "3"],
+ ]);
+ const patches = resolveZOrderPatches(target, "bring-to-front");
+ expect(patches).toHaveLength(1);
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(6);
+ });
+
+ it("bring-to-front returns null when already on top", () => {
+ const { target } = makeFamily("6", [
+ ["a", "1"],
+ ["b", "5"],
+ ["c", "3"],
+ ]);
+ expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
+ });
+
+ it("send-to-back moves target below all", () => {
+ const { target, byId, parent } = makeFamily("3", [
+ ["a", "1"],
+ ["b", "5"],
+ ["c", "2"],
+ ]);
+ const patches = resolveZOrderPatches(target, "send-to-back");
+ // target must end up strictly below the current min (1) in render order.
+ expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
+ });
+
+ it("send-to-back returns null when already at back", () => {
+ const { target } = makeFamily("0", [
+ ["a", "1"],
+ ["b", "5"],
+ ["c", "3"],
+ ]);
+ expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
+ });
+
+ it("bring-forward steps up exactly one in render order", () => {
+ const { target, byId, parent } = makeFamily("2", [
+ ["a", "1"],
+ ["b", "4"],
+ ["c", "7"],
+ ]);
+ // render order bottom→top: a(1), target(2), b(4), c(7). forward → above b.
+ const patches = resolveZOrderPatches(target, "bring-forward");
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "b", "target", "c"]);
+ });
+
+ it("send-backward steps down exactly one in render order", () => {
+ const { target, byId, parent } = makeFamily("5", [
+ ["a", "1"],
+ ["b", "3"],
+ ["c", "8"],
+ ]);
+ // bottom→top: a(1), b(3), target(5), c(8). backward → below b.
+ const patches = resolveZOrderPatches(target, "send-backward");
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target", "b", "c"]);
+ });
+
+ it("bring-forward returns null when already top of set", () => {
+ const { target } = makeFamily("8", [
+ ["a", "1"],
+ ["b", "4"],
+ ["c", "7"],
+ ]);
+ expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
+ });
+
+ it("send-backward returns null when already bottom of set", () => {
+ const { target } = makeFamily("0", [
+ ["a", "1"],
+ ["b", "3"],
+ ["c", "8"],
+ ]);
+ expect(resolveZOrderChange(target, "send-backward")).toBeNull();
+ });
+
+ it("returns null when no siblings", () => {
+ const target = makeEl("solo", "2");
+ document.createElement("div").appendChild(target);
+ for (const action of [
+ "bring-forward",
+ "send-backward",
+ "bring-to-front",
+ "send-to-back",
+ ] as ZOrderAction[]) {
+ expect(resolveZOrderChange(target, action)).toBeNull();
+ }
+ });
+});
+
+// ── DOM-order ties (the repro) ─────────────────────────────────────────────────
+
+describe("resolveZOrderChange – DOM-order ties (repro: equal z)", () => {
+ it("send-backward: tied target LATER in DOM (visually on top) can go below", () => {
+ // img#a (z=0, earlier in DOM) then video#target (z=0, later) → video paints
+ // on top. send-backward must put target below the image.
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
+ const patches = resolveZOrderPatches(target, "send-backward");
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["target", "a"]);
+ // target ends strictly below the image.
+ const tz = patchFor(patches, byId, "target")?.zIndex ?? 0;
+ expect(tz).toBeGreaterThanOrEqual(0);
+ });
+
+ it("send-to-back: tied target LATER in DOM goes to the very back", () => {
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]], { targetLast: true });
+ const patches = resolveZOrderPatches(target, "send-to-back");
+ expect(renderOrderIds(parent, byId, patches)[0]).toBe("target");
+ });
+
+ it("bring-forward: tied target EARLIER in DOM (visually below) can go above", () => {
+ // target#target (z=0, earlier) then #a (z=0, later) → a paints on top.
+ // bring-forward on target must lift it above a.
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
+ const patches = resolveZOrderPatches(target, "bring-forward");
+ expect(renderOrderIds(parent, byId, patches)).toEqual(["a", "target"]);
+ });
+
+ it("bring-to-front: tied target EARLIER in DOM goes to the very front", () => {
+ const { target, byId, parent } = makeFamily("0", [["a", "0"]]);
+ const patches = resolveZOrderPatches(target, "bring-to-front");
+ const order = renderOrderIds(parent, byId, patches);
+ expect(order[order.length - 1]).toBe("target");
+ });
+
+ it("send-backward: tied target EARLIER in DOM is already at back → null", () => {
+ // target earlier + a later, both z=0. target already paints below a.
+ const { target } = makeFamily("0", [["a", "0"]]);
+ expect(resolveZOrderChange(target, "send-backward")).toBeNull();
+ expect(resolveZOrderChange(target, "send-to-back")).toBeNull();
+ });
+
+ it("bring-forward: tied target LATER in DOM is already on top → null", () => {
+ const { target } = makeFamily("0", [["a", "0"]], { targetLast: true });
+ expect(resolveZOrderChange(target, "bring-forward")).toBeNull();
+ expect(resolveZOrderChange(target, "bring-to-front")).toBeNull();
+ });
+
+ it("renumber emits a real patch per changed element and none for the unchanged (minimal, no no-ops)", () => {
+ // Three tied at z=0, target in the middle of DOM order. Sending it back must
+ // renumber to distinct values but leave the target (which keeps its bottom
+ // slot's value 0) unpatched — and every emitted patch must be a genuine change.
+ const parent = document.createElement("div");
+ const a = makeEl("a", "0");
+ const target = makeEl("target", "0");
+ const b = makeEl("b", "0");
+ parent.append(a, target, b);
+ const originalZ = new Map([
+ [a, 0],
+ [target, 0],
+ [b, 0],
+ ]);
+ // render order bottom→top by (z, dom): a, target, b. send-backward → below a.
+ const patches = resolveZOrderPatches(target, "send-backward");
+ // Every emitted patch is a REAL change: its new z differs from the old z.
+ for (const p of patches) expect(p.zIndex).not.toBe(originalZ.get(p.element));
+ // target renumbers to 0 (its existing value) → it must NOT be in the patch set.
+ expect(patchFor(patches, { a, target, b }, "target")).toBeUndefined();
+ // No two patches collide on the same element (a well-formed minimal set).
+ expect(new Set(patches.map((p) => p.element)).size).toBe(patches.length);
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
+ expect(renderOrderIds(parent, { a, target, b }, [])).toEqual(["target", "a", "b"]);
+ });
+});
+
+// ── overlap scoping (real getBoundingClientRect) ────────────────────────────────
+//
+// jsdom's getBoundingClientRect is 0×0, so getOverlappingFamily keeps the whole
+// family and the SCOPED (overlapping-only) path is never exercised above. These
+// mock rects so a sibling can be genuinely NON-overlapping and thus non-scoped.
+
+interface Rect {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+}
+function setRect(el: HTMLElement, r: Rect): void {
+ el.getBoundingClientRect = (): DOMRect =>
+ ({
+ left: r.left,
+ top: r.top,
+ right: r.right,
+ bottom: r.bottom,
+ width: r.right - r.left,
+ height: r.bottom - r.top,
+ x: r.left,
+ y: r.top,
+ toJSON: () => ({}),
+ }) as DOMRect;
+}
+
+describe("resolveZOrderChange – overlap scoping preserves untouched non-scoped pairs (#2202)", () => {
+ it("send-backward renumber keeps a scoped sibling above an untouched NON-overlapping one", () => {
+ // A (z5, overlaps target) and target (z5) are tied and overlap; C (z3) does NOT
+ // overlap target, so it is non-scoped. Old renumber sent the scoped set to
+ // 0..n-1 (A→1), dropping A BELOW C (z3) — an untouched (A, C) pair inverting.
+ // The band-preserving renumber keeps the scoped block above C: A→6, C untouched.
+ const parent = document.createElement("div");
+ const a = makeEl("a", "5");
+ const target = makeEl("target", "5");
+ const c = makeEl("c", "3");
+ parent.append(a, target, c);
+ setRect(a, { left: 0, top: 0, right: 10, bottom: 10 });
+ setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
+ setRect(c, { left: 100, top: 100, right: 110, bottom: 110 }); // disjoint → non-scoped
+ const byId = { a, target, c };
+
+ const patches = resolveZOrderPatches(target, "send-backward");
+ // C (untouched, non-scoped) is never patched.
+ expect(patchFor(patches, byId, "c")).toBeUndefined();
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
+ const order = renderOrderIds(parent, byId, []);
+ // Deliberate move: target below a. Preserved untouched pair: a stays above c.
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
+ expect(order.indexOf("a")).toBeGreaterThan(order.indexOf("c"));
+ });
+
+ it("scopes forward/backward to the overlapping set (a non-overlapping sibling is ignored)", () => {
+ // target (z1) overlaps a (z2) only; far (z5) does not overlap target. bring-
+ // forward must step target above a (its sole overlapping neighbour), NOT chase
+ // the non-overlapping far — proving the scoping actually runs with real rects.
+ const parent = document.createElement("div");
+ const target = makeEl("target", "1");
+ const a = makeEl("a", "2");
+ const far = makeEl("far", "5");
+ parent.append(target, a, far);
+ setRect(target, { left: 0, top: 0, right: 10, bottom: 10 });
+ setRect(a, { left: 5, top: 5, right: 15, bottom: 15 }); // overlaps target
+ setRect(far, { left: 200, top: 200, right: 210, bottom: 210 }); // disjoint
+ const byId = { target, a, far };
+
+ const patches = resolveZOrderPatches(target, "bring-forward");
+ // far is untouched (not in the overlapping scope).
+ expect(patchFor(patches, byId, "far")).toBeUndefined();
+ for (const p of patches) p.element.style.zIndex = String(p.zIndex);
+ const order = renderOrderIds(parent, byId, []);
+ // target rose just above its overlapping neighbour a, staying below far.
+ expect(order.indexOf("target")).toBeGreaterThan(order.indexOf("a"));
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("far"));
+ });
+});
+
+// ── non-painting sibling hygiene ───────────────────────────────────────────────
+
+describe("resolveZOrderChange – excludes non-painting siblings", () => {
+ it("ignores /',
+);
+
+function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
+ const disk: Record = { "index.html": ORIGINAL };
+ const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
+
+ const storage = createMemoryEditHistoryStorage();
+ const store = createPersistentEditHistoryStore({
+ projectId: "p1",
+ storage,
+ initialState: createEmptyEditHistory(),
+ now: (() => {
+ let t = 1000;
+ return () => (t += 10);
+ })(),
+ onChange: () => {},
+ });
+
+ // Faithful stand-in for the studio-server file-mutation endpoints: the server
+ // writes the split to disk itself, then returns the patched content.
+ const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
+ const u = String(url);
+ if (u.includes("/gsap-mutations/")) {
+ if (opts.gsap) {
+ // Mirror the server: rewrites the GSAP script for the new id, writes to
+ // disk, returns the final content.
+ disk["index.html"] = SPLIT_GSAP;
+ return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ // The fixture has no GSAP script — mirror the server's 400 response.
+ return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (u.includes("/file-mutations/split-element/")) {
+ disk["index.html"] = SPLIT;
+ return new Response(
+ JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ if (u.includes("/files/")) {
+ return new Response(JSON.stringify({ content: disk["index.html"] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ void init;
+ throw new Error(`unexpected fetch: ${u}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const splitRef: { current: Split | undefined } = { current: undefined };
+
+ function Component() {
+ const { handleRazorSplit } = useRazorSplit({
+ projectId: "p1",
+ activeCompPath: "index.html",
+ showToast: () => {},
+ writeProjectFile: async (path, content) => {
+ disk[path] = content;
+ },
+ recordEdit: store.recordEdit,
+ domEditSaveTimestampRef: { current: 0 },
+ reloadPreview: () => {},
+ });
+ splitRef.current = handleRazorSplit;
+ return null;
+ }
+
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => {
+ root.render( );
+ });
+
+ return { disk, store, splitRef, root, expected: finalContent };
+}
+
+async function undoViaDisk(harness: Harness) {
+ return harness.store.undo({
+ readFile: async (path) => harness.disk[path],
+ writeFile: async (path, content) => {
+ harness.disk[path] = content;
+ },
+ });
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ vi.unstubAllGlobals();
+});
+
+describe("useRazorSplit — split is undoable via edit history", () => {
+ for (const gsap of [false, true]) {
+ describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
+ let harness: Harness;
+ beforeEach(() => {
+ harness = mountRazorSplit({ gsap });
+ });
+ afterEach(() => {
+ act(() => harness.root.unmount());
+ });
+
+ it("records a single 'Split timeline clip' history entry that undo restores", async () => {
+ await act(async () => {
+ await harness.splitRef.current!(element, 2);
+ });
+
+ // The split reached disk.
+ expect(harness.disk["index.html"]).toBe(harness.expected);
+
+ // The split must be the top of the undo stack — not a prior/other entry.
+ expect(harness.store.snapshot().canUndo).toBe(true);
+ expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
+
+ // Undo restores the exact pre-split file.
+ const result = await undoViaDisk(harness);
+ expect(result.ok).toBe(true);
+ expect(result.label).toBe("Split timeline clip");
+ expect(harness.disk["index.html"]).toBe(ORIGINAL);
+ });
+ });
+ }
+});
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx
index 7557b22345..cc8ea38309 100644
--- a/packages/studio/src/player/components/Timeline.tsx
+++ b/packages/studio/src/player/components/Timeline.tsx
@@ -253,6 +253,8 @@ export const Timeline = memo(function Timeline({
if (
!draggedClip?.started ||
trackOrder.length === 0 ||
+ draggedClip.previewLayerId === undefined ||
+ draggedClip.previewLayerIndex === undefined ||
trackOrder.includes(draggedClip.previewLayerId)
)
return trackOrder;
diff --git a/packages/studio/src/player/components/TimelineOverlays.tsx b/packages/studio/src/player/components/TimelineOverlays.tsx
new file mode 100644
index 0000000000..0f0efcb73d
--- /dev/null
+++ b/packages/studio/src/player/components/TimelineOverlays.tsx
@@ -0,0 +1,122 @@
+import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
+import type { TimelineTheme } from "./timelineTheme";
+import type { TimelineRangeSelection } from "./timelineEditing";
+import type { TimelineEditCallbacks } from "./timelineCallbacks";
+import { EditPopover } from "./EditModal";
+import {
+ KeyframeDiamondContextMenu,
+ type KeyframeDiamondContextMenuState,
+} from "./KeyframeDiamondContextMenu";
+import { ClipContextMenu } from "./ClipContextMenu";
+import { TimelineShortcutHint } from "./TimelineShortcutHint";
+
+interface ClipContextMenuState {
+ x: number;
+ y: number;
+ element: TimelineElement;
+}
+
+interface TimelineOverlaysProps {
+ theme: TimelineTheme;
+ showShortcutHint: boolean;
+ showPopover: boolean;
+ rangeSelection: TimelineRangeSelection | null;
+ setShowPopover: (value: boolean) => void;
+ setRangeSelection: (value: TimelineRangeSelection | null) => void;
+ kfContextMenu: KeyframeDiamondContextMenuState | null;
+ setKfContextMenu: (value: KeyframeDiamondContextMenuState | null) => void;
+ onDeleteKeyframe: TimelineEditCallbacks["onDeleteKeyframe"];
+ onDeleteAllKeyframes: TimelineEditCallbacks["onDeleteAllKeyframes"];
+ onChangeKeyframeEase: TimelineEditCallbacks["onChangeKeyframeEase"];
+ onMoveKeyframeToPlayhead: TimelineEditCallbacks["onMoveKeyframeToPlayhead"];
+ keyframeCache: Map;
+ clipContextMenu: ClipContextMenuState | null;
+ setClipContextMenu: (value: ClipContextMenuState | null) => void;
+ currentTime: number;
+ onSplitElement: TimelineEditCallbacks["onSplitElement"];
+ pinZoomBeforeEdit: () => void;
+ onDeleteElement?: (element: TimelineElement) => Promise | void;
+}
+
+// The timeline's floating overlays, rendered as siblings above the scroll area:
+// the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
+// and the clip context menu.
+export function TimelineOverlays({
+ theme,
+ showShortcutHint,
+ showPopover,
+ rangeSelection,
+ setShowPopover,
+ setRangeSelection,
+ kfContextMenu,
+ setKfContextMenu,
+ onDeleteKeyframe,
+ onDeleteAllKeyframes,
+ onChangeKeyframeEase,
+ onMoveKeyframeToPlayhead,
+ keyframeCache,
+ clipContextMenu,
+ setClipContextMenu,
+ currentTime,
+ onSplitElement,
+ pinZoomBeforeEdit,
+ onDeleteElement,
+}: TimelineOverlaysProps) {
+ return (
+ <>
+ {showShortcutHint && !showPopover && !rangeSelection && (
+
+ )}
+
+ {showPopover && rangeSelection && (
+ {
+ setShowPopover(false);
+ setRangeSelection(null);
+ }}
+ />
+ )}
+
+ {kfContextMenu && (
+ setKfContextMenu(null)}
+ onDelete={(elId, pct) => onDeleteKeyframe?.(elId, pct)}
+ onDeleteAll={(elId) => onDeleteAllKeyframes?.(elId)}
+ onChangeEase={(elId, pct, ease) => onChangeKeyframeEase?.(elId, pct, ease)}
+ onMoveToPlayhead={
+ onMoveKeyframeToPlayhead
+ ? (elId, pct) => onMoveKeyframeToPlayhead(elId, pct)
+ : undefined
+ }
+ onCopyProperties={(elId, pct) => {
+ const kfData = keyframeCache.get(elId);
+ const kf = kfData?.keyframes.find((k) => k.percentage === pct);
+ if (kf) {
+ void navigator.clipboard.writeText(JSON.stringify(kf.properties, null, 2));
+ }
+ }}
+ />
+ )}
+
+ {clipContextMenu && (
+ setClipContextMenu(null)}
+ onSplit={(el, time) => onSplitElement?.(el, time)}
+ onDelete={(el) => {
+ pinZoomBeforeEdit();
+ onDeleteElement?.(el);
+ }}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/timelineCallbacks.ts b/packages/studio/src/player/components/timelineCallbacks.ts
index 47e2d83f94..1b68cc88ea 100644
--- a/packages/studio/src/player/components/timelineCallbacks.ts
+++ b/packages/studio/src/player/components/timelineCallbacks.ts
@@ -39,10 +39,15 @@ export interface TimelineEditCallbacks {
element: TimelineElement,
updates: Pick,
) => Promise | void;
- onMoveElements?: (
- changes: TimelineGroupMoveChange[],
+ /**
+ * Batched move. Method syntax (bivariant) + union parameter so both the
+ * legacy group-editing shape (TimelineGroupMoveChange) and the NLE edit
+ * shape (TimelineGroupMoveEdit) type-check while both engines coexist.
+ */
+ onMoveElements?(
+ changes: Array,
options?: TimelineGroupCommitOptions,
- ) => Promise | void;
+ ): Promise | void;
onResizeElements?: (
changes: TimelineGroupResizeChange[],
options?: TimelineGroupCommitOptions,
@@ -66,3 +71,13 @@ export interface TimelineEditCallbacks {
) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
}
+
+/**
+ * NLE batched-move edit: the element plus exactly the fields the move changes.
+ * The legacy engine's TimelineGroupMoveChange carries resolved absolute values
+ * instead; onMoveElements accepts either while the engines coexist.
+ */
+export interface TimelineGroupMoveEdit {
+ element: TimelineElement;
+ updates: Pick;
+}
diff --git a/packages/studio/src/player/components/timelineClipChildren.tsx b/packages/studio/src/player/components/timelineClipChildren.tsx
new file mode 100644
index 0000000000..3482fbdc00
--- /dev/null
+++ b/packages/studio/src/player/components/timelineClipChildren.tsx
@@ -0,0 +1,39 @@
+import { type ReactNode } from "react";
+import { usePlayerStore, type TimelineElement } from "../store/playerStore";
+import type { TrackVisualStyle } from "./timelineIcons";
+
+function ClipLintDot({ element }: { element: TimelineElement }) {
+ const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
+ if (!lint || lint.count === 0) return null;
+ return (
+
+ );
+}
+
+export function renderClipChildren(
+ element: TimelineElement,
+ clipStyle: TrackVisualStyle,
+ renderClipContent:
+ | ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode)
+ | undefined,
+ renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
+): ReactNode {
+ return (
+ <>
+ {renderClipOverlay?.(element)}
+ {!renderClipContent && }
+ {renderClipContent && (
+ // borderRadius: inherit — the clip itself is overflow-visible (keyframe
+ // diamonds hang outside its bounds), so the thumbnail layer must clip
+ // itself to the clip's rounded corners or sharp corners poke out.
+
+ {renderClipContent(element, clipStyle)}
+
+ )}
+ >
+ );
+}
diff --git a/packages/studio/src/player/components/timelineClipDragPreview.ts b/packages/studio/src/player/components/timelineClipDragPreview.ts
index f537ab9ab2..c9899fbbf4 100644
--- a/packages/studio/src/player/components/timelineClipDragPreview.ts
+++ b/packages/studio/src/player/components/timelineClipDragPreview.ts
@@ -4,8 +4,10 @@ import type { TimelineLayerId } from "./timelineTrackOrder";
interface DragPreviewState {
element: Pick;
- previewLayerId: TimelineLayerId;
- previewLayerIndex: number;
+ // Optional during the NLE coexistence window: the intermediate
+ // DraggedClipState carries these only on the legacy drag path.
+ previewLayerId?: TimelineLayerId;
+ previewLayerIndex?: number;
}
export interface TimelineMovePreview {
@@ -28,8 +30,8 @@ export function resolveDragPreviewPlacement(
): {
previewStart: number;
previewTrack: number;
- previewLayerId: TimelineLayerId;
- previewLayerIndex: number;
+ previewLayerId: TimelineLayerId | undefined;
+ previewLayerIndex: number | undefined;
previewStackingReorder: TimelineStackingReorderIntent | null;
} {
if (groupMove.active) {
diff --git a/packages/studio/src/player/components/timelineClipDragTypes.ts b/packages/studio/src/player/components/timelineClipDragTypes.ts
new file mode 100644
index 0000000000..8ac36c9a68
--- /dev/null
+++ b/packages/studio/src/player/components/timelineClipDragTypes.ts
@@ -0,0 +1,65 @@
+import type { TimelineElement } from "../store/playerStore";
+import type { TimelineSnapType } from "./timelineSnapping";
+import type { BlockedTimelineEditIntent } from "./timelineEditing";
+
+/* ── Shared clip-drag state types ───────────────────────────────── */
+export interface DraggedClipState {
+ element: TimelineElement;
+ originClientX: number;
+ originClientY: number;
+ originScrollLeft: number;
+ originScrollTop: number;
+ pointerClientX: number;
+ pointerClientY: number;
+ pointerOffsetX: number;
+ pointerOffsetY: number;
+ previewStart: number;
+ previewTrack: number;
+ /**
+ * The lane the POINTER aims at (from the drag's vertical position), before
+ * collision resolution. `previewTrack` is where the clip actually LANDS —
+ * which may differ from `desiredTrack` when the aimed span is occupied and the
+ * collision rules bump the dragged clip to a free lane. The commit reads this
+ * to tell a deliberate VERTICAL lane change (pointer aimed at another lane →
+ * stacking sync allowed) from a plain HORIZONTAL drag whose clip merely got
+ * bumped sideways (never touches z). Optional: when absent the commit falls
+ * back to `previewTrack` (pre-existing behaviour).
+ */
+ desiredTrack?: number;
+ /**
+ * 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.
+ */
+ insertRow: number | null;
+ /** Snap target the clip will land on, for the guide highlight. */
+ snapTime: number | null;
+ snapType: TimelineSnapType | null;
+ started: boolean;
+}
+
+export interface ResizingClipState {
+ element: TimelineElement;
+ edge: "start" | "end";
+ originClientX: number;
+ /**
+ * scrollLeft at gesture start. Edge auto-scroll moves the content under a
+ * stationary pointer, so the trim math folds (current − origin) scrollLeft
+ * into the pointer x — mirroring resolveTimelineMove's scroll compensation.
+ * Optional so pre-existing constructors/tests stay valid; when absent the
+ * first preview update captures the current scrollLeft.
+ */
+ originScrollLeft?: number;
+ previewStart: number;
+ previewDuration: number;
+ previewPlaybackStart?: number;
+ started: boolean;
+}
+
+export interface BlockedClipState {
+ element: TimelineElement;
+ intent: BlockedTimelineEditIntent;
+ originClientX: number;
+ originClientY: number;
+ started: boolean;
+}
diff --git a/packages/studio/src/player/components/timelineCollision.test.ts b/packages/studio/src/player/components/timelineCollision.test.ts
new file mode 100644
index 0000000000..c1c9b26ca5
--- /dev/null
+++ b/packages/studio/src/player/components/timelineCollision.test.ts
@@ -0,0 +1,437 @@
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import {
+ clampTrackToZone,
+ isInsertAllowedForZone,
+ isLaneFree,
+ resolveInsertRow,
+ resolvePlacement,
+ resolveZoneDropPlacement,
+ timeRangesOverlap,
+} from "./timelineCollision";
+
+function el(id: string, track: number, start: number, duration: number): TimelineElement {
+ return { id, tag: "video", start, duration, track };
+}
+
+describe("timeRangesOverlap", () => {
+ it("detects overlap and treats touching edges as free (half-open)", () => {
+ expect(timeRangesOverlap(0, 2, 1, 3)).toBe(true);
+ expect(timeRangesOverlap(0, 2, 2, 4)).toBe(false); // touching at 2
+ expect(timeRangesOverlap(2, 4, 0, 2)).toBe(false);
+ });
+});
+
+describe("isLaneFree", () => {
+ const els = [el("a", 0, 0, 5), el("b", 1, 2, 3)];
+
+ it("is free when nothing overlaps on the track", () => {
+ expect(isLaneFree(els, 2, 0, 5, null)).toBe(true);
+ expect(isLaneFree(els, 0, 6, 8, null)).toBe(true); // same track, no time overlap
+ });
+
+ it("is occupied when a clip overlaps on the same track", () => {
+ expect(isLaneFree(els, 0, 1, 3, null)).toBe(false);
+ });
+
+ it("ignores the excluded (dragged) clip", () => {
+ expect(isLaneFree(els, 0, 1, 3, "a")).toBe(true);
+ });
+});
+
+describe("resolvePlacement", () => {
+ const trackOrder = [0, 1, 2, 3];
+
+ it("keeps the desired lane when it is free", () => {
+ const els = [el("a", 2, 0, 4)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 1,
+ start: 0,
+ duration: 4,
+ trackOrder,
+ excludeKey: null,
+ }),
+ ).toEqual({
+ track: 1,
+ needsInsert: false,
+ });
+ });
+
+ it("pushes up to the nearest free lane above when the target is occupied", () => {
+ // desired = 2 occupied; 1 free above → land on 1
+ const els = [el("blocker", 2, 0, 4)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 2,
+ start: 1,
+ duration: 2,
+ trackOrder,
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 1, needsInsert: false });
+ });
+
+ it("prefers up even when a lane below is also free", () => {
+ // desired 2 occupied; both 1 (up) and 3 (down) free → up wins
+ const els = [el("blocker", 2, 0, 5)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 2,
+ start: 0,
+ duration: 3,
+ trackOrder,
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 1, needsInsert: false });
+ });
+
+ it("falls to a lane below when every lane above is occupied", () => {
+ // desired 1 occupied; 0 occupied above; 2 free below → land on 2
+ const els = [el("x", 0, 0, 5), el("y", 1, 0, 5)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 1,
+ start: 1,
+ duration: 2,
+ trackOrder,
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 2, needsInsert: false });
+ });
+
+ it("signals needsInsert when no lane is free", () => {
+ const els = [el("a", 0, 0, 9), el("b", 1, 0, 9), el("c", 2, 0, 9), el("d", 3, 0, 9)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 2,
+ start: 1,
+ duration: 2,
+ trackOrder,
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 2, needsInsert: true });
+ });
+
+ it("signals needsInsert when the desired track is occupied but absent from the zone's lanes (#2195)", () => {
+ // desiredTrack 5 is occupied yet not in trackOrder (its kind-zone has no lane
+ // here). Landing on it would overlap, so the empty-zone branch must insert —
+ // not silently land on the occupied track (the placement hole).
+ const els = [el("blocker", 5, 0, 5)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 5,
+ start: 1,
+ duration: 2,
+ trackOrder: [],
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 5, needsInsert: true });
+ });
+
+ it("signals needsInsert when the desired track is FREE but absent from the zone's lanes (#2195 free-span hole)", () => {
+ // desiredTrack 5 is FREE (no overlap) yet not in trackOrder — its kind-zone has
+ // no lane here. The old code short-circuited on isLaneFree and landed on the
+ // foreign-zone lane; the zone check must win and signal an insert.
+ const els = [el("elsewhere", 9, 0, 5)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 5,
+ start: 1,
+ duration: 2,
+ trackOrder: [],
+ excludeKey: null,
+ }),
+ ).toEqual({ track: 5, needsInsert: true });
+ });
+
+ it("placeholder-scenario excludes the dragged clip so it does not collide with itself", () => {
+ const els = [el("self", 1, 0, 5)];
+ expect(
+ resolvePlacement({
+ elements: els,
+ desiredTrack: 1,
+ start: 0,
+ duration: 5,
+ trackOrder,
+ excludeKey: "self",
+ }),
+ ).toEqual({ track: 1, needsInsert: false });
+ });
+});
+
+describe("resolveInsertRow", () => {
+ const n = 3; // three lanes: rows 0,1,2
+
+ it("targets the lane (null) when over its middle band", () => {
+ expect(resolveInsertRow(1.5, n, 0.22)).toBe(null); // dead center of lane 1
+ });
+
+ it("inserts at the top boundary of a lane when near its top edge", () => {
+ expect(resolveInsertRow(1.1, n, 0.22)).toBe(1); // just into lane 1 → boundary above it
+ });
+
+ it("inserts at the bottom boundary of a lane when near its bottom edge", () => {
+ expect(resolveInsertRow(1.9, n, 0.22)).toBe(2); // near bottom of lane 1 → boundary below
+ });
+
+ it("inserts above the top lane when the pointer is above everything", () => {
+ expect(resolveInsertRow(-0.5, n, 0.22)).toBe(0);
+ });
+
+ it("inserts below the bottom lane when the pointer is past the last lane", () => {
+ expect(resolveInsertRow(3.4, n, 0.22)).toBe(3);
+ });
+});
+
+// (The production insert-band suite imports INSERT_BOUNDARY_BAND from timelineLayout,
+// which lands with the timeline glue swap — the full suite re-lands there.)
+describe("clampTrackToZone", () => {
+ // trackOrder [0,1,2,3]: rows 0,1 = visual; rows 2,3 = audio (audioRow = 2).
+ const order = [0, 1, 2, 3];
+
+ it("is a no-op when there is no audio zone", () => {
+ expect(clampTrackToZone(3, order, -1, false)).toBe(3);
+ });
+
+ it("keeps a visual clip in the visual zone", () => {
+ expect(clampTrackToZone(1, order, 2, false)).toBe(1); // already visual
+ expect(clampTrackToZone(3, order, 2, false)).toBe(1); // in audio → last visual lane
+ });
+
+ it("keeps an audio clip in the audio zone", () => {
+ expect(clampTrackToZone(2, order, 2, true)).toBe(2); // already audio
+ expect(clampTrackToZone(0, order, 2, true)).toBe(2); // in visual → first audio lane
+ });
+});
+
+describe("isInsertAllowedForZone", () => {
+ // audioRow = 2
+ it("allows any insert when there is no audio zone", () => {
+ expect(isInsertAllowedForZone(0, -1, false)).toBe(true);
+ expect(isInsertAllowedForZone(3, -1, true)).toBe(true);
+ });
+
+ it("allows a visual insert only at/above the audio zone top", () => {
+ expect(isInsertAllowedForZone(0, 2, false)).toBe(true);
+ expect(isInsertAllowedForZone(2, 2, false)).toBe(true); // bottom of the visual zone
+ expect(isInsertAllowedForZone(3, 2, false)).toBe(false); // inside the audio zone
+ });
+
+ it("allows an audio insert only at/below the audio zone top (audio clips make audio tracks)", () => {
+ expect(isInsertAllowedForZone(2, 2, true)).toBe(true);
+ expect(isInsertAllowedForZone(4, 2, true)).toBe(true); // below the bottom
+ expect(isInsertAllowedForZone(1, 2, true)).toBe(false); // inside the visual zone
+ });
+});
+
+describe("resolveZoneDropPlacement (the whole drop decision, no same-track overlap)", () => {
+ // order [0,1,2] visual + [3] audio. audioRow = 3.
+ const order = [0, 1, 2, 3];
+ const audioTracks = new Set([3]);
+ const base = {
+ order,
+ audioTracks,
+ deliberateInsertRow: null as number | null,
+ start: 2,
+ duration: 2,
+ dragKey: "x",
+ isAudio: false,
+ };
+
+ it("lands on the aimed track when it is free at that time", () => {
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 10, 3)], desiredTrack: 1 }),
+ ).toEqual({ track: 1, insertRow: null });
+ });
+
+ it("relocates UP to the nearest free track when the aimed spot overlaps a clip", () => {
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: [el("a", 1, 0, 5)], desiredTrack: 1 }),
+ ).toEqual({ track: 0, insertRow: null });
+ });
+
+ it("relocates DOWN when the tracks above are also occupied", () => {
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5)],
+ desiredTrack: 1,
+ }),
+ ).toEqual({ track: 2, insertRow: null });
+ });
+
+ it("auto-creates a new track when EVERY lane in the zone is occupied at that time", () => {
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: [el("a", 0, 0, 5), el("b", 1, 0, 5), el("c", 2, 0, 5)],
+ desiredTrack: 1,
+ }),
+ ).toEqual({ track: 1, insertRow: 2 });
+ });
+
+ // Every visual lane (0,1,2) occupied across the drop span → no free lane exists.
+ const allVisualOccupied = () => [el("a", 0, 0, 10), el("b", 1, 0, 10), el("c", 2, 0, 10)];
+
+ it("occupied aim with NO free lane creates an adjacent track — never snaps back (UX rule 3)", () => {
+ // The clip must NOT return to its origin; it gets a fresh track adjacent to the
+ // aim. Default bias = below the aimed row.
+ const result = resolveZoneDropPlacement({
+ ...base,
+ elements: allVisualOccupied(),
+ desiredTrack: 1,
+ });
+ expect(result.insertRow).not.toBeNull(); // a track is created, not an origin snap-back
+ expect(result).toEqual({ track: 1, insertRow: 2 }); // adjacent, below the aimed row
+ });
+
+ it("opens the new adjacent track ABOVE the aimed row when the pointer is in its upper half (UX rule 3)", () => {
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: allVisualOccupied(),
+ desiredTrack: 1,
+ preferInsertAbove: true,
+ }),
+ ).toEqual({ track: 1, insertRow: 1 }); // boundary ABOVE lane 1
+ // Same aim, pointer in the lower half → the track opens BELOW the aimed row.
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: allVisualOccupied(),
+ desiredTrack: 1,
+ preferInsertAbove: false,
+ }),
+ ).toEqual({ track: 1, insertRow: 2 });
+ });
+
+ it("shares a track for sequential (non-overlapping) clips", () => {
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: [el("a", 1, 0, 2)],
+ desiredTrack: 1,
+ start: 2,
+ }),
+ ).toEqual({ track: 1, insertRow: null });
+ });
+
+ it("clamps a visual clip OUT of the audio zone before placing", () => {
+ expect(resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 3 })).toEqual({
+ track: 2,
+ insertRow: null,
+ });
+ });
+
+ it("clamps an audio clip INTO the audio zone before placing", () => {
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 0, isAudio: true }),
+ ).toEqual({ track: 3, insertRow: null });
+ });
+
+ it("upward create-drag off the top lane inserts at the TOP of the visual zone, never below audio (reviewer repro)", () => {
+ // 3-visual + 1-audio timeline. Dragging a top-lane visual clip up past the
+ // create threshold emits the sentinel desiredTrack = minTrack-1 = -1. The
+ // old hoist did order.indexOf(-1) = -1 and fell to below = order.length = 4,
+ // creating the new track BELOW the audio zone — repro {track:-1, insertRow:4}.
+ // It must now anchor to the top boundary of the visual zone.
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: -1 }),
+ ).toEqual({ track: -1, insertRow: 0 });
+ });
+
+ it("downward create-drag off the bottom visual lane inserts at the audio boundary, never below it", () => {
+ // Sentinel desiredTrack = maxTrack+1 = 4 for a downward create-drag. A visual
+ // clip must land at the bottom of the visual zone (row 3 = just above audio),
+ // not past the audio lanes.
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: allVisualOccupied(), desiredTrack: 4 }),
+ ).toEqual({ track: 4, insertRow: 3 });
+ });
+
+ it("audio create-drag with an out-of-range aim inserts inside the audio zone", () => {
+ // An audio clip aimed above its zone (sentinel below its own min lane) anchors
+ // to the audio zone's top boundary (row 3), not into the visual zone.
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: [el("a", 3, 0, 10)],
+ desiredTrack: -1,
+ isAudio: true,
+ }),
+ ).toEqual({ track: -1, insertRow: 3 });
+ });
+
+ it("honors a deliberate boundary insert in the clip's own zone", () => {
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 1 }),
+ ).toEqual({ track: 1, insertRow: 1 });
+ });
+
+ it("ignores a deliberate insert that lands in the WRONG zone (visual into audio)", () => {
+ expect(
+ resolveZoneDropPlacement({ ...base, elements: [], desiredTrack: 1, deliberateInsertRow: 4 }),
+ ).toEqual({ track: 1, insertRow: null });
+ });
+
+ it("lets an AUDIO clip create a new audio track via a boundary insert", () => {
+ expect(
+ resolveZoneDropPlacement({
+ ...base,
+ elements: [],
+ desiredTrack: 3,
+ isAudio: true,
+ deliberateInsertRow: 4,
+ }),
+ ).toEqual({ track: 3, insertRow: 4 });
+ });
+
+ it("Abhai repro: audio dropped on a visual-only timeline over an occupied span → insert, no overlap (#2195)", () => {
+ // No audio zone exists yet (audioTracks empty). The audio clip is aimed at the
+ // sole visual lane, which is occupied at the drop time. The zone-drop must
+ // create the audio zone's first lane (insertRow set) rather than stacking the
+ // audio clip onto the occupied visual track — the no-overlap invariant.
+ const result = resolveZoneDropPlacement({
+ order: [0],
+ audioTracks: new Set(),
+ elements: [el("v", 0, 0, 5)],
+ desiredTrack: 0,
+ deliberateInsertRow: null,
+ start: 1,
+ duration: 2,
+ dragKey: "audio",
+ isAudio: true,
+ });
+ expect(result.insertRow).not.toBeNull();
+ expect(result).toEqual({ track: 0, insertRow: 1 });
+ });
+
+ it("free-span repro: audio dropped on a FREE stretch of a visual-only timeline → insert, not a visual lane (#2195)", () => {
+ // Same visual-only timeline, but the audio clip is aimed at a stretch the sole
+ // visual lane is FREE at. The zone check must still fire an insert (create the
+ // audio zone) rather than short-circuiting on isLaneFree and dropping the audio
+ // clip onto the visual lane — the kind-zone hole where the free-lane fast path
+ // beat the wrong-zone check.
+ const result = resolveZoneDropPlacement({
+ order: [0],
+ audioTracks: new Set(),
+ elements: [el("v", 0, 0, 5)],
+ desiredTrack: 0,
+ deliberateInsertRow: null,
+ start: 10, // past the visual clip's end → the visual lane is FREE here
+ duration: 2,
+ dragKey: "audio",
+ isAudio: true,
+ });
+ expect(result.insertRow).not.toBeNull();
+ expect(result).toEqual({ track: 0, insertRow: 1 });
+ });
+});
diff --git a/packages/studio/src/player/components/timelineCollision.ts b/packages/studio/src/player/components/timelineCollision.ts
new file mode 100644
index 0000000000..6dce919ec9
--- /dev/null
+++ b/packages/studio/src/player/components/timelineCollision.ts
@@ -0,0 +1,263 @@
+import type { TimelineElement } from "../store/playerStore";
+
+/**
+ * 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
+ * clip from appearing to land in the wrong zone mid-drag (which normalizeToZones
+ * would then snap back). `audioRow` = index in `trackOrder` of the first audio
+ * lane, or -1 when there is no audio zone yet (then it's a no-op).
+ */
+export function clampTrackToZone(
+ targetTrack: number,
+ trackOrder: number[],
+ audioRow: number,
+ isAudio: boolean,
+): number {
+ if (audioRow < 0) return targetTrack;
+ const row = trackOrder.indexOf(targetTrack);
+ if (row < 0) return targetTrack;
+ if (isAudio) return row >= audioRow ? targetTrack : (trackOrder[audioRow] ?? targetTrack);
+ return row < audioRow ? targetTrack : (trackOrder[audioRow - 1] ?? targetTrack);
+}
+
+/**
+ * Whether a new-track insert at boundary `insertRow` is allowed for a clip of the
+ * given kind. Visual clips may only insert visual lanes (boundary at/above the top
+ * of the audio zone); audio clips may only insert audio lanes (boundary at/below
+ * it) — so audio clips CAN create a new audio track, and neither kind inserts into
+ * the other's zone. `audioRow` = first audio lane row, or -1 (no audio zone) → any.
+ */
+export function isInsertAllowedForZone(
+ insertRow: number,
+ audioRow: number,
+ isAudio: boolean,
+): boolean {
+ if (audioRow < 0) return true;
+ return isAudio ? insertRow >= audioRow : insertRow <= audioRow;
+}
+
+/**
+ * The full drop-placement decision for a dragged clip — one pure, testable unit.
+ * Enforces: NO time-overlap on a single track; a clip stays in its kind-zone;
+ * a new track is created only when needed. Order of resolution:
+ * 1. Deliberate boundary insert (pointer near a lane edge), if it's in the
+ * clip's own zone → create a new track there.
+ * 2. Otherwise land on a lane: clamp the aimed track to the clip's zone, take it
+ * if free at [start, start+duration), else the nearest FREE lane in the zone
+ * (prefer up), else auto-create a new track right below the aimed lane.
+ * `audioTracks` = the set of track indices that currently hold audio (so the fn
+ * needs no element-kind import). Returns the landing `track` and, when a new track
+ * should be created, the `insertRow` boundary (else null).
+ *
+ * `preferInsertAbove` biases the auto-created track (occupied-aim → new adjacent
+ * track) toward the boundary ABOVE the aimed row instead of below it, so the new
+ * lane opens on whichever side of the aimed clip the pointer is nearer (the drag
+ * preview passes the pointer's sub-row half). A clip whose aimed span is occupied
+ * never snaps back to its origin — it relocates to a free lane, or (none free)
+ * gets a fresh track next to the aim. Default (below) preserves prior behaviour.
+ */
+/**
+ * Insert-row boundary for an out-of-range aim — a `desired` track that isn't a
+ * real lane: the sentinel minTrack-1 an upward create-drag emits (#2214-adjacent
+ * repro) or a beyond-the-bottom index a downward one does. Anchors the new track
+ * to a boundary of the clip's OWN kind-zone so a visual insert can never land
+ * past the audio zone (the old below = order.length fallback dropped it BELOW the
+ * audio lanes). Above the zone (`desired` < the zone's min lane) → the zone's TOP
+ * boundary; otherwise → its BOTTOM boundary (for a visual clip, the top of the
+ * audio zone). `zoneTracks` = this kind's lanes, in `order` sequence.
+ */
+function outOfRangeZoneInsertRow(
+ order: number[],
+ zoneTracks: number[],
+ 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;
+ // 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]);
+ const zoneBottom = order.indexOf(zoneTracks[zoneTracks.length - 1]) + 1;
+ return desired < Math.min(...zoneTracks) ? zoneTop : zoneBottom;
+}
+
+export function resolveZoneDropPlacement(input: {
+ order: number[];
+ audioTracks: ReadonlySet;
+ elements: TimelineElement[];
+ desiredTrack: number;
+ deliberateInsertRow: number | null;
+ start: number;
+ duration: number;
+ dragKey: string;
+ isAudio: boolean;
+ preferInsertAbove?: boolean;
+}): { track: number; insertRow: number | null } {
+ const { order, audioTracks, elements, desiredTrack, deliberateInsertRow } = input;
+ const { start, duration, dragKey, isAudio, preferInsertAbove } = input;
+ const audioRow = order.findIndex((t) => audioTracks.has(t));
+
+ if (
+ deliberateInsertRow !== null &&
+ isInsertAllowedForZone(deliberateInsertRow, audioRow, isAudio)
+ ) {
+ return { track: desiredTrack, insertRow: deliberateInsertRow };
+ }
+
+ const desired = clampTrackToZone(desiredTrack, order, audioRow, isAudio);
+ const zoneTracks = order.filter((t) => audioTracks.has(t) === isAudio);
+ const placement = resolvePlacement({
+ elements,
+ desiredTrack: desired,
+ start,
+ duration,
+ trackOrder: zoneTracks,
+ excludeKey: dragKey,
+ });
+ if (placement.needsInsert) {
+ const desiredRow = order.indexOf(desired);
+ if (desiredRow < 0) {
+ return {
+ track: desired,
+ insertRow: outOfRangeZoneInsertRow(order, zoneTracks, audioRow, desired),
+ };
+ }
+ // Prefer the gap NEAREST the pointer: insert above the aimed row when the
+ // pointer sits in its upper half AND that boundary is in the clip's own zone
+ // (else the visual/audio split would be crossed) — otherwise fall to below.
+ // `desired` is clamped into the zone, so both boundaries stay in-zone.
+ const insertRow =
+ preferInsertAbove && isInsertAllowedForZone(desiredRow, audioRow, isAudio)
+ ? desiredRow
+ : desiredRow + 1;
+ return { track: desired, insertRow };
+ }
+ return { track: placement.track, insertRow: null };
+}
+
+/**
+ * Fallback half-width (fraction of a track height) of the insert band straddling
+ * a lane boundary — used only when the caller passes no explicit band. Production
+ * threads the geometry-exact `INSERT_BOUNDARY_BAND` (timelineLayout.ts, = the clip
+ * inset `CLIP_Y / TRACK_H`) so the band matches the rendered inter-clip gutter and
+ * NEVER reaches into a clip body. Kept in sync with that constant; do not widen it
+ * back toward the old 0.32 (which armed an insert across ~64% of every row — the
+ * misfire that turned a plain horizontal drag into a phantom track insert).
+ */
+const INSERT_BAND = 3 / 48;
+
+/**
+ * Decide whether a vertical drag is inserting a new track at a lane boundary.
+ * `rowFloat` is the pointer's position in track-height units from the top of the
+ * first lane (0 = top of lane 0). Returns the boundary row to insert at
+ * (0 = above the top lane, `trackCount` = below the bottom), or null when the
+ * pointer is over a lane's middle band (a normal move/target).
+ */
+export function resolveInsertRow(
+ rowFloat: number,
+ trackCount: number,
+ band: number = INSERT_BAND,
+): number | null {
+ if (trackCount === 0) return 0;
+ if (rowFloat <= 0) return 0;
+ if (rowFloat >= trackCount) return trackCount;
+ const lane = Math.floor(rowFloat);
+ const frac = rowFloat - lane;
+ if (frac < band) return lane;
+ if (frac > 1 - band) return lane + 1;
+ return null;
+}
+
+/** Half-open overlap test: [aStart, aEnd) intersects [bStart, bEnd). */
+export function timeRangesOverlap(
+ aStart: number,
+ aEnd: number,
+ bStart: number,
+ bEnd: number,
+): boolean {
+ return aStart < bEnd && bStart < aEnd;
+}
+
+/**
+ * True when no clip on `track` overlaps [start, end) — excluding the clip
+ * identified by `excludeKey` (the one being dragged).
+ */
+export function isLaneFree(
+ elements: TimelineElement[],
+ track: number,
+ start: number,
+ end: number,
+ excludeKey: string | null,
+): boolean {
+ return !elements.some(
+ (el) =>
+ (el.key ?? el.id) !== excludeKey &&
+ el.track === track &&
+ timeRangesOverlap(start, end, el.start, el.start + el.duration),
+ );
+}
+
+export interface PlacementInput {
+ elements: TimelineElement[];
+ desiredTrack: number;
+ start: number;
+ duration: number;
+ trackOrder: number[];
+ excludeKey: string | null;
+}
+
+export interface PlacementResult {
+ /** The lane the clip should land on. */
+ track: number;
+ /**
+ * True when no existing lane was free and the caller should insert a new
+ * track instead of landing on `track` (which is then the desired lane as a
+ * last-resort fallback). Consumed in later stages (2b/2c); stage 2a ignores it.
+ */
+ needsInsert: boolean;
+}
+
+/**
+ * Resolve where a dragged clip should land, avoiding overlap. If the desired
+ * lane is free, keep it. Otherwise search the nearest free lane, **preferring
+ * up** (all lanes above, nearest first), then down. If none is free, signal an
+ * insert and fall back to the desired lane.
+ */
+export function resolvePlacement({
+ elements,
+ desiredTrack,
+ start,
+ duration,
+ trackOrder,
+ excludeKey,
+}: PlacementInput): PlacementResult {
+ const end = start + duration;
+ const idx = trackOrder.indexOf(desiredTrack);
+ // desiredTrack is not one of the zone's lanes — the clip's kind-zone has no lane
+ // yet (e.g. an audio clip dropped on a visual-only timeline). This MUST be checked
+ // BEFORE the isLaneFree short-circuit below: a free-aimed span on a foreign-zone
+ // lane (an audio clip aimed at an empty stretch of a visual-only timeline) is
+ // "free" only because that lane belongs to the wrong zone. Landing there would
+ // put the clip in the wrong kind-zone, so signal an insert to create the zone's
+ // first lane instead — regardless of whether the aimed span is occupied (#2195).
+ if (idx === -1) return { track: desiredTrack, needsInsert: true };
+
+ if (isLaneFree(elements, desiredTrack, start, end, excludeKey)) {
+ return { track: desiredTrack, needsInsert: false };
+ }
+
+ // Prefer up: nearest lane above first, then the rest above.
+ for (let up = idx - 1; up >= 0; up--) {
+ if (isLaneFree(elements, trackOrder[up], start, end, excludeKey)) {
+ return { track: trackOrder[up], needsInsert: false };
+ }
+ }
+ // Then down: nearest lane below first.
+ for (let down = idx + 1; down < trackOrder.length; down++) {
+ if (isLaneFree(elements, trackOrder[down], start, end, excludeKey)) {
+ return { track: trackOrder[down], needsInsert: false };
+ }
+ }
+ return { track: desiredTrack, needsInsert: true };
+}
diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts
index a8d1bb0a66..685f96e7c4 100644
--- a/packages/studio/src/player/components/timelineEditing.ts
+++ b/packages/studio/src/player/components/timelineEditing.ts
@@ -525,3 +525,72 @@ export function buildTimelineElementAgentPrompt(element: {
export function formatTimelineAttributeNumber(value: number): string {
return Number(roundToCentiseconds(value).toFixed(2)).toString();
}
+
+/**
+ * Apply one edge auto-scroll step: scroll `scroll` toward the edge zone the
+ * pointer is in, clamped to the scrollable range. Returns true when the
+ * container actually moved (the caller keeps its RAF running and re-runs its
+ * live preview), false when the pointer is outside the edge zones or the scroll
+ * is already clamped (the caller stops).
+ */
+export function applyTimelineAutoScrollStep(
+ scroll: HTMLElement,
+ clientX: number,
+ clientY: number,
+): boolean {
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
+ if (delta.x === 0 && delta.y === 0) return false;
+ const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
+ const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
+ const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
+ if (nextScrollLeft === scroll.scrollLeft && nextScrollTop === scroll.scrollTop) return false;
+ scroll.scrollLeft = nextScrollLeft;
+ scroll.scrollTop = nextScrollTop;
+ return true;
+}
+
+/**
+ * Decide whether an edge auto-scroll RAF loop should start, stop, or stay as-is
+ * for the current pointer: "start" when the pointer is in an edge zone and no
+ * loop is running, "stop" when it left the zones while a loop is running,
+ * "none" otherwise.
+ */
+export function resolveTimelineAutoScrollLoopAction(
+ scroll: HTMLElement | null,
+ clientX: number,
+ clientY: number,
+ rafActive: boolean,
+): "start" | "stop" | "none" {
+ if (!scroll) return "none";
+ const delta = resolveTimelineAutoScroll(scroll.getBoundingClientRect(), clientX, clientY);
+ if (delta.x === 0 && delta.y === 0) return rafActive ? "stop" : "none";
+ return rafActive ? "none" : "start";
+}
+
+/**
+ * Escape cancels an in-progress clip drag / resize / blocked-drag: no commit,
+ * no undo entry — the previews live only in the gesture state, so clearing it
+ * restores the pre-drag timeline. `suppressClick` arms the click suppressor
+ * only when the gesture actually started, so the click generated by the
+ * eventual pointerup can't reselect or split the clip.
+ */
+export function resolveTimelineDragEscape(input: TimelineDragEscapeInput): {
+ cancel: boolean;
+ suppressClick: boolean;
+} {
+ if (input.key !== "Escape" || (!input.drag && !input.resize && !input.blocked)) {
+ return { cancel: false, suppressClick: false };
+ }
+ return {
+ cancel: true,
+ suppressClick: Boolean(input.drag?.started || input.resize?.started || input.blocked?.started),
+ };
+}
+
+export interface TimelineDragEscapeInput {
+ key: string;
+ drag: { started: boolean } | null;
+ resize: { started: boolean } | null;
+ blocked: { started: boolean } | null;
+}
diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts
index d2ecaabed4..c5251846eb 100644
--- a/packages/studio/src/player/components/timelineLayout.ts
+++ b/packages/studio/src/player/components/timelineLayout.ts
@@ -256,3 +256,91 @@ export function resolveTimelineAssetDrop(
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
};
}
+
+/**
+ * The y (content-space) of the top edge of track ROW index `row` (0 = first
+ * displayed lane). The single source of truth for row→y — the ruler height plus
+ * the top breathing pad plus whole track lanes above it. Every clip/ghost/
+ * placeholder/insertion top and every pointer-y→row inversion goes through this
+ * (or its inverse in {@link getTimelineRowFromY}) so the pad can never drift.
+ */
+export function getTimelineRowTop(row: number): number {
+ return RULER_H + TRACKS_TOP_PAD + row * TRACK_H;
+}
+
+/**
+ * Inverse of {@link getTimelineRowTop}: the fractional row index for a content-
+ * space y (used for insert-row / drop-lane decisions). Subtracts the ruler and
+ * top pad before dividing by the track height.
+ */
+export function getTimelineRowFromY(contentY: number): number {
+ return (contentY - RULER_H - TRACKS_TOP_PAD) / TRACK_H;
+}
+
+/**
+ * While a clip drag is live, the rendered timeline extends this far past the
+ * ghost's end so the right-edge auto-scroll zone always has room to keep
+ * stepping — that's what lets a drag extend the timeline past its current
+ * rendered width (see Timeline.tsx displayContentWidth).
+ */
+export const DRAG_EXTEND_MARGIN_PX = 160;
+
+/**
+ * Fit-mode pixels-per-second: fill the viewport with the composition, but
+ * never map fewer than MIN_TIMELINE_EXTENT_S seconds onto it — a short comp
+ * takes a fraction of the width and the remaining ruler runs to 1:00.
+ * Manual zoom multiplies this base, so the floor only anchors the default.
+ */
+export function getTimelineFitPps(viewportWidth: number, effectiveDuration: number): number {
+ const safeDuration =
+ Number.isFinite(effectiveDuration) && effectiveDuration > 0 ? effectiveDuration : 0;
+ const span = Math.max(safeDuration, MIN_TIMELINE_EXTENT_S);
+ if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
+ return (viewportWidth - GUTTER - 2) / span;
+}
+
+/**
+ * The rendered timeline extent in px. Always covers, whichever is largest:
+ * the actual clip content, the visible viewport (no dead black after short
+ * content — CapCut-style), a live drag or resize ghost plus the auto-scroll
+ * margin (drag/trim-to-extend), and the MIN_TIMELINE_EXTENT_S floor. Only the
+ * RENDERED extent grows; clip positions/durations are untouched.
+ */
+export function getTimelineDisplayContentWidth(input: {
+ trackContentWidth: number;
+ viewportWidth: number;
+ pps: number;
+ dragGhostEndPx?: number;
+ resizeGhostEndPx?: number;
+}): number {
+ const safePps = Number.isFinite(input.pps) ? Math.max(input.pps, 0) : 0;
+ return Math.max(
+ input.trackContentWidth,
+ input.viewportWidth - GUTTER - 2,
+ input.dragGhostEndPx ?? 0,
+ input.resizeGhostEndPx ?? 0,
+ MIN_TIMELINE_EXTENT_S * safePps,
+ );
+}
+
+/**
+ * Breathing room INSIDE the scroll area (CapCut-style), threaded through every
+ * track-row y computation via {@link getTimelineRowTop} — never inline a magic
+ * offset; a track row's top is always `RULER_H + TRACKS_TOP_PAD + row*TRACK_H`.
+ *
+ * - TRACKS_TOP_PAD: empty space between the (sticky) ruler and the first track
+ * (~half a track height) so the first clip isn't jammed under the ruler.
+ * - TRACKS_BOTTOM_PAD: empty space below the last track (~1.5 track heights),
+ * enough to comfortably drag a clip into the void to create a new bottom lane.
+ */
+export const TRACKS_TOP_PAD = 50;
+
+/**
+ * The rendered timeline always spans at least this many seconds of ruler +
+ * track lanes, even when the composition is shorter — the empty space on the
+ * right is a real, drag/drop-enabled surface (clips can be moved into it; the
+ * composition grows on commit, content-driven). In fit mode the fit pps is
+ * derived against this floor, so a 10s comp renders as ~1/6 of the viewport
+ * with 60s of ruler after it.
+ */
+export const MIN_TIMELINE_EXTENT_S = 60;
diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.test.ts b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts
new file mode 100644
index 0000000000..6d7791991e
--- /dev/null
+++ b/packages/studio/src/player/components/timelineMultiDragPreview.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from "vitest";
+import {
+ clampGroupMoveDelta,
+ isMultiDragActive,
+ isMultiDragPassenger,
+ multiDragDeltaSeconds,
+ multiDragPassengerOffsetPx,
+ type MultiDragPreviewInput,
+} from "./timelineMultiDragPreview";
+
+const base = (over: Partial = {}): MultiDragPreviewInput => ({
+ dragStarted: true,
+ draggedKey: "a",
+ draggedOriginStart: 2,
+ draggedPreviewStart: 5,
+ selectedKeys: new Set(["a", "b", "c"]),
+ ...over,
+});
+
+describe("isMultiDragActive", () => {
+ it("is active when a started drag's clip is part of a 2+ selection", () => {
+ expect(isMultiDragActive(base())).toBe(true);
+ });
+
+ it("is inactive before the drag starts", () => {
+ expect(isMultiDragActive(base({ dragStarted: false }))).toBe(false);
+ });
+
+ it("is inactive for a single-clip selection (single-drag behavior)", () => {
+ expect(isMultiDragActive(base({ selectedKeys: new Set(["a"]) }))).toBe(false);
+ });
+
+ it("is inactive when the dragged clip is not itself selected", () => {
+ expect(isMultiDragActive(base({ draggedKey: "z" }))).toBe(false);
+ });
+});
+
+describe("multiDragDeltaSeconds (the one formation delta)", () => {
+ it("is the grabbed clip's preview − origin start when active", () => {
+ // The preview start is already group-clamped upstream, so this delta is the
+ // clamped delta every member (ghost + passengers) moves by.
+ expect(multiDragDeltaSeconds(base())).toBe(3);
+ });
+
+ it("supports a leftward (negative) delta", () => {
+ expect(multiDragDeltaSeconds(base({ draggedPreviewStart: 0.5 }))).toBeCloseTo(-1.5);
+ });
+
+ it("is zero when no multi-drag is active", () => {
+ expect(multiDragDeltaSeconds(base({ selectedKeys: new Set(["a"]) }))).toBe(0);
+ });
+});
+
+describe("isMultiDragPassenger", () => {
+ it("marks a selected non-dragged clip as a passenger", () => {
+ expect(isMultiDragPassenger("b", base())).toBe(true);
+ expect(isMultiDragPassenger("c", base())).toBe(true);
+ });
+
+ it("never marks the dragged clip itself (it is the free ghost)", () => {
+ expect(isMultiDragPassenger("a", base())).toBe(false);
+ });
+
+ it("never marks an unselected clip", () => {
+ expect(isMultiDragPassenger("d", base())).toBe(false);
+ });
+
+ it("marks nothing when the drag is a single-drag", () => {
+ const single = base({ selectedKeys: new Set(["a"]) });
+ expect(isMultiDragPassenger("b", single)).toBe(false);
+ });
+});
+
+describe("multiDragPassengerOffsetPx (rigid: every passenger shares the delta)", () => {
+ it("converts the one formation delta to pixels for every passenger", () => {
+ // Both passengers move by the SAME 3s × 100pps = 300px — spacing locked.
+ expect(multiDragPassengerOffsetPx("b", 100, base())).toBe(300);
+ expect(multiDragPassengerOffsetPx("c", 100, base())).toBe(300);
+ });
+
+ it("is zero for the dragged clip and for non-passengers", () => {
+ expect(multiDragPassengerOffsetPx("a", 100, base())).toBe(0);
+ expect(multiDragPassengerOffsetPx("d", 100, base())).toBe(0);
+ });
+
+ it("is zero for a non-finite pps", () => {
+ expect(multiDragPassengerOffsetPx("b", Number.NaN, base())).toBe(0);
+ });
+
+ it("follows a leftward delta", () => {
+ expect(multiDragPassengerOffsetPx("c", 50, base({ draggedPreviewStart: 0 }))).toBe(-100);
+ });
+});
+
+describe("clampGroupMoveDelta (rigid group move)", () => {
+ it("passes a rightward delta through unchanged (no right wall)", () => {
+ expect(clampGroupMoveDelta(3, [2, 5, 9])).toBe(3);
+ expect(clampGroupMoveDelta(1000, [0, 4])).toBe(1000);
+ });
+
+ it("passes a leftward delta through when no member would cross 0", () => {
+ // Leftmost member at 5, moving left by 3 → 2 ≥ 0, so unclamped.
+ expect(clampGroupMoveDelta(-3, [5, 8, 12])).toBe(-3);
+ });
+
+ it("clamps a leftward delta so the leftmost member stops exactly at 0", () => {
+ // Leftmost at 2 → the furthest left the group can move is -2 (that member → 0).
+ // A pointer asking for -5 is clamped to -2: the grabbed clip stops with the
+ // formation instead of out-running it.
+ expect(clampGroupMoveDelta(-5, [2, 6, 10])).toBe(-2);
+ });
+
+ it("is bounded by the MOST-constrained (leftmost) member, not the grabbed one", () => {
+ // Grabbed clip is at 10; a passenger at 1 is the constraint. Max left = -1.
+ expect(clampGroupMoveDelta(-8, [10, 1, 4])).toBe(-1);
+ });
+
+ it("already-at-0 member forbids any leftward move", () => {
+ expect(clampGroupMoveDelta(-4, [0, 3, 7])).toBe(0);
+ // rightward still allowed
+ expect(clampGroupMoveDelta(2, [0, 3, 7])).toBe(2);
+ });
+
+ it("returns the raw delta for an empty formation", () => {
+ expect(clampGroupMoveDelta(-9, [])).toBe(-9);
+ });
+});
diff --git a/packages/studio/src/player/components/timelineMultiDragPreview.ts b/packages/studio/src/player/components/timelineMultiDragPreview.ts
new file mode 100644
index 0000000000..6b85466322
--- /dev/null
+++ b/packages/studio/src/player/components/timelineMultiDragPreview.ts
@@ -0,0 +1,106 @@
+/**
+ * Pure geometry for the LIVE multi-selection drag preview.
+ *
+ * Visual model (matches main): while a selected clip is dragged, ALL selected
+ * clips move together LIVE as one rigid formation following the cursor. The
+ * GRABBED clip is drawn as the free-floating ghost; every OTHER selected member
+ * ("passenger") slides by the SAME time delta via a cheap compositor
+ * `translateX` (no re-layout). Passengers do NOT stay still, and they do NOT lag
+ * behind individually — the whole formation moves by one delta, spacing locked.
+ *
+ * That single delta is the GRABBED clip's `draggedPreviewStart − draggedOriginStart`.
+ * The preview start is ALREADY group-clamped upstream (updateDraggedClipPreview
+ * runs clampGroupMoveDelta before setting it), so this delta is the clamped delta:
+ * the instant any member would cross 0 the grabbed clip stops and every passenger
+ * stops with it — the formation never deforms. On DROP the commit shifts every
+ * selected clip by this same delta (see timelineClipDragCommit / useTimelineClipDrag).
+ *
+ * Track changes apply to the grabbed clip only (mirroring the commit); passengers
+ * keep their lanes, so only their x moves.
+ */
+
+export interface MultiDragPreviewInput {
+ /** The drag is live (past the movement threshold). */
+ dragStarted: boolean;
+ /** Key of the clip under the pointer. */
+ draggedKey: string;
+ /** The dragged clip's committed start (pre-drag). */
+ draggedOriginStart: number;
+ /** The dragged clip's live preview start (already group-clamped upstream). */
+ draggedPreviewStart: number;
+ /** The current multi-selection (store.selectedElementIds). */
+ selectedKeys: ReadonlySet;
+}
+
+/**
+ * Whether a live multi-selection drag is in effect: the drag started, and the
+ * dragged clip is itself part of a 2+ multi-selection. Below this, single-drag
+ * behavior is unchanged and there are no passengers.
+ */
+export function isMultiDragActive(input: MultiDragPreviewInput): boolean {
+ return (
+ input.dragStarted && input.selectedKeys.size > 1 && input.selectedKeys.has(input.draggedKey)
+ );
+}
+
+/**
+ * The single time delta the WHOLE formation shifts by — the grabbed clip's
+ * preview start minus its origin start. Because the preview start is already
+ * group-clamped, this is the clamped delta every member (ghost + passengers)
+ * moves by. Zero when the clip hasn't moved (or no multi-drag).
+ */
+export function multiDragDeltaSeconds(input: MultiDragPreviewInput): number {
+ if (!isMultiDragActive(input)) return 0;
+ return input.draggedPreviewStart - input.draggedOriginStart;
+}
+
+/**
+ * Whether a specific rendered clip is a passenger — a selected clip that is NOT
+ * the dragged clip and NOT the same clip key. Passengers get the translateX
+ * treatment; the dragged clip is drawn as the free-floating ghost instead.
+ */
+export function isMultiDragPassenger(clipKey: string, input: MultiDragPreviewInput): boolean {
+ return (
+ isMultiDragActive(input) && clipKey !== input.draggedKey && input.selectedKeys.has(clipKey)
+ );
+}
+
+/**
+ * The passenger's rendered x offset in PIXELS (delta seconds × pixels/second),
+ * to apply as `transform: translateX(...px)`. Every passenger uses the SAME
+ * formation delta, so the group moves rigidly. Returns 0 for non-passengers so
+ * callers can compute unconditionally and only branch on the elevated styling.
+ */
+export function multiDragPassengerOffsetPx(
+ clipKey: string,
+ pixelsPerSecond: number,
+ input: MultiDragPreviewInput,
+): number {
+ if (!isMultiDragPassenger(clipKey, input)) return 0;
+ const pps = Number.isFinite(pixelsPerSecond) ? pixelsPerSecond : 0;
+ return multiDragDeltaSeconds(input) * pps;
+}
+
+/**
+ * Clamp a group move so the WHOLE selection moves as ONE rigid formation.
+ *
+ * The grabbed clip proposes a raw delta (its desired preview start minus its
+ * origin start, after its own snapping). Applied naively, a passenger could be
+ * pushed below 0 (or past any other member bound), and the commit's per-clip
+ * `Math.max(0, …)` would then deform the formation — the grabbed clip out-runs
+ * the group while a passenger sticks at the wall. This ports main's model
+ * (useTimelineClipGroupDrag / clampTimelineGroupMoveDelta): the applied delta is
+ * bounded by the MOST-CONSTRAINED member, so the grabbed clip STOPS the instant
+ * any member hits 0 and the formation never deforms.
+ *
+ * `memberStarts` are the pre-drag starts of every selected clip (the grabbed clip
+ * included). Only the lower bound (start ≥ 0) constrains a move; the timeline has
+ * no fixed right wall (the composition grows on commit).
+ */
+export function clampGroupMoveDelta(rawDelta: number, memberStarts: readonly number[]): number {
+ if (memberStarts.length === 0) return rawDelta;
+ // Leftmost member sets the floor: delta ≥ -min(start) keeps every start ≥ 0.
+ const minStart = Math.min(...memberStarts);
+ const minDelta = minStart === 0 ? 0 : -minStart; // avoid -0
+ return rawDelta < minDelta ? minDelta : rawDelta;
+}
diff --git a/packages/studio/src/player/components/timelineSnapping.test.ts b/packages/studio/src/player/components/timelineSnapping.test.ts
new file mode 100644
index 0000000000..35980b85e3
--- /dev/null
+++ b/packages/studio/src/player/components/timelineSnapping.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+import {
+ TIMELINE_SNAP_PX,
+ collectTimelineSnapTargets,
+ snapMoveToTargets,
+ snapTimelineTime,
+} from "./timelineSnapping";
+
+describe("collectTimelineSnapTargets", () => {
+ const elements = [
+ { start: 2, duration: 3, key: "a", id: "a" },
+ { start: 10, duration: 1.5, key: "b", id: "b" },
+ ];
+
+ it("collects clip starts and ends, playhead, and beats with types", () => {
+ const targets = collectTimelineSnapTargets({
+ elements,
+ playheadTime: 7.25,
+ beatTimes: [0.5, 1.0],
+ });
+ expect(targets).toContainEqual({ time: 2, type: "clip-edge" });
+ expect(targets).toContainEqual({ time: 5, type: "clip-edge" });
+ expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
+ expect(targets).toContainEqual({ time: 11.5, type: "clip-edge" });
+ expect(targets).toContainEqual({ time: 7.25, type: "playhead" });
+ expect(targets).toContainEqual({ time: 0.5, type: "beat" });
+ });
+
+ it("excludes the dragged element's own edges", () => {
+ const targets = collectTimelineSnapTargets({
+ elements,
+ playheadTime: null,
+ beatTimes: [],
+ excludeElementKey: "a",
+ });
+ expect(targets.some((t) => t.time === 2)).toBe(false);
+ expect(targets.some((t) => t.time === 5)).toBe(false);
+ expect(targets).toContainEqual({ time: 10, type: "clip-edge" });
+ });
+
+ it("omits playhead when null and dedupes identical times preferring playhead > clip-edge > beat", () => {
+ const targets = collectTimelineSnapTargets({
+ elements: [{ start: 1, duration: 1, key: "x", id: "x" }],
+ playheadTime: 2,
+ beatTimes: [2],
+ });
+ const atTwo = targets.filter((t) => t.time === 2);
+ expect(atTwo).toEqual([{ time: 2, type: "playhead" }]);
+ });
+
+ it("dedupes a coincident clip-edge and beat preferring clip-edge (no playhead)", () => {
+ // A beat and a clip edge land on the same time (2). clip-edge has higher
+ // priority than beat, so the deduped target is clip-edge — not beat.
+ const targets = collectTimelineSnapTargets({
+ elements: [{ start: 2, duration: 3, key: "x", id: "x" }],
+ playheadTime: null,
+ beatTimes: [2],
+ });
+ const atTwo = targets.filter((t) => t.time === 2);
+ expect(atTwo).toEqual([{ time: 2, type: "clip-edge" }]);
+ });
+});
+
+describe("snapTimelineTime", () => {
+ const targets = [
+ { time: 5, type: "clip-edge" as const },
+ { time: 5.3, type: "playhead" as const },
+ ];
+
+ it("snaps to the nearest target within threshold", () => {
+ expect(snapTimelineTime(5.05, targets, 0.1)).toEqual({
+ time: 5,
+ target: { time: 5, type: "clip-edge" },
+ });
+ });
+
+ it("returns input unchanged when nothing is within threshold", () => {
+ expect(snapTimelineTime(6, targets, 0.1)).toEqual({ time: 6, target: null });
+ });
+});
+
+describe("snapMoveToTargets", () => {
+ // pps=100 → threshold = TIMELINE_SNAP_PX/100 = 0.08s
+ const targets = [{ time: 5, type: "playhead" as const }];
+
+ it("snaps the start edge when it is the closer edge", () => {
+ const r = snapMoveToTargets(5.05, 2, targets, 100, 60);
+ expect(r).toEqual({ start: 5, snapTime: 5, snapType: "playhead" });
+ });
+
+ it("snaps the end edge, shifting start so the end lands on the target", () => {
+ const r = snapMoveToTargets(3.03, 2, targets, 100, 60);
+ expect(r.start).toBeCloseTo(3, 5);
+ expect(r.snapTime).toBe(5);
+ expect(r.snapType).toBe("playhead");
+ });
+
+ it("drops the snap when clamping to timeline bounds pulls it off target", () => {
+ // duration 2, timeline 6 → maxStart 4; target at 5.05 wants start 5.05 → clamped to 4
+ const r = snapMoveToTargets(5.0, 2, [{ time: 5.05, type: "beat" }], 100, 6);
+ expect(r.snapTime).toBeNull();
+ });
+
+ it("threshold scales with pixels-per-second", () => {
+ // pps=10 → threshold 0.8s: 5.5 snaps; pps=1000 → threshold 0.008s: it does not
+ expect(snapMoveToTargets(5.5, 2, targets, 10, 60).snapTime).toBe(5);
+ expect(snapMoveToTargets(5.5, 2, targets, 1000, 60).snapTime).toBeNull();
+ });
+
+ it("TIMELINE_SNAP_PX matches the historical beat-snap threshold", () => {
+ expect(TIMELINE_SNAP_PX).toBe(8);
+ });
+
+ it("keeps the snap indicator for a frame-quantized duration (ms-rounding residue, no clamp)", () => {
+ // duration 10/3 ≈ 3.3333…; end-snap onto a clip-edge at 5 gives a candidate
+ // start of 5 - 10/3 = 1.6666…, whose ms-rounding residue (~3.3e-4) exceeds the
+ // old 1e-6 tolerance. With a huge timeline (no bounds clamp), the snap must
+ // survive — the clip snaps AND the indicator shows.
+ const duration = 10 / 3;
+ const edge = [{ time: 5, type: "clip-edge" as const }];
+ const r = snapMoveToTargets(5 - duration + 0.001, duration, edge, 100, 1000);
+ expect(r.snapTime).toBe(5);
+ expect(r.snapType).toBe("clip-edge");
+ });
+
+ it("still drops the snap when the bounds clamp genuinely moves a frame-quantized clip off target", () => {
+ // Same 10/3 duration, but the timeline is short enough that clamping to maxStart
+ // pulls the clip off the target — the indicator must still vanish (the residue
+ // widening must not mask a real clamp).
+ const duration = 10 / 3;
+ const r = snapMoveToTargets(5.0, duration, [{ time: 5.05, type: "beat" }], 100, 6);
+ expect(r.snapTime).toBeNull();
+ });
+});
diff --git a/packages/studio/src/player/components/timelineSnapping.ts b/packages/studio/src/player/components/timelineSnapping.ts
new file mode 100644
index 0000000000..30d1175273
--- /dev/null
+++ b/packages/studio/src/player/components/timelineSnapping.ts
@@ -0,0 +1,110 @@
+import type { TimelineElement } from "../store/playerStore";
+
+export type TimelineSnapType = "beat" | "playhead" | "clip-edge";
+
+export interface TimelineSnapTarget {
+ time: number;
+ type: TimelineSnapType;
+}
+
+/** Pixel radius within which a time snaps to a target (matches historical beat snap). */
+export const TIMELINE_SNAP_PX = 8;
+
+const TYPE_PRIORITY: Record = {
+ playhead: 0,
+ "clip-edge": 1,
+ beat: 2,
+};
+
+export function collectTimelineSnapTargets(input: {
+ elements: ReadonlyArray>;
+ playheadTime: number | null;
+ beatTimes: readonly number[];
+ excludeElementKey?: string | null;
+}): TimelineSnapTarget[] {
+ const byTime = new Map();
+ const add = (time: number, type: TimelineSnapType) => {
+ if (!Number.isFinite(time) || time < 0) return;
+ const rounded = Math.round(time * 1000) / 1000;
+ const existing = byTime.get(rounded);
+ if (!existing || TYPE_PRIORITY[type] < TYPE_PRIORITY[existing.type]) {
+ byTime.set(rounded, { time: rounded, type });
+ }
+ };
+
+ for (const beat of input.beatTimes) add(beat, "beat");
+ for (const el of input.elements) {
+ if (input.excludeElementKey != null && (el.key ?? el.id) === input.excludeElementKey) continue;
+ add(el.start, "clip-edge");
+ add(el.start + el.duration, "clip-edge");
+ }
+ if (input.playheadTime != null) add(input.playheadTime, "playhead");
+
+ return Array.from(byTime.values()).sort((a, b) => a.time - b.time);
+}
+
+export function snapTimelineTime(
+ time: number,
+ targets: readonly TimelineSnapTarget[],
+ thresholdSecs: number,
+): { time: number; target: TimelineSnapTarget | null } {
+ let best: TimelineSnapTarget | null = null;
+ let bestDist = thresholdSecs;
+ for (const target of targets) {
+ const d = Math.abs(target.time - time);
+ if (
+ d < bestDist ||
+ (d === bestDist && best && TYPE_PRIORITY[target.type] < TYPE_PRIORITY[best.type])
+ ) {
+ bestDist = d;
+ best = target;
+ }
+ }
+ return best ? { time: best.time, target: best } : { time, target: null };
+}
+
+/**
+ * Snap a moved clip so whichever edge (start or end) is nearest a target lands
+ * on it, keeping duration fixed. Mirrors the historical beat-snap semantics:
+ * clamp to [0, timelineDuration - duration]; if clamping pulls the clip off the
+ * target, drop the highlight.
+ */
+export function snapMoveToTargets(
+ start: number,
+ duration: number,
+ targets: readonly TimelineSnapTarget[],
+ pixelsPerSecond: number,
+ timelineDuration: number,
+): { start: number; snapTime: number | null; snapType: TimelineSnapType | null } {
+ if (targets.length === 0) return { start, snapTime: null, snapType: null };
+ const thresholdSecs = TIMELINE_SNAP_PX / Math.max(pixelsPerSecond, 1);
+ const startSnap = snapTimelineTime(start, targets, thresholdSecs);
+ const endSnap = snapTimelineTime(start + duration, targets, thresholdSecs);
+ const startMoved = startSnap.target !== null;
+ const endMoved = endSnap.target !== null;
+
+ let candidate = start;
+ let target: TimelineSnapTarget | null = null;
+ if (
+ startMoved &&
+ (!endMoved || Math.abs(startSnap.time - start) <= Math.abs(endSnap.time - (start + duration)))
+ ) {
+ candidate = startSnap.time;
+ target = startSnap.target;
+ } else if (endMoved) {
+ candidate = endSnap.time - duration;
+ target = endSnap.target;
+ }
+
+ const maxStart = Math.max(0, timelineDuration - duration);
+ // Round the candidate to ms FIRST, then compare the clamp against that rounded
+ // value — not the raw candidate. A frame-quantized duration (e.g. 1/30s, 10/3s)
+ // leaves sub-ms residue after rounding that exceeds a 1e-6 tolerance, so comparing
+ // the clamp to the raw candidate dropped the snap-line indicator on every snap
+ // even though no clamping happened. Comparing against the rounded candidate makes
+ // the residue exactly 0 unless the timeline-bounds clamp actually moved the clip.
+ const roundedCandidate = Math.round(candidate * 1000) / 1000;
+ const clamped = Math.max(0, Math.min(maxStart, roundedCandidate));
+ if (target && Math.abs(clamped - roundedCandidate) > 1e-6) target = null;
+ return { start: clamped, snapTime: target?.time ?? null, snapType: target?.type ?? null };
+}
diff --git a/packages/studio/src/player/components/timelineStackingSync.test.ts b/packages/studio/src/player/components/timelineStackingSync.test.ts
new file mode 100644
index 0000000000..925787e772
--- /dev/null
+++ b/packages/studio/src/player/components/timelineStackingSync.test.ts
@@ -0,0 +1,244 @@
+import { describe, expect, it } from "vitest";
+import { computeStackingPatches, laneIsAbove, type StackingElement } from "./timelineStackingSync";
+
+function el(
+ key: string,
+ track: number,
+ start: number,
+ duration: number,
+ zIndex: number,
+ isAudio = false,
+ domIndex?: number,
+): StackingElement {
+ return { key, track, start, duration, zIndex, isAudio, domIndex };
+}
+
+function patchMap(elements: StackingElement[], edited: string[]): Record {
+ const out: Record = {};
+ for (const p of computeStackingPatches(elements, edited)) out[p.key] = p.zIndex;
+ return out;
+}
+
+describe("laneIsAbove", () => {
+ it("lower track renders above (top of timeline wins)", () => {
+ expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
+ expect(laneIsAbove({ track: 2 }, { track: 1 })).toBe(false);
+ expect(laneIsAbove({ track: 1 }, { track: 1 })).toBe(false);
+ });
+});
+
+describe("computeStackingPatches", () => {
+ it("no overlapping clips → no patch", () => {
+ // a (0..5 on track 0) and b (10..15 on track 1) never overlap in time.
+ const elements = [el("a", 0, 0, 5, 10), el("b", 1, 10, 5, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({});
+ });
+
+ it("edited clip moved to a HIGHER lane (top) but z too low → raised above the below-neighbour", () => {
+ // a on top lane (0) overlaps b on lane 1; a.z=1 is below b.z=5 → wrong.
+ const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
+ // Only a is edited → only a gets a patch, lifting it above b (5) → 6.
+ expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
+ });
+
+ it("edited clip moved to a LOWER lane (bottom) but z too high → lowered below the above-neighbour", () => {
+ // a on lane 2 (bottom) overlaps b on lane 0 (top); a.z=9 above b.z=5 → wrong.
+ const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
+ });
+
+ it("edited clip already correctly ordered → no patch (authored z preserved)", () => {
+ // a on top lane already has higher z than the lower-lane b it overlaps.
+ const elements = [el("a", 0, 0, 10, 8), el("b", 1, 0, 10, 3)];
+ expect(patchMap(elements, ["a"])).toEqual({});
+ });
+
+ it("untouched clips never get a patch even when they overlap the edit", () => {
+ // b is out of order relative to a, but a is the only edited clip.
+ const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5), el("c", 2, 0, 10, 9)];
+ const patches = computeStackingPatches(elements, ["a"]);
+ expect(patches.map((p) => p.key)).toEqual(["a"]);
+ });
+
+ it("sits strictly between neighbours when there is integer room", () => {
+ // edited a on middle lane 1 between below-lane-2 (z=2) and above-lane-0 (z=10).
+ const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 2), el("above", 0, 0, 10, 10)];
+ // Between 2 and 10 → floor((2+10)/2)=6.
+ expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
+ });
+
+ it("adjacent neighbours (no integer gap) → edited lands above lower, upper is bumped", () => {
+ // below z=4, above z=5 (adjacent). There is no integer strictly between 4 and
+ // 5, so the old single-patch a=5 left `a` TIED with `above` — and with no DOM
+ // order to break the tie, `above` no longer paints strictly above `a` (the
+ // under-patch bug). Tie-aware cascade: a→5 (above below's 4) AND above→6 so it
+ // stays strictly on top. Minimal: only the two overlapping neighbours move.
+ const elements = [el("a", 1, 0, 10, 0), el("below", 2, 0, 10, 4), el("above", 0, 0, 10, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({ a: 5, above: 6 });
+ });
+
+ it("audio clips are excluded — an audio edit yields no patch", () => {
+ const elements = [el("music", 3, 0, 10, 0, true), el("v", 0, 0, 10, 5)];
+ expect(patchMap(elements, ["music"])).toEqual({});
+ });
+
+ it("audio clips are excluded as neighbours — a visual edit ignores overlapping audio", () => {
+ // The only overlapping clip is audio → treated as no visual overlap → no patch.
+ const elements = [el("v", 0, 0, 10, 3), el("music", 3, 0, 10, 99, true)];
+ expect(patchMap(elements, ["v"])).toEqual({});
+ });
+
+ it("only-below neighbours → maxBelow + 1", () => {
+ const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 3), el("c", 2, 0, 10, 7)];
+ // a on top overlaps b(3) and c(7), both below → 7+1=8.
+ expect(patchMap(elements, ["a"])).toEqual({ a: 8 });
+ });
+
+ it("only-above neighbours → minAbove - 1 (clamped ≥ 0)", () => {
+ const elements = [el("a", 2, 0, 10, 9), el("b", 0, 0, 10, 1), el("c", 1, 0, 10, 4)];
+ // a on bottom overlaps b(1) and c(4), both above → min(1)-1=0.
+ expect(patchMap(elements, ["a"])).toEqual({ a: 0 });
+ });
+
+ it("partial time overlap still counts", () => {
+ // a: 0..6, b: 5..15 overlap in [5,6).
+ const elements = [el("a", 0, 0, 6, 1), el("b", 1, 5, 10, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({ a: 6 });
+ });
+
+ it("touching-but-not-overlapping intervals do NOT count", () => {
+ // a ends exactly where b starts (t=5) → half-open, no overlap.
+ const elements = [el("a", 0, 0, 5, 1), el("b", 1, 5, 5, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({});
+ });
+
+ it("multi-clip edit: two dragged clips resolve consistently against the region", () => {
+ // Drag a (lane 0) and b (lane 1) onto a region already holding c (lane 2, z=5).
+ // Both overlap c. Lower-lane b resolves first (above c → 6), then a (above b → 7).
+ const elements = [el("a", 0, 0, 10, 0), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
+ expect(patchMap(elements, ["a", "b"])).toEqual({ a: 7, b: 6 });
+ });
+
+ it("multi-clip edit skips a member that is already correctly ordered", () => {
+ const elements = [el("a", 0, 0, 10, 20), el("b", 1, 0, 10, 0), el("c", 2, 0, 10, 5)];
+ // a(20) already above everything → no patch. b (lane 1) sits between
+ // below-neighbour c(5) and above-neighbour a(20) → floor((5+20)/2)=12.
+ expect(patchMap(elements, ["a", "b"])).toEqual({ b: 12 });
+ });
+
+ it("empty edited set → no patches", () => {
+ const elements = [el("a", 0, 0, 10, 1), el("b", 1, 0, 10, 5)];
+ expect(computeStackingPatches(elements, [])).toEqual([]);
+ });
+
+ it("item 13: an unresolved neighbour (non-finite z) is EXCLUDED, not treated as z=0", () => {
+ // `ghost` is an overlapping upper-lane clip whose live node could not be
+ // resolved, so its z came back NaN. If it were fabricated to 0 it would be a
+ // real above-neighbour at the z-floor and drag the edited clip's cascade down.
+ // Excluded, the only real overlap is below-neighbour b(3) → a rises to 4.
+ const elements = [
+ el("a", 1, 0, 10, 0),
+ el("b", 2, 0, 10, 3),
+ el("ghost", 0, 0, 10, Number.NaN),
+ ];
+ expect(patchMap(elements, ["a"])).toEqual({ a: 4 });
+ });
+
+ it("item 13: an edited clip whose own z is unresolved (non-finite) yields no patch", () => {
+ // The edited clip itself couldn't be resolved → it is dropped from the working
+ // set and produces nothing (no fabricated-0 self-patch).
+ const elements = [el("a", 0, 0, 10, Number.NaN), el("b", 1, 0, 10, 5)];
+ expect(patchMap(elements, ["a"])).toEqual({});
+ });
+});
+
+describe("computeStackingPatches — tie-aware cascade (lane move always realisable)", () => {
+ it("drag below an overlapping z=0 neighbour → cascade bumps the neighbour, edit→0", () => {
+ // edited `v` (z=2) dragged to the BOTTOM lane, overlapping `r` (z=0) which is
+ // now on the upper lane. No z ≥ 0 fits strictly below 0, so the old resolver
+ // clamped v to 0 (tied with r) and nothing changed on canvas — the reported
+ // bug. Tie-aware: v→0 AND r bumped to 1 so r paints strictly above v.
+ const elements = [el("v", 1, 0, 10, 2), el("r", 0, 0, 10, 0)];
+ expect(patchMap(elements, ["v"])).toEqual({ v: 0, r: 1 });
+ });
+
+ it("equal-z + domIndex: dragging the LATER-in-DOM clip below is realised via a bump", () => {
+ // Two equal-z clips; `v` is later in DOM (domIndex 1) so it currently paints
+ // ON TOP of `r` (domIndex 0). User drags v to the lower lane (track 1). With
+ // domIndex the sync SEES that v is currently above r and must be lowered:
+ // v→0 (already 0, stays) then r bumped to 1 so r wins. Without domIndex the
+ // equal z would look already-correct and under-patch.
+ const elements = [el("r", 0, 0, 10, 0, false, 0), el("v", 1, 0, 10, 0, false, 1)];
+ expect(patchMap(elements, ["v"])).toEqual({ r: 1 });
+ });
+
+ it("equal-z without domIndex is ambiguous → conservatively bumps to guarantee order", () => {
+ // Same shape but NO domIndex: equal z is ambiguous, so the resolver cannot
+ // prove v is already below r and patches to make the order explicit (r above).
+ const elements = [el("r", 0, 0, 10, 0), el("v", 1, 0, 10, 0)];
+ const out = patchMap(elements, ["v"]);
+ // r must end up strictly above v (higher z) regardless of the exact numbers.
+ const vz = out.v ?? 0;
+ const rz = out.r ?? 0;
+ expect(rz).toBeGreaterThan(vz);
+ });
+
+ it("#2198 (Abhai repro): a lift cascades transitively so an UNTOUCHED pair never inverts", () => {
+ // m (z1, lane0, dom0), n (z0, lane1, dom1), e (z2, lane2, dom2, edited).
+ // e overlaps n [5,10); n overlaps m [12,15); e does NOT overlap m.
+ // Dragging e to the bottom lane forces n up to paint above e. A naive lift sets
+ // n→1, which TIES m (z1) and — n being later in the DOM — paints n above m,
+ // inverting the untouched (m,n) pair (which the next normalize would reshuffle).
+ // The transitive cascade lifts m too (→2) so m stays strictly above n.
+ const elements = [
+ el("m", 0, 12, 8, 1, false, 0),
+ el("n", 1, 5, 10, 0, false, 1),
+ el("e", 2, 0, 10, 2, false, 2),
+ ];
+ expect(patchMap(elements, ["e"])).toEqual({ e: 0, n: 1, m: 2 });
+ });
+
+ it("cascade patches as FEW clips as possible (only the blockers move)", () => {
+ // v dragged to bottom under r(z0) and s(z0) both on higher lanes; a distant
+ // non-overlapping clip x is never touched.
+ const elements = [
+ el("v", 2, 0, 10, 5),
+ el("r", 0, 0, 10, 0),
+ el("s", 1, 0, 10, 0),
+ el("x", 3, 50, 10, 0), // no time overlap → untouched
+ ];
+ const out = patchMap(elements, ["v"]);
+ expect("x" in out).toBe(false);
+ // v below both r and s.
+ expect(out.v).toBeLessThan(out.r ?? 0);
+ expect(out.v).toBeLessThan(out.s ?? 0);
+ });
+});
+
+describe("computeStackingPatches — DOM tie-break gates the cascade (item 12)", () => {
+ it("tie ACCEPTABLE: edited may sit AT minAbove when the above-neighbour is later in DOM → single patch, no neighbour bump", () => {
+ // below b (z3, lane2, dom0), edited e (lane1, dom1), above a (z4, lane0, dom2).
+ // e must paint above b and below a. There is no integer strictly between 3 and
+ // 4, but a is LATER in the DOM, so e=4 ties a and a still paints on top by DOM
+ // order — a valid SINGLE patch. The old gap<2 rule cascaded and needlessly
+ // bumped a's authored z (the over-patch). Only e changes here.
+ const elements = [
+ el("b", 2, 0, 10, 3, false, 0),
+ el("e", 1, 0, 10, 0, false, 1),
+ el("a", 0, 0, 10, 4, false, 2),
+ ];
+ expect(patchMap(elements, ["e"])).toEqual({ e: 4 });
+ });
+
+ it("tie INVERTING: edited tying minAbove would paint ABOVE it (earlier in DOM) → cascade bumps the neighbour", () => {
+ // Same z's, but a is EARLIER in the DOM than e (dom0 vs dom2). Now e=4 would
+ // tie a AND paint on top (e later in DOM), violating the lane order, so the
+ // tie-break can't save it: e→4 and a is bumped to 5.
+ const elements = [
+ el("a", 0, 0, 10, 4, false, 0),
+ el("b", 2, 0, 10, 3, false, 1),
+ el("e", 1, 0, 10, 0, false, 2),
+ ];
+ expect(patchMap(elements, ["e"])).toEqual({ e: 4, a: 5 });
+ });
+});
diff --git a/packages/studio/src/player/components/timelineStackingSync.ts b/packages/studio/src/player/components/timelineStackingSync.ts
new file mode 100644
index 0000000000..4362879bef
--- /dev/null
+++ b/packages/studio/src/player/components/timelineStackingSync.ts
@@ -0,0 +1,331 @@
+/**
+ * timelineStackingSync — lane ↔ stacking unification (pure).
+ *
+ * The approved design: **lane order implies stacking**. A clip on a higher lane
+ * (rendered ABOVE another in the timeline) should render ON TOP of any clip it
+ * OVERLAPS IN TIME. But authored z-indexes are sacred: z only changes on a user
+ * edit, and ONLY for the clip(s) the user actually edited.
+ *
+ * Lane → screen mapping (see Timeline.tsx trackOrder / TimelineCanvas rows):
+ * tracks are sorted ASCENDING and rendered top → bottom, so a LOWER `track`
+ * value renders HIGHER on screen. Standard NLE convention = the top row wins,
+ * therefore **lower track ⇒ higher z-index**. We express this with a single
+ * comparator so callers never have to remember the polarity.
+ *
+ * This module is DOM-free and store-free. Callers project their world onto
+ * `StackingElement` (supplying the live z-index they read from the DOM/inline
+ * style) and apply the returned `StackingPatch[]` however they persist styles.
+ */
+
+/** Minimal element view this module reasons over. */
+export interface StackingElement {
+ /** Stable identity (TimelineElement.key ?? id). */
+ key: string;
+ /** Absolute start time (seconds). */
+ start: number;
+ /** Duration (seconds). */
+ duration: number;
+ /**
+ * Display lane (the normalized timeline `track`). Lower = higher on screen =
+ * should stack on top. This is the post-edit lane for edited clips.
+ */
+ track: number;
+ /**
+ * Current z-index (parsed from inline style / computed; "auto" ⇒ 0), or a
+ * NON-FINITE value (NaN) when the caller could NOT resolve the clip's live node
+ * (e.g. an unmounted / nested sub-comp element, or one outside the active file).
+ * A non-finite-z clip is EXCLUDED from the computation — it is neither a stacking
+ * neighbour nor resolvable as an edit — so an unresolved node never fabricates a
+ * z=0 neighbour that poisons the boundary math (item 13). The reader signals a
+ * miss with NaN rather than null so the value stays assignable to the existing
+ * `(el) => number` reader contract the drag hook / commit deps declare.
+ */
+ zIndex: number;
+ /** Audio clips have no visual stacking and are excluded from the computation. */
+ isAudio: boolean;
+ /**
+ * Discovery / DOM document position (optional). Two clips with EQUAL z paint by
+ * DOM order — the one LATER in the DOM paints ON TOP. When supplied, "is A above
+ * B" uses (zIndex, domIndex); without it equal-z is ambiguous and the sync can
+ * under-patch (the reported bug: a clip dragged to the bottom lane over an
+ * equal-z neighbour changed nothing on canvas). Callers pass the index of the
+ * element in the discovery order array.
+ */
+ domIndex?: number;
+}
+
+/** A minimal z-index change for one clip. */
+export interface StackingPatch {
+ key: string;
+ zIndex: number;
+}
+
+const EPS = 1e-6;
+
+/**
+ * Two clips overlap in time when their half-open [start, end) intervals intersect.
+ *
+ * NOTE the `- EPS`: this DELIBERATELY diverges from `timeRangesOverlap`'s exact
+ * strict-`<` (timelineCollision.ts). A boolean collision decision is idempotent, so
+ * exact `<` is fine there; here the result drives a VISIBLE stacking re-lane, so the
+ * epsilon guards against float fuzz (e.g. 5.0000001 vs 5) spuriously overlapping two
+ * abutting clips and shuffling lanes. The two are intended to differ, not align.
+ */
+function overlapsInTime(a: StackingElement, b: StackingElement): boolean {
+ return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
+}
+
+/**
+ * Is `a` visually ABOVE `b` (should stack on top)? Lower track renders higher on
+ * screen, so a lower track number means "above". Exposed for tests / callers.
+ */
+export function laneIsAbove(
+ a: Pick,
+ b: Pick,
+): boolean {
+ return a.track < b.track;
+}
+
+/**
+ * Working record for the cascade resolver: a live-mutable, RESOLVED (non-null) z
+ * the resolver can bump, plus the immutable identity/lane/time/dom fields. Clips
+ * whose z could not be resolved (null) are dropped before this stage.
+ */
+interface MutZ extends StackingElement {
+ zIndex: number;
+}
+
+/**
+ * Does `a` currently paint ON TOP of `b`? Higher z wins; equal z breaks by DOM
+ * order (later in DOM paints on top). When either domIndex is absent, equal z is
+ * treated as "not strictly above" (ambiguous) — callers should supply domIndex to
+ * disambiguate (see StackingElement.domIndex). Operates on resolved (`MutZ`) clips.
+ */
+function paintsAbove(a: MutZ, b: MutZ): boolean {
+ if (a.zIndex !== b.zIndex) return a.zIndex > b.zIndex;
+ if (a.domIndex != null && b.domIndex != null) return a.domIndex > b.domIndex;
+ return false;
+}
+
+/** Reduce a neighbour set's z-indices to a single bound, or null when empty. */
+function boundaryZ(neighbours: MutZ[], reduce: (zs: number[]) => number): number | null {
+ return neighbours.length > 0 ? reduce(neighbours.map((o) => o.zIndex)) : null;
+}
+
+/**
+ * Resolve `edited` so that, among the clips it OVERLAPS IN TIME, its paint order
+ * matches its lane order (lower lane ⇒ paints on top). Records every z change
+ * (edited clip AND any neighbours that must be bumped) into `patchZ`.
+ *
+ * Fast path (unchanged behaviour): when a single non-negative z for the edited
+ * clip alone realises the order — strictly between the neighbours if there is
+ * integer room, else just above the lower neighbour, else just below the upper —
+ * emit only that. This keeps every existing single-patch test passing.
+ *
+ * Cascade path: when ties/clamping make the single-clip patch impossible or
+ * ineffective (must sit below an overlapping z=0 neighbour, or between adjacent /
+ * equal-z neighbours where DOM order alone can't express it), bump the minimum set
+ * of overlapping neighbours that must stay ABOVE by +1 (cascading only as far as
+ * needed) so the edited clip's intended lane order is realised with all z ≥ 0.
+ * "Authored z sacred" stays the default — neighbours are touched only when the
+ * user's explicit lane move is otherwise inexpressible (same precedent as the
+ * canvas context-menu tie-aware fix).
+ *
+ * Returns true when any z changed (recorded in `patchZ`), false for a no-op.
+ */
+function resolveEditedZ(
+ edited: MutZ,
+ overlapping: MutZ[],
+ overlappersOf: (clip: MutZ) => MutZ[],
+ patchZ: (clip: MutZ, z: number) => void,
+): boolean {
+ const visualOverlap = overlapping.filter((o) => !o.isAudio);
+ if (visualOverlap.length === 0) return false;
+
+ // Neighbours that must end up BELOW edited (lower lane) vs ABOVE (higher lane).
+ const below = visualOverlap.filter((o) => laneIsAbove(edited, o));
+ const above = visualOverlap.filter((o) => laneIsAbove(o, edited));
+
+ // Already correct against every overlapping neighbour → no-op (authored z kept).
+ const correct =
+ below.every((o) => paintsAbove(edited, o)) && above.every((o) => paintsAbove(o, edited));
+ if (correct) return false;
+
+ const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
+
+ // ── Fast path: try to realise the order by moving only `edited`. ──────────────
+ const single = trySingleZ(edited, below, above);
+ if (single != null) {
+ if (single !== edited.zIndex) patchZ(edited, single);
+ // Even at an unchanged z the DOM-order ties may already be satisfied; if not,
+ // `trySingleZ` returned null and we fall through to the cascade.
+ return single !== edited.zIndex;
+ }
+
+ // ── Cascade path: can't fit `edited` between the neighbours with one z ≥ 0. ───
+ // Sit edited at maxBelow+1 (or 0 when it only has above-neighbours) and lift the
+ // above-neighbours that are now not strictly above, minimally, one step past it.
+ const target = maxBelow != null ? maxBelow + 1 : 0;
+ const clamped = Math.max(0, target);
+ if (clamped !== edited.zIndex) patchZ(edited, clamped);
+ liftAbove(edited, overlappersOf, patchZ);
+ return true;
+}
+
+/**
+ * Pick a single non-negative z for `edited` that lands it correctly against its
+ * neighbours (paints above every below-neighbour, below every above-neighbour), or
+ * null when no such z exists and the caller must cascade.
+ *
+ * The candidate is verified with the SAME `paintsAbove` predicate the resolver uses
+ * (z + DOM tie-break), so an authored z that already paints correctly by DOM order
+ * is honoured instead of over-patched: with below=z3 and an above-neighbour at z4
+ * that is LATER in the DOM, edited=4 ties the neighbour but the neighbour still
+ * paints on top by DOM order — a valid single patch, no neighbour bump (item 12).
+ * When the tie would INVERT (the above-neighbour is earlier in DOM) the candidate
+ * fails verification and the caller cascades.
+ */
+// edited has neighbours on BOTH sides: prefer the integer midpoint of a real gap;
+// with no strict gap a DOM tie-break may still let it sit AT minAbove (item 12),
+// else the caller cascades.
+function zBetweenNeighbours(
+ maxBelow: number,
+ minAbove: number,
+ correctAt: (z: number) => boolean,
+): number | null {
+ if (minAbove - maxBelow >= 2) {
+ const mid = Math.floor((maxBelow + minAbove) / 2);
+ return mid > maxBelow && mid < minAbove ? mid : null;
+ }
+ return correctAt(minAbove) ? minAbove : null;
+}
+
+// edited has only above-neighbours: sit one step below minAbove, or at the z=0
+// floor tie minAbove when a DOM tie-break keeps that neighbour on top.
+function zBelowOnly(minAbove: number, correctAt: (z: number) => boolean): number | null {
+ const candidate = minAbove - 1;
+ if (candidate >= 0) return candidate;
+ return correctAt(minAbove) ? minAbove : null;
+}
+
+function trySingleZ(edited: MutZ, below: MutZ[], above: MutZ[]): number | null {
+ const maxBelow = boundaryZ(below, (zs) => Math.max(...zs));
+ const minAbove = boundaryZ(above, (zs) => Math.min(...zs));
+
+ const correctAt = (z: number): boolean => {
+ const probe: MutZ = { ...edited, zIndex: z };
+ return below.every((b) => paintsAbove(probe, b)) && above.every((a) => paintsAbove(a, probe));
+ };
+
+ if (maxBelow != null && minAbove != null)
+ return zBetweenNeighbours(maxBelow, minAbove, correctAt);
+ if (maxBelow != null) return maxBelow + 1; // only below-neighbours → grow upward
+ if (minAbove != null) return zBelowOnly(minAbove, correctAt);
+ return null;
+}
+
+/**
+ * Enforce the module invariant — for every OVERLAPPING pair, the clip on the upper
+ * lane paints on top — starting from `edited` and cascading TRANSITIVELY.
+ *
+ * Seeded with `edited`: each of its upper-lane overlappers must paint strictly
+ * above it (the deliberate lane move). Raising a clip can then tie or cross ANOTHER
+ * clip it overlaps that sits on an even higher lane — that clip must be lifted too,
+ * and so on. Without the cascade a lifted neighbour could tie an untouched clip on
+ * a higher lane and, being later in the DOM, paint above it — an untouched pair
+ * visibly inverting (#2198). The condition is LANE order (not "was originally
+ * above"), so a clip that was already violating lane order — e.g. a bottom-lane
+ * clip painting on top — is fixed, never preserved. Only clips whose z actually
+ * changes are patched; z climbs by +1 each step so the walk terminates.
+ */
+function liftAbove(
+ edited: MutZ,
+ overlappersOf: (clip: MutZ) => MutZ[],
+ patchZ: (clip: MutZ, z: number) => void,
+): void {
+ const queue: MutZ[] = [edited];
+ const raiseAbove = (clip: MutZ, floor: MutZ): void => {
+ if (paintsAbove(clip, floor)) return; // already strictly on top
+ patchZ(clip, floor.zIndex + 1); // patchZ mutates clip.zIndex in place
+ queue.push(clip);
+ };
+ while (queue.length > 0) {
+ const floor = queue.shift()!;
+ for (const other of overlappersOf(floor)) {
+ if (laneIsAbove(other, floor) && !paintsAbove(other, floor)) raiseAbove(other, floor);
+ }
+ }
+}
+
+/**
+ * Compute z-index patches so each edited clip's stacking matches its lane order.
+ *
+ * @param elements The FULL post-edit element set (edited clips already carry
+ * their new lane/time). Untouched clips keep their current z.
+ * @param editedKeys Keys of the clip(s) the user just edited.
+ * @returns Minimal z patches. When a single-clip patch realises the order it is
+ * the only patch (authored z of neighbours untouched); when ties or a
+ * z=0 floor make that impossible, the minimum set of overlapping
+ * neighbours is bumped too so the lane move is always realisable with
+ * all z ≥ 0. Non-overlapping / already-correct edits yield nothing.
+ *
+ * Multi-clip edits: each edited clip is resolved against the CURRENT (already-
+ * patched) z of all OTHER clips, lower lane first, so a group dragged onto a busy
+ * region stacks consistently.
+ */
+export function computeStackingPatches(
+ elements: StackingElement[],
+ editedKeys: Iterable,
+): StackingPatch[] {
+ const editedSet = new Set(editedKeys);
+ if (editedSet.size === 0) return [];
+
+ // Drop clips whose live z couldn't be resolved (non-finite / NaN): a fabricated
+ // z=0 would enter the boundary math as a phantom neighbour at the z-floor. An
+ // unresolved clip is neither a neighbour nor resolvable as an edit, so it is
+ // excluded outright (item 13).
+ const resolved = elements.filter((e) => Number.isFinite(e.zIndex));
+
+ // Mutable z snapshot so edits + cascaded bumps see each other's applied z.
+ const byKey = new Map(resolved.map((e) => [e.key, { ...e }]));
+ const edited = resolved
+ .filter((e) => editedSet.has(e.key) && !e.isAudio)
+ .map((e) => byKey.get(e.key)!)
+ // Resolve lower-lane (renders below) clips first so their new z is visible
+ // to higher-lane siblings resolved after them.
+ .sort((a, b) => b.track - a.track);
+
+ const changed = new Map();
+ const patchZ = (clip: MutZ, z: number): void => {
+ clip.zIndex = z;
+ changed.set(clip.key, z);
+ };
+
+ // The full live set, so the transitive cascade can reach clips that overlap a
+ // LIFTED neighbour without overlapping the edited clip itself (#2198).
+ const all = [...byKey.values()];
+ const overlappersOf = (clip: MutZ): MutZ[] =>
+ all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
+
+ for (const clip of edited) {
+ resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
+ }
+
+ // Emit in a stable order (edited clips first in their resolve order, then any
+ // cascaded neighbours) — deterministic for tests and undo grouping.
+ const emitted = new Set();
+ const patches: StackingPatch[] = [];
+ for (const clip of edited) {
+ if (changed.has(clip.key) && !emitted.has(clip.key)) {
+ patches.push({ key: clip.key, zIndex: changed.get(clip.key)! });
+ emitted.add(clip.key);
+ }
+ }
+ for (const [key, zIndex] of changed) {
+ if (!emitted.has(key)) {
+ patches.push({ key, zIndex });
+ emitted.add(key);
+ }
+ }
+ return patches;
+}
diff --git a/packages/studio/src/player/components/timelineZones.test.ts b/packages/studio/src/player/components/timelineZones.test.ts
new file mode 100644
index 0000000000..4e446b0568
--- /dev/null
+++ b/packages/studio/src/player/components/timelineZones.test.ts
@@ -0,0 +1,425 @@
+import { describe, expect, it } from "vitest";
+import type { TimelineElement } from "../store/playerStore";
+import { classifyZone, normalizeToZones } from "./timelineZones";
+import { computeStackingPatches, type StackingElement } from "./timelineStackingSync";
+
+function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
+ return { id, tag, start: 0, duration, track };
+}
+
+function zClip(
+ id: string,
+ start: number,
+ duration: number,
+ track: number,
+ zIndex: number,
+ tag = "video",
+): TimelineElement {
+ return { id, tag, start, duration, track, zIndex };
+}
+
+function trackOf(els: TimelineElement[], id: string): number {
+ return els.find((e) => e.id === id)!.track;
+}
+
+/** Assert normalizeToZones is idempotent: re-zoning keeps every clip's lane. */
+function expectZoningIdempotent(input: TimelineElement[]): void {
+ const once = normalizeToZones(input);
+ const twice = normalizeToZones(once);
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
+}
+
+/** The exact qa-clean live repro (array order = DOM order); fresh objects per call. */
+function qaCleanRepro(): TimelineElement[] {
+ return [
+ zClip("blue-logo", 6.37, 3, 0, 3, "img"),
+ zClip("ralu", 6.37, 3, 0, 0, "img"),
+ zClip("black-logo", 11.92, 3, 1, 1, "img"),
+ zClip("video", 0.84, 20, 3, 2, "video"),
+ ];
+}
+
+describe("classifyZone", () => {
+ it("audio → audio; video / image / everything else → visual", () => {
+ expect(classifyZone(el("m", "audio", 3))).toBe("audio");
+ expect(classifyZone(el("v", "video", 1))).toBe("visual");
+ expect(classifyZone(el("i", "img", 0))).toBe("visual");
+ });
+
+ it("zone identity invariant: normalizeToZones preserves each clip's zone (mixed input)", () => {
+ // normalizeToZones only remaps lanes — it must never reclassify a clip's zone.
+ const input = [
+ el("v", "video", 0),
+ el("a1", "audio", 1),
+ el("i", "img", 2),
+ el("a2", "audio", 3),
+ ];
+ const out = normalizeToZones(input);
+ for (const e of input) {
+ expect(classifyZone(out.find((o) => o.id === e.id)!)).toBe(classifyZone(e));
+ }
+ // And the partition holds: every visual lane sits above every audio lane.
+ const laneOf = (id: string) => trackOf(out, id);
+ const maxVisual = Math.max(laneOf("v"), laneOf("i"));
+ const minAudio = Math.min(laneOf("a1"), laneOf("a2"));
+ expect(maxVisual).toBeLessThan(minAudio);
+ });
+});
+
+describe("normalizeToZones", () => {
+ it("orders visual (top) → audio (bottom); equal-z overlap stacks by DOM order", () => {
+ // img and vid are both z=0, start=0 → they OVERLAP in time. CSS paints
+ // equal-z siblings by DOM order (later paints on top), so the later-in-array
+ // `vid` must own the upper lane. (Was: pinned img=0/vid=1 to authored order,
+ // which contradicts the canvas — updated for per-clip DOM-order tie-break.)
+ const out = normalizeToZones([
+ el("img", "img", 0),
+ el("vid", "video", 2),
+ el("mus", "audio", 5),
+ ]);
+ expect(trackOf(out, "vid")).toBe(0); // later in DOM → paints on top → upper lane
+ expect(trackOf(out, "img")).toBe(1); // earlier in DOM → below
+ expect(trackOf(out, "mus")).toBe(2); // audio (bottom)
+ });
+
+ it("keeps all visual lanes together on top; equal-z overlap stacks by DOM order", () => {
+ // All three z=0, start=0 → mutually overlapping. DOM order (later on top)
+ // decides the stack: v3 (last) top, then i1, then v0. (Was: pinned to authored
+ // order v0=0/i1=1/v3=2, which the canvas contradicts for overlapping equal-z.)
+ const out = normalizeToZones([el("v0", "video", 0), el("i1", "img", 1), el("v3", "video", 3)]);
+ expect(trackOf(out, "v3")).toBe(0);
+ expect(trackOf(out, "i1")).toBe(1);
+ expect(trackOf(out, "v0")).toBe(2);
+ });
+
+ it("drops audio below the visual lanes even when sharing a track index", () => {
+ const out = normalizeToZones([el("v", "video", 0), el("a", "audio", 0)]);
+ expect(trackOf(out, "v")).toBe(0); // visual
+ expect(trackOf(out, "a")).toBe(1); // audio, below
+ });
+
+ it("groups multiple audio tracks at the bottom preserving relative order", () => {
+ const out = normalizeToZones([el("v", "video", 0), el("a1", "audio", 1), el("a2", "audio", 4)]);
+ expect(trackOf(out, "v")).toBe(0);
+ expect(trackOf(out, "a1")).toBe(1);
+ expect(trackOf(out, "a2")).toBe(2);
+ });
+
+ it("returns the same array (identity) when already zoned", () => {
+ // A fixed-point layout: the two overlapping equal-z visual clips are already
+ // in DOM-order-consistent lanes (later-in-array `v` on the upper lane 0), so
+ // re-zoning is a no-op and the SAME array reference comes back. (Was: i=0/v=1,
+ // which the new per-clip DOM-order pack would flip — so it wasn't a fixed
+ // point under the corrected canvas semantics; swapped to the stable order.)
+ const input = [el("v", "video", 1), el("i", "img", 0), el("a", "audio", 2)];
+ expect(normalizeToZones(input)).toBe(input);
+ });
+
+ it("is idempotent (no drift on re-zoning)", () => {
+ const input = [
+ el("img", "img", 1),
+ el("v", "video", 3),
+ el("a1", "audio", 2),
+ el("a2", "audio", 6),
+ ];
+ expectZoningIdempotent(input);
+ });
+
+ it("splits time-overlapping clips on one track onto separate lanes (no visible overlap)", () => {
+ const clip = (id: string, start: number, duration: number): TimelineElement => ({
+ id,
+ tag: "video",
+ start,
+ duration,
+ track: 1, // all authored on the SAME track, some overlapping in time
+ });
+ // a [0,5), b [2,7) overlaps a, c [6,9) fits after a. All equal-z (absent).
+ // Per-clip pack (DOM-order tie-break): c is last in DOM so it places on the
+ // top lane 0; b overlaps c and lands on lane 1; a overlaps b (and is earlier
+ // in DOM than b, so it must paint BELOW b) → lane 2. a can NOT drop onto c's
+ // lane 0 even though it doesn't overlap c, because that would place a ABOVE b
+ // in lane order while a paints below b — the canvas-correct constraint the old
+ // whole-track packer ignored (it shared a/c on lane 0, contradicting paint).
+ const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
+ expect(trackOf(out, "c")).toBe(0); // last in DOM → top lane
+ expect(trackOf(out, "b")).toBe(1); // overlaps c → below it
+ expect(trackOf(out, "a")).toBe(2); // paints below b (earlier DOM, equal z) → lane below b
+
+ // No two time-overlapping clips share a lane (the real NLE invariant).
+ expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
+ expect(trackOf(out, "b")).not.toBe(trackOf(out, "c"));
+
+ // Idempotent: re-laying the split result changes nothing.
+ const twice = normalizeToZones(out);
+ for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
+ });
+});
+
+describe("normalizeToZones — reverse z→lane mapping", () => {
+ it("orders overlapping same-zone clips by z: higher z → higher (upper) lane", () => {
+ // lo (z=1) and hi (z=9) fully overlap in time on the same authored track.
+ const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 0, 9)]);
+ expect(trackOf(out, "hi")).toBe(0); // higher z → upper lane (top)
+ expect(trackOf(out, "lo")).toBe(1); // lower z → below
+ });
+
+ it("orders three overlapping clips strictly by descending z", () => {
+ const out = normalizeToZones([
+ zClip("mid", 0, 10, 0, 5),
+ zClip("top", 0, 10, 0, 8),
+ zClip("bot", 0, 10, 0, 2),
+ ]);
+ expect(trackOf(out, "top")).toBe(0);
+ expect(trackOf(out, "mid")).toBe(1);
+ expect(trackOf(out, "bot")).toBe(2);
+ });
+
+ it("does NOT reorder non-overlapping (sequential) clips by z — they share a lane", () => {
+ // a [0,5) z=1 then c [6,9) z=9 — no time overlap, so z is irrelevant.
+ const out = normalizeToZones([zClip("a", 0, 5, 0, 1), zClip("c", 6, 3, 0, 9)]);
+ expect(trackOf(out, "a")).toBe(0);
+ expect(trackOf(out, "c")).toBe(0); // shares the lane regardless of higher z
+ });
+
+ it("leaves the audio zone unaffected by z", () => {
+ const out = normalizeToZones([
+ zClip("v", 0, 10, 0, 1),
+ zClip("m1", 0, 10, 1, 99, "audio"),
+ zClip("m2", 0, 10, 1, 0, "audio"),
+ ]);
+ // Two overlapping audio clips split onto lanes below the visual clip; their
+ // relative z does not lift one above a visual clip.
+ expect(trackOf(out, "v")).toBe(0);
+ expect(trackOf(out, "m1")).toBeGreaterThan(trackOf(out, "v"));
+ expect(trackOf(out, "m2")).toBeGreaterThan(trackOf(out, "v"));
+ });
+
+ it("treats missing / auto z as 0 (undefined z clip sinks below a positive-z overlap)", () => {
+ const out = normalizeToZones([
+ { id: "noz", tag: "video", start: 0, duration: 10, track: 0 }, // no zIndex
+ zClip("pos", 0, 10, 0, 3),
+ ]);
+ expect(trackOf(out, "pos")).toBe(0); // z=3 → upper
+ expect(trackOf(out, "noz")).toBe(1); // absent z ⇒ 0 → below
+ });
+
+ it("tie-breaks equal-z overlapping clips on the STABLE id, not the mutated lane", () => {
+ // Equal z + full overlap: order must be deterministic (id asc) and survive
+ // re-normalization — the historical oscillation bug tie-broke on the track.
+ const out = normalizeToZones([zClip("b", 0, 10, 0, 5), zClip("a", 0, 10, 0, 5)]);
+ expect(trackOf(out, "a")).toBe(0); // "a" < "b"
+ expect(trackOf(out, "b")).toBe(1);
+ const twice = normalizeToZones(out);
+ for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
+ });
+
+ it("FIXED POINT: normalizeToZones(normalizeToZones(x)) === normalizeToZones(x) with z present", () => {
+ const input = [
+ zClip("hi", 0, 10, 0, 9),
+ zClip("lo", 0, 10, 0, 1),
+ zClip("mid", 2, 6, 0, 5),
+ zClip("seq", 12, 4, 0, 7),
+ zClip("music", 0, 16, 1, 3, "audio"),
+ ];
+ expectZoningIdempotent(input);
+ });
+
+ it("reload simulation: re-deriving lanes from the SAME z values yields identical lanes", () => {
+ // Simulate two independent discovery passes producing fresh element objects
+ // carrying the same z — lane assignment must be stable across reloads.
+ const build = (): TimelineElement[] => [
+ zClip("hi", 0, 10, 0, 9),
+ zClip("lo", 0, 10, 0, 1),
+ zClip("mid", 3, 5, 0, 5),
+ ];
+ const first = normalizeToZones(build());
+ const second = normalizeToZones(build());
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
+ });
+});
+
+describe("normalizeToZones — cross-track z→lane (real qa-clean shape)", () => {
+ // Derived from /tmp/hf-dnd-qa/qa-clean: a full-length video on authored track 0
+ // (z=0), two logo SVGs on track 1 (z=26 and z=0), an icon on track 3 (z=5), and
+ // background music on track 2. In the canvas the z=26 / z=5 icons paint ON TOP of
+ // the z=0 video; the timeline must agree — the higher-z tracks sit on upper lanes.
+ const realProject = (): TimelineElement[] => [
+ zClip("ralu", 6.14, 3, 3, 5, "img"),
+ zClip("video", 1, 20, 0, 0, "video"),
+ zClip("blueLogo", 5.93, 3, 1, 26, "img"),
+ zClip("blackLogo", 1, 3, 1, 0, "img"),
+ zClip("music", 8.93, 8, 2, 0, "audio"),
+ ];
+
+ it("stacks a higher-z track ABOVE a lower-z track on a different authored track", () => {
+ const out = normalizeToZones(realProject());
+ // Track 1 (max z 26) tops the visual zone, then track 3 (z 5), then track 0 (z 0).
+ expect(trackOf(out, "blueLogo")).toBe(0);
+ expect(trackOf(out, "blackLogo")).toBe(0); // sequential to blueLogo → shares lane
+ expect(trackOf(out, "ralu")).toBe(1);
+ expect(trackOf(out, "video")).toBe(2);
+ // Audio stays at the very bottom regardless of its authored track index.
+ expect(trackOf(out, "music")).toBe(3);
+ });
+
+ it("the video (z=0) no longer sits above the z=26 / z=5 icons — canvas & timeline agree", () => {
+ const out = normalizeToZones(realProject());
+ expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "blueLogo"));
+ expect(trackOf(out, "video")).toBeGreaterThan(trackOf(out, "ralu"));
+ });
+
+ it("is idempotent on the real-project shape (no lane drift on re-discovery)", () => {
+ const once = normalizeToZones(realProject());
+ const twice = normalizeToZones(once);
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
+ });
+
+ it("re-derives identical lanes from fresh objects carrying the same z (reload-stable)", () => {
+ const first = normalizeToZones(realProject());
+ const second = normalizeToZones(realProject());
+ for (const e of first) expect(trackOf(second, e.id)).toBe(e.track);
+ });
+
+ it("all-equal-z overlapping clips stack by DOM order (later on top), lanes contiguous", () => {
+ // Was: "keeps ascending authored track order when all tracks share z" — that
+ // pinned t0=0/t1=1/t3=2 to the authored track index. But these three all
+ // start at 0 and OVERLAP, all z=0, so CSS paints them by DOM order: t3 (last)
+ // on top. The per-clip pack now reflects that (t3 lane 0, then t1, then t0),
+ // which is what the canvas actually shows. Lanes stay contiguous 0..2.
+ const out = normalizeToZones([
+ zClip("t0", 0, 2, 0, 0),
+ zClip("t1", 0, 2, 1, 0),
+ zClip("t3", 0, 2, 3, 0),
+ ]);
+ expect(trackOf(out, "t3")).toBe(0); // last in DOM → paints on top
+ expect(trackOf(out, "t1")).toBe(1);
+ expect(trackOf(out, "t0")).toBe(2);
+ });
+});
+
+describe("normalizeToZones — EXACT qa-clean repro (per-clip constrained pack)", () => {
+ // The live repro from /tmp/hf-dnd-qa/qa-clean/index.html:
+ // blue-logo authored track 0, z=3, 6.37–9.37
+ // ralu image authored track 0, z=0, 6.37–9.37 (shares track 0 with blue-logo)
+ // black-logo authored track 1, z=1, 11.92–14.92
+ // video authored track 3, z=2, 0.84–20.84
+ // Canvas truth: video (z=2) covers ralu (z=0). The OLD whole-track packer
+ // ordered track 0 by its MAX z (3, from blue-logo), so ralu rode above the
+ // z=2 video — the timeline↔canvas contradiction. Array order below = DOM order.
+
+ it("ACCEPTANCE: lane order top→bottom is blue-logo, video, black-logo, ralu", () => {
+ const out = normalizeToZones(qaCleanRepro());
+ expect(trackOf(out, "blue-logo")).toBe(0); // z=3 → top
+ expect(trackOf(out, "video")).toBe(1); // z=2, overlaps blue-logo → below it
+ expect(trackOf(out, "black-logo")).toBe(2); // z=1, overlaps video (11.92–14.92 ∩ 0.84–20.84)
+ expect(trackOf(out, "ralu")).toBe(3); // z=0, overlaps blue-logo AND video → bottom
+ });
+
+ it("REGRESSION: a low-z clip must not ride its authored trackmate's high z above a clip that covers it", () => {
+ const out = normalizeToZones(qaCleanRepro());
+ // ralu (z=0) shares authored track 0 with blue-logo (z=3) but must sink BELOW
+ // the video (z=2) that overlaps and paints over it — the whole-track bug.
+ expect(trackOf(out, "ralu")).toBeGreaterThan(trackOf(out, "video"));
+ // black-logo (z=1) below video (z=2) because they overlap in time.
+ expect(trackOf(out, "black-logo")).toBeGreaterThan(trackOf(out, "video"));
+ });
+
+ it("FIXED POINT: running the NEW pack on its own output changes nothing", () => {
+ const once = normalizeToZones(qaCleanRepro());
+ const twice = normalizeToZones(once);
+ for (const e of once) expect(trackOf(twice, e.id)).toBe(e.track);
+ // And a third pass, to be sure convergence is genuine.
+ const thrice = normalizeToZones(twice);
+ for (const e of twice) expect(trackOf(thrice, e.id)).toBe(e.track);
+ });
+});
+
+describe("z ↔ lane round-trip convergence (both directions agree)", () => {
+ // Project a normalized TimelineElement onto the StackingElement view the
+ // forward (lane→z) mapping reasons over.
+ const toStacking = (els: TimelineElement[]): StackingElement[] =>
+ els.map((e, domIndex) => ({
+ key: e.key ?? e.id,
+ start: e.start,
+ duration: e.duration,
+ track: e.track,
+ zIndex: Number.isFinite(e.zIndex) ? (e.zIndex as number) : 0,
+ isAudio: classifyZone(e) === "audio",
+ domIndex,
+ }));
+
+ it("lane-move → z patch → re-discovery orders lanes by that same z → identical lanes (no oscillation)", () => {
+ // Two fully-overlapping visual clips. Authored: a below (z=1), b above (z=5).
+ const authored: TimelineElement[] = [zClip("a", 0, 10, 0, 1), zClip("b", 0, 10, 0, 5)];
+ const normalized = normalizeToZones(authored);
+ // z→lane placed b (z=5) on the upper lane 0, a on lane 1.
+ expect(trackOf(normalized, "b")).toBe(0);
+ expect(trackOf(normalized, "a")).toBe(1);
+
+ // USER lane-move: drag a to the TOP (lane 0) and push b down (lane 1).
+ const afterMove = normalized.map((e) =>
+ e.id === "a" ? { ...e, track: 0 } : e.id === "b" ? { ...e, track: 1 } : e,
+ );
+
+ // FORWARD: a lane-move writes the minimal z patch for the edited clip.
+ const patches = computeStackingPatches(toStacking(afterMove), ["a"]);
+ expect(patches).toEqual([{ key: "a", zIndex: 6 }]); // lifted above b (5)
+
+ // Apply the z patch back onto the elements (what handleDomZIndexReorderCommit
+ // persists; next discovery re-reads it as TimelineElement.zIndex).
+ const rediscovered = afterMove.map((e) => {
+ const p = patches.find((pp) => pp.key === (e.key ?? e.id));
+ return p ? { ...e, zIndex: p.zIndex } : e;
+ });
+
+ // REVERSE: re-normalize from the new z. a (z=6) must now own the upper lane —
+ // the same lane the user moved it to. Directions converge, they do not fight.
+ const renormalized = normalizeToZones(rediscovered);
+ expect(trackOf(renormalized, "a")).toBe(0);
+ expect(trackOf(renormalized, "b")).toBe(1);
+
+ // FIXED POINT: forward on the converged state produces NO further patch, and
+ // reverse is idempotent — the round-trip is stable.
+ expect(computeStackingPatches(toStacking(renormalized), ["a"])).toEqual([]);
+ const twice = normalizeToZones(renormalized);
+ for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
+ });
+
+ it("qa-clean: drag video BELOW ralu → z patch → re-pack keeps it below, no oscillation", () => {
+ // EXACT repro fixture (array order = DOM order).
+ const normalized = normalizeToZones(qaCleanRepro());
+ // Baseline lanes: blue-logo 0, video 1, black-logo 2, ralu 3.
+ expect(trackOf(normalized, "video")).toBe(1);
+ expect(trackOf(normalized, "ralu")).toBe(3);
+
+ // USER lane-move: drag video to the lane BELOW ralu (bottom). ralu is at
+ // lane 3, so video goes to a lane strictly greater — model it as lane 4.
+ const afterMove = normalized.map((e) => (e.id === "video" ? { ...e, track: 4 } : e));
+
+ // FORWARD: video (z=2) must drop below ralu (z=0). No integer z ≥ 0 fits
+ // strictly below 0, so the tie-aware sync cascades: video→0 and the clips that
+ // must stay above it (ralu z=0, black-logo z=1, blue-logo z=3) are bumped as
+ // needed so video paints below ralu with all z ≥ 0.
+ const patches = computeStackingPatches(toStacking(afterMove), ["video"]);
+ const patchByKey = new Map(patches.map((p) => [p.key, p.zIndex]));
+ // Video was moved; it must now be strictly below ralu in paint order.
+ const zAfter = (id: string): number =>
+ patchByKey.get(id) ?? (afterMove.find((e) => e.id === id)!.zIndex as number);
+ expect(zAfter("video")).toBeLessThan(zAfter("ralu"));
+ expect(zAfter("video")).toBeGreaterThanOrEqual(0);
+ expect(patchByKey.size).toBeGreaterThan(0);
+
+ // Apply patches and re-pack: video's lane must now be BELOW ralu's.
+ const rediscovered = afterMove.map((e) => {
+ const z = patchByKey.get(e.id);
+ return z != null ? { ...e, zIndex: z } : e;
+ });
+ const renormalized = normalizeToZones(rediscovered);
+ expect(trackOf(renormalized, "video")).toBeGreaterThan(trackOf(renormalized, "ralu"));
+
+ // FIXED POINT: re-running BOTH directions on the converged state is a no-op.
+ expect(computeStackingPatches(toStacking(renormalized), ["video"])).toEqual([]);
+ const twice = normalizeToZones(renormalized);
+ for (const e of renormalized) expect(trackOf(twice, e.id)).toBe(e.track);
+ });
+});
diff --git a/packages/studio/src/player/components/timelineZones.ts b/packages/studio/src/player/components/timelineZones.ts
new file mode 100644
index 0000000000..aed2a0a921
--- /dev/null
+++ b/packages/studio/src/player/components/timelineZones.ts
@@ -0,0 +1,198 @@
+import type { TimelineElement } from "../store/playerStore";
+import { isAudioTimelineElement } from "../../utils/timelineInspector";
+
+/**
+ * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
+ * — layering is CSS z-index (the renderer ignores track index), so the timeline's
+ * only job is to keep visual clips grouped above audio clips.
+ */
+export type TrackZone = "visual" | "audio";
+
+/** Which zone a clip belongs to: audio elements sink to the bottom, everything
+ * else (video / image / text / sub-comp) is a visual lane on top. */
+export function classifyZone(el: TimelineElement): TrackZone {
+ return isAudioTimelineElement(el) ? "audio" : "visual";
+}
+
+const keyOf = (el: TimelineElement) => el.key ?? el.id;
+
+/** Stacking order for a clip: missing / "auto" z is treated as 0. */
+const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
+
+const EPS = 1e-6;
+
+/** Two clips overlap when their half-open [start, end) intervals intersect. */
+function overlaps(a: TimelineElement, b: TimelineElement): boolean {
+ return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
+}
+
+/** A clip paired with its position in the discovery/document (input) order. */
+interface IndexedClip {
+ el: TimelineElement;
+ /** Index in the input `elements` array = discovery/DOM order. */
+ domIndex: number;
+}
+
+/** One display lane: the clips packed onto it, in placement order. */
+interface Lane {
+ occupants: IndexedClip[];
+ /** The single authored track all occupants share, or null once mixed (never
+ * happens — we only ever add same-track clips to an existing lane). */
+ track: number;
+}
+
+/**
+ * Lowest lane index a clip may occupy: strictly above every already-placed lane
+ * holding a clip it overlaps in time (all of which out-stack it by the z-desc
+ * placement order).
+ */
+function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
+ let minLane = 0;
+ for (let i = 0; i < lanes.length; i++) {
+ if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
+ }
+ return minLane;
+}
+
+/**
+ * First lane at index ≥ minLane that holds solely this clip's authored track and
+ * nothing overlapping (so sequential same-track clips share a lane); -1 when none
+ * qualifies and a fresh lane must open.
+ */
+function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
+ for (let i = minLane; i < lanes.length; i++) {
+ const lane = lanes[i];
+ if (lane.track !== item.el.track) continue;
+ if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
+ return i;
+ }
+ return -1;
+}
+
+/**
+ * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
+ * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
+ * equals canvas stacking order. This replaces the old two-stage
+ * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
+ * tracks by their MAX z and so lifted a low-z clip above a clip that covers it
+ * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
+ * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
+ * whole-track mapping can fix that; the mapping must be per-clip.
+ *
+ * Algorithm:
+ * 1. Order clips by z DESC; z-tie → INPUT-ARRAY-INDEX (DOM order) DESC (CSS
+ * paints equal-z siblings by DOM order — LATER in DOM paints on top, so it
+ * must place first / upper); final tie → stable key. NEVER tie-break on the
+ * mutated lane/track index (historic oscillation bug — the tie-break must be
+ * a stable function of the input, not of the output being computed).
+ * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
+ * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
+ * clip out-stacks this one (higher z, or equal z but later in DOM), so this
+ * guarantees lane order == stacking order for every overlapping pair.
+ * 3. To preserve the "distinct authored tracks stay distinct / sequential
+ * same-track clips share a lane" feel, reuse an existing lane at index ≥ that
+ * minimum ONLY when the lane's occupants are all from the SAME authored track
+ * AND none overlaps this clip in time; otherwise open a fresh lane.
+ *
+ * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
+ * returns the number of lanes used (≥ 1 when non-empty).
+ */
+function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map): number {
+ const ordered = [...clips].sort(
+ (a, b) =>
+ zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
+ );
+ const lanes: Lane[] = [];
+ for (const item of ordered) {
+ const minLane = lowestAllowedLane(lanes, item);
+ let placed = findReusableLane(lanes, minLane, item);
+ if (placed === -1) {
+ placed = lanes.length;
+ lanes.push({ occupants: [], track: item.el.track });
+ }
+ lanes[placed].occupants.push(item);
+ laneOf.set(keyOf(item.el), base + placed);
+ }
+ return lanes.length;
+}
+
+/**
+ * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
+ * authored track's clips onto sub-lanes so no two overlap in time — sequential
+ * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
+ * start (then stable key) so the layout is deterministic and idempotent. Returns
+ * the number of lanes used (≥ 1 when non-empty).
+ */
+function packAudioTrackLanes(
+ clips: IndexedClip[],
+ base: number,
+ laneOf: Map,
+): number {
+ const ordered = [...clips].sort(
+ (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
+ );
+ const lanes: IndexedClip[][] = [];
+ for (const item of ordered) {
+ let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
+ if (sub === -1) {
+ sub = lanes.length;
+ lanes.push([]);
+ }
+ lanes[sub].push(item);
+ laneOf.set(keyOf(item.el), base + sub);
+ }
+ return Math.max(1, lanes.length);
+}
+
+/**
+ * Assign display lanes for the timeline: visual lanes on top, audio lanes below.
+ *
+ * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
+ * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
+ * low-z clip sinks below a clip that covers it even if it shares an authored track
+ * with a higher-z clip. Time-overlapping clips still split onto separate lanes
+ * (standard NLE), sequential same-track clips still share a lane, and distinct
+ * authored tracks stay distinct.
+ *
+ * The AUDIO zone keeps the original behavior — authored-track order, per-track
+ * interval packing — because audio has no z / stacking semantics.
+ *
+ * Pure — returns a new array; unchanged clips keep their identity. Display-only
+ * (runs on discovery); it does not rewrite the source. Idempotent (running it on
+ * its own output is a fixed point).
+ */
+export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
+ if (elements.length === 0) return elements;
+
+ const laneOf = new Map();
+ let nextLane = 0;
+
+ const visual: IndexedClip[] = [];
+ const audio: IndexedClip[] = [];
+ elements.forEach((el, domIndex) => {
+ (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
+ });
+
+ nextLane += packZoneLanes(visual, nextLane, laneOf);
+
+ // Audio: preserve legacy behavior — group by authored track (ascending), pack
+ // each track's overlapping clips onto sub-lanes.
+ const audioByTrack = new Map();
+ for (const item of audio) {
+ const list = audioByTrack.get(item.el.track);
+ if (list) list.push(item);
+ else audioByTrack.set(item.el.track, [item]);
+ }
+ for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
+ nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
+ }
+
+ let changed = false;
+ const remapped = elements.map((el) => {
+ const lane = laneOf.get(keyOf(el));
+ if (lane == null || lane === el.track) return el;
+ changed = true;
+ return { ...el, track: lane };
+ });
+ return changed ? remapped : elements;
+}
diff --git a/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts b/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts
index 9b41494d85..1a4fca13e2 100644
--- a/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts
+++ b/packages/studio/src/player/components/useResolvedTimelineEditCallbacks.ts
@@ -6,7 +6,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
// the rest of the callback bag still comes from TimelineEditContext.
export type TimelineEditOverrides = Pick<
TimelineEditCallbacks,
- "onMoveElement" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
+ "onMoveElement" | "onMoveElements" | "onResizeElement" | "onBlockedEditAttempt" | "onSplitElement"
>;
// Merge any prop overrides over the context callbacks. Used so NLELayout can
@@ -16,15 +16,17 @@ export function useResolvedTimelineEditCallbacks(
overrides: TimelineEditOverrides,
): TimelineEditCallbacks {
const ctx = useTimelineEditContextOptional();
- const { onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement } = overrides;
+ const { onMoveElement, onMoveElements, onResizeElement, onBlockedEditAttempt, onSplitElement } =
+ overrides;
return useMemo(
() => ({
...ctx,
onMoveElement: onMoveElement ?? ctx.onMoveElement,
+ onMoveElements: onMoveElements ?? ctx.onMoveElements,
onResizeElement: onResizeElement ?? ctx.onResizeElement,
onBlockedEditAttempt: onBlockedEditAttempt ?? ctx.onBlockedEditAttempt,
onSplitElement: onSplitElement ?? ctx.onSplitElement,
}),
- [ctx, onMoveElement, onResizeElement, onBlockedEditAttempt, onSplitElement],
+ [ctx, onMoveElement, onMoveElements, onResizeElement, onBlockedEditAttempt, onSplitElement],
);
}
diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts
index b9faa4be12..bd4fb28e70 100644
--- a/packages/studio/src/player/components/useTimelineClipDrag.ts
+++ b/packages/studio/src/player/components/useTimelineClipDrag.ts
@@ -1,3 +1,4 @@
+import type { TimelineSnapType } from "./timelineSnapping";
import { useRef, useState, useCallback, useMemo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import {
@@ -41,14 +42,23 @@ export interface DraggedClipState {
pointerOffsetY: number;
previewStart: number;
previewTrack: number;
- previewLayerId: TimelineLayerId;
- previewLayerIndex: number;
+ previewLayerId?: TimelineLayerId;
+ previewLayerIndex?: number;
/** Beat time the clip will snap to on drop, for the grid-line highlight. */
- snapBeatTime: number | null;
- snapGuideTime: number | null;
- snapGuideKind: TimelineSnapKind | null;
+ snapBeatTime?: number | null;
+ snapGuideTime?: number | null;
+ snapGuideKind?: TimelineSnapKind | null;
/** Sibling-scoped z-index reorder intent resolved from the vertical drag. */
- previewStackingReorder: TimelineStackingReorderIntent | null;
+ previewStackingReorder?: TimelineStackingReorderIntent | null;
+ /**
+ * When non-null, the drop inserts a NEW track at this visual row boundary
+ * (0 = above the top lane) instead of landing on previewTrack. Optional —
+ * populated only by the lane-zone drag path (TimelineLanes).
+ */
+ insertRow?: number | null;
+ /** Snap target the clip will land on, for the guide highlight (lane-zone drag path). */
+ snapTime?: number | null;
+ snapType?: TimelineSnapType | null;
started: boolean;
}
@@ -56,11 +66,16 @@ export interface ResizingClipState {
element: TimelineElement;
edge: "start" | "end";
originClientX: number;
+ /**
+ * scrollLeft at gesture start. Optional so pre-existing constructors/tests
+ * stay valid; when absent the first preview update captures the current value.
+ */
+ originScrollLeft?: number;
previewStart: number;
previewDuration: number;
previewPlaybackStart?: number;
- snapGuideTime: number | null;
- snapGuideKind: TimelineSnapKind | null;
+ snapGuideTime?: number | null;
+ snapGuideKind?: TimelineSnapKind | null;
started: boolean;
}
diff --git a/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts b/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts
new file mode 100644
index 0000000000..bda3d50601
--- /dev/null
+++ b/packages/studio/src/player/hooks/useTimelinePlayerLoop.ts
@@ -0,0 +1,85 @@
+/**
+ * The forward playback loop for the timeline player.
+ *
+ * Owns the three requestAnimationFrame lifecycle callbacks that drive (and stop)
+ * playback:
+ * - startRAFLoop — the forward tick: advance liveTime, honour in/out loop
+ * points + loopEnabled, and pause + sync the store at the end.
+ * - stopRAFLoop — cancel the forward tick.
+ * - stopReverseLoop — cancel the reverse-shuttle tick (owned by the parent hook).
+ *
+ * Called unconditionally at the top level of useTimelinePlayer so its useCallback
+ * hooks run in a stable order; every dependency is passed in as an argument.
+ */
+
+import { useCallback } from "react";
+import { liveTime, usePlayerStore } from "../store/playerStore";
+import type { PlaybackAdapter } from "../lib/playbackTypes";
+
+interface UseTimelinePlayerLoopParams {
+ rafRef: React.MutableRefObject;
+ reverseRafRef: React.MutableRefObject;
+ getAdapter: () => PlaybackAdapter | null;
+ setCurrentTime: (v: number) => void;
+ setIsPlaying: (v: boolean) => void;
+}
+
+interface UseTimelinePlayerLoopResult {
+ startRAFLoop: () => void;
+ stopRAFLoop: () => void;
+ stopReverseLoop: () => void;
+}
+
+export function useTimelinePlayerLoop({
+ rafRef,
+ reverseRafRef,
+ getAdapter,
+ setCurrentTime,
+ setIsPlaying,
+}: UseTimelinePlayerLoopParams): UseTimelinePlayerLoopResult {
+ const stopReverseLoop = useCallback(() => {
+ cancelAnimationFrame(reverseRafRef.current);
+ }, [reverseRafRef]);
+
+ const startRAFLoop = useCallback(() => {
+ // fallow-ignore-next-line complexity
+ const tick = () => {
+ const adapter = getAdapter();
+ if (adapter) {
+ const rawTime = adapter.getTime();
+ const dur = adapter.getDuration();
+ const time = dur > 0 ? Math.min(rawTime, dur) : rawTime;
+ liveTime.notify(time); // direct DOM updates, no React re-render
+ const { inPoint, outPoint } = usePlayerStore.getState();
+ const rawLoopEnd = outPoint !== null ? Math.min(outPoint, dur) : dur;
+ const rawLoopStart = inPoint !== null ? inPoint : 0;
+ const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
+ const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
+ if (time >= loopEnd) {
+ if (usePlayerStore.getState().loopEnabled && dur > 0) {
+ // keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
+ adapter.seek(loopStart, { keepPlaying: true });
+ liveTime.notify(loopStart);
+ adapter.play();
+ setIsPlaying(true);
+ rafRef.current = requestAnimationFrame(tick);
+ return;
+ }
+ if (adapter.isPlaying()) adapter.pause();
+ setCurrentTime(time); // sync Zustand once at end
+ setIsPlaying(false);
+ cancelAnimationFrame(rafRef.current);
+ return;
+ }
+ }
+ rafRef.current = requestAnimationFrame(tick);
+ };
+ rafRef.current = requestAnimationFrame(tick);
+ }, [rafRef, getAdapter, setCurrentTime, setIsPlaying]);
+
+ const stopRAFLoop = useCallback(() => {
+ cancelAnimationFrame(rafRef.current);
+ }, [rafRef]);
+
+ return { startRAFLoop, stopRAFLoop, stopReverseLoop };
+}
diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts
index f582530821..a9eb60425e 100644
--- a/packages/studio/src/player/lib/timelineElementHelpers.ts
+++ b/packages/studio/src/player/lib/timelineElementHelpers.ts
@@ -15,6 +15,31 @@ import { isFinitePositive } from "./playbackAdapter";
// Duration attribute helpers
// ---------------------------------------------------------------------------
+/**
+ * Read a host element's effective CSS stacking order for the timeline's reverse
+ * z→lane mapping. Prefers the inline `style.zIndex` (what the canvas context
+ * menu and LayersPanel z-edits write via handleDomZIndexReorderCommit), falls
+ * back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
+ * detached parse Document (no defaultView) as well as a live iframe. Mirrors
+ * canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
+ */
+export function readTimelineElementZIndex(el: Element): number {
+ const html = el as HTMLElement;
+ const parseZ = (value: string | null | undefined): number | null => {
+ if (value == null || value === "" || value === "auto") return null;
+ const n = Number.parseInt(value, 10);
+ return Number.isFinite(n) ? n : null;
+ };
+ const fromInline = parseZ(html.style?.zIndex);
+ if (fromInline != null) return fromInline;
+ const view = el.ownerDocument?.defaultView;
+ if (view?.getComputedStyle) {
+ const fromComputed = parseZ(view.getComputedStyle(html).zIndex);
+ if (fromComputed != null) return fromComputed;
+ }
+ return 0;
+}
+
function readDurationAttribute(el: Element | null | undefined): number {
if (!el) return 0;
const duration =
@@ -36,14 +61,20 @@ export function isTimelineIgnoredElement(el: Element): boolean {
);
}
-export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
+/**
+ * Furthest clip end (start + RAW `data-duration`) over every non-root clip in the
+ * document. Reads the authored attribute, NOT any runtime-computed value — so it
+ * is immune to the runtime's clamp that truncates a clip's live duration to the
+ * composition length. This is the source of truth for content-driven duration:
+ * computing it from the store instead would feed the truncated value back in and
+ * make the composition length ratchet down (research HANDOFF-3 §6.1 feedback loop).
+ */
+export function furthestClipEndFromDocument(doc: Document | null | undefined): number {
if (!doc) return 0;
- const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
- if (rootDuration > 0) return rootDuration;
-
+ const root = doc.querySelector("[data-composition-id]");
let maxEnd = 0;
for (const node of Array.from(doc.querySelectorAll("[data-start]"))) {
- if (isTimelineIgnoredElement(node)) continue;
+ if (node === root || isTimelineIgnoredElement(node)) continue;
const start = Number.parseFloat(node.getAttribute("data-start") ?? "");
const duration = readDurationAttribute(node);
if (!Number.isFinite(start) || start < 0 || duration <= 0) continue;
@@ -52,6 +83,24 @@ export function readTimelineDurationFromDocument(doc: Document | null | undefine
return maxEnd;
}
+export function readTimelineDurationFromDocument(doc: Document | null | undefined): number {
+ if (!doc) return 0;
+ const rootDuration = readDurationAttribute(doc.querySelector("[data-composition-id]"));
+ if (rootDuration > 0) return rootDuration;
+ return furthestClipEndFromDocument(doc);
+}
+
+/**
+ * Furthest clip end parsed straight from a composition SOURCE STRING (the HTML
+ * being saved). Uses raw `data-duration`, so it is the correct input for syncing
+ * the root duration after an edit — reading the store instead would use the
+ * runtime-truncated durations and shrink the composition (the feedback loop).
+ */
+export function furthestClipEndFromSource(source: string): number {
+ if (!source) return 0;
+ return furthestClipEndFromDocument(new DOMParser().parseFromString(source, "text/html"));
+}
+
// ---------------------------------------------------------------------------
// DOM element type guards
// ---------------------------------------------------------------------------
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index ac7fcf0d43..73553e45c3 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -3,6 +3,7 @@ import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
import type { BeatEditState } from "../../utils/beatEditing";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
+import { computePinnedZoomPercent } from "../components/timelineZoom";
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
export interface KeyframeCacheEntry {
@@ -142,6 +143,25 @@ interface PlayerState {
/** Multi-select: additional selected elements beyond selectedElementId. */
selectedElementIds: Set;
+ clearSelectedElementIds: () => void;
+ /** Replace the whole multi-selection at once (marquee live updates). */
+ setSelectedElementIds: (ids: Set) => void;
+ /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
+ timelineSnapEnabled: boolean;
+ setTimelineSnapEnabled: (enabled: boolean) => void;
+ /**
+ * Pin the timeline zoom to its current visual scale before a duration-changing
+ * edit, so a subsequent duration change (which recomputes fit-pps) stops
+ * rescaling every clip. No-op once already pinned (mode is "manual").
+ */
+ pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
+ /**
+ * The timeline's live pixels-per-second + fit basis, published by on
+ * every render. Non-reactive scratch state (never read as a render input).
+ */
+ timelinePps: number;
+ timelineFitPps: number;
+ setTimelineScale: (pps: number, fitPps: number) => void;
setSelection: (ids: Iterable, anchor?: string | null) => void;
addSelectedElementId: (id: string) => void;
toggleSelectedElementId: (id: string) => void;
@@ -260,6 +280,8 @@ export const usePlayerStore = create((set, get) => ({
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
+ timelinePps: 100,
+ timelineFitPps: 100,
inPoint: null,
outPoint: null,
@@ -383,6 +405,32 @@ export const usePlayerStore = create((set, get) => ({
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
+ clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
+ setSelectedElementIds: (ids: Set) => set({ selectedElementIds: new Set(ids) }),
+ timelineSnapEnabled: readStudioUiPreferences().timelineSnapEnabled ?? true,
+ setTimelineSnapEnabled: (enabled) => {
+ writeStudioUiPreferences({ timelineSnapEnabled: enabled });
+ set({ timelineSnapEnabled: enabled });
+ },
+ pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
+ set((s) => {
+ // Already pinned (or the user manually zoomed) — never clobber that.
+ if (s.zoomMode === "manual") return {};
+ const percent = computePinnedZoomPercent(currentPixelsPerSecond, fitPixelsPerSecond);
+ writeStudioUiPreferences({
+ timelineZoomMode: "manual",
+ timelineManualZoomPercent: percent,
+ });
+ return { zoomMode: "manual", manualZoomPercent: percent };
+ }),
+ setTimelineScale: (pps, fitPps) => {
+ // Non-reactive publish: mutate in place + reuse the same object identity so no
+ // subscriber re-renders (these fields are never a render input, only read
+ // imperatively before pinning).
+ const state = get();
+ state.timelinePps = pps;
+ state.timelineFitPps = fitPps;
+ },
setInPoint: (time) =>
set((state) => {
const t = time !== null && Number.isFinite(time) ? time : null;
diff --git a/packages/studio/src/utils/assetClickBehavior.test.ts b/packages/studio/src/utils/assetClickBehavior.test.ts
new file mode 100644
index 0000000000..e1ae21ac40
--- /dev/null
+++ b/packages/studio/src/utils/assetClickBehavior.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+import { findClipForAsset, isPointerClick, DRAG_THRESHOLD_PX } from "./assetClickBehavior";
+import type { TimelineElement } from "../player/store/playerStore";
+
+// Minimal TimelineElement factory — only the fields the function inspects.
+function makeEl(overrides: Partial & { id: string }): TimelineElement {
+ return {
+ tag: "div",
+ start: 0,
+ duration: 5,
+ track: 0,
+ ...overrides,
+ };
+}
+
+describe("findClipForAsset", () => {
+ it("returns null when elements array is empty", () => {
+ expect(findClipForAsset([], "assets/foo.mp4")).toBeNull();
+ });
+
+ it("returns null when no element matches", () => {
+ const el = makeEl({ id: "el1", src: "assets/other.mp4" });
+ expect(findClipForAsset([el], "assets/foo.mp4")).toBeNull();
+ });
+
+ it("matches a bare relative src against the project-relative asset path", () => {
+ const el = makeEl({ id: "el1", src: "assets/clip.mp4", start: 2 });
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
+ });
+
+ it("matches a src with a ./ prefix", () => {
+ const el = makeEl({ id: "el1", src: "./assets/logo.png" });
+ expect(findClipForAsset([el], "assets/logo.png")).toBe(el);
+ });
+
+ it("matches a server-relative /api/projects/…/preview/ src", () => {
+ const el = makeEl({ id: "el1", src: "/api/projects/demo/preview/assets/bgm.mp3" });
+ expect(findClipForAsset([el], "assets/bgm.mp3")).toBe(el);
+ });
+
+ it("matches a fully-absolute URL (as produced by the core runtime)", () => {
+ const el = makeEl({
+ id: "el1",
+ src: "http://localhost:3012/api/projects/demo/preview/assets/clip.mp4",
+ });
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
+ });
+
+ it("decodes percent-encoded filenames when matching", () => {
+ const el = makeEl({
+ id: "el1",
+ src: "http://localhost:3012/api/projects/p/preview/assets/my%20file%20(1).mp4",
+ });
+ expect(findClipForAsset([el], "assets/my file (1).mp4")).toBe(el);
+ });
+
+ it("strips query strings from src before matching", () => {
+ const el = makeEl({ id: "el1", src: "assets/clip.mp4?v=2" });
+ expect(findClipForAsset([el], "assets/clip.mp4")).toBe(el);
+ });
+
+ it("returns the element with the earliest start when multiple clips match", () => {
+ const later = makeEl({ id: "late", src: "assets/clip.mp4", start: 10 });
+ const earlier = makeEl({ id: "early", src: "assets/clip.mp4", start: 2 });
+ const first = makeEl({ id: "first", src: "assets/clip.mp4", start: 0 });
+ expect(findClipForAsset([later, earlier, first], "assets/clip.mp4")).toBe(first);
+ });
+
+ it("prefers the key over id when the element has both", () => {
+ // findClipForAsset returns the element object itself; callers do `clip.key ?? clip.id`
+ const el = makeEl({ id: "el1", key: "clip-key", src: "assets/img.png" });
+ const found = findClipForAsset([el], "assets/img.png");
+ expect(found?.key).toBe("clip-key");
+ });
+
+ it("skips elements with no src", () => {
+ const noSrc = makeEl({ id: "nosrc" });
+ const withSrc = makeEl({ id: "withsrc", src: "assets/img.png" });
+ expect(findClipForAsset([noSrc, withSrc], "assets/img.png")).toBe(withSrc);
+ });
+});
+
+describe("isPointerClick", () => {
+ it("returns true for zero movement", () => {
+ expect(isPointerClick(0, 0)).toBe(true);
+ });
+
+ it("returns true for movement within the threshold", () => {
+ expect(isPointerClick(DRAG_THRESHOLD_PX - 1, 0)).toBe(true);
+ expect(isPointerClick(0, DRAG_THRESHOLD_PX - 1)).toBe(true);
+ expect(isPointerClick(DRAG_THRESHOLD_PX - 1, DRAG_THRESHOLD_PX - 1)).toBe(true);
+ });
+
+ it("returns false at or beyond the threshold", () => {
+ expect(isPointerClick(DRAG_THRESHOLD_PX, 0)).toBe(false);
+ expect(isPointerClick(0, DRAG_THRESHOLD_PX)).toBe(false);
+ expect(isPointerClick(DRAG_THRESHOLD_PX + 10, 0)).toBe(false);
+ });
+
+ it("handles negative movement (pointer moved left/up)", () => {
+ expect(isPointerClick(-(DRAG_THRESHOLD_PX - 1), 0)).toBe(true);
+ expect(isPointerClick(-DRAG_THRESHOLD_PX, 0)).toBe(false);
+ });
+});
diff --git a/packages/studio/src/utils/assetClickBehavior.ts b/packages/studio/src/utils/assetClickBehavior.ts
new file mode 100644
index 0000000000..d007f1b0f3
--- /dev/null
+++ b/packages/studio/src/utils/assetClickBehavior.ts
@@ -0,0 +1,75 @@
+/**
+ * Pure helpers for CapCut-style asset card click behavior.
+ *
+ * Clicking an asset card that is ALREADY ADDED to the timeline selects the
+ * corresponding clip. Clicking one NOT yet in the timeline opens a lightweight
+ * preview overlay. Both behaviors are gated on "this was a click, not a drag".
+ *
+ * Pure — unit-tested.
+ */
+import type { TimelineElement } from "../player/store/playerStore";
+
+/**
+ * Find the TimelineElement that references `assetPath`, returning the one with
+ * the earliest start time when multiple clips share the same source.
+ *
+ * Matching mirrors `deriveUsedPaths` in AssetsTab: an element's `src` may be a
+ * fully-absolute URL, a server-relative `/api/projects/…/preview/…` path, a
+ * `./`-prefixed relative path, or a bare relative path — all normalised to the
+ * project-relative form that `assetPath` carries.
+ *
+ * Returns `null` when no element matches.
+ */
+export function findClipForAsset(
+ elements: TimelineElement[],
+ assetPath: string,
+): TimelineElement | null {
+ let best: TimelineElement | null = null;
+ for (const el of elements) {
+ if (!el.src) continue;
+ if (normalizeSrc(el.src) !== assetPath) continue;
+ if (best === null || el.start < best.start) best = el;
+ }
+ return best;
+}
+
+/**
+ * Normalise a raw element `src` to the bare project-relative path so it can be
+ * compared against the asset-list strings (which have no leading slash, no
+ * origin, no query string).
+ *
+ * Mirrors the logic in `deriveUsedPaths` (AssetsTab.tsx) — keep in sync.
+ */
+function normalizeSrc(src: string): string {
+ let s = src;
+ try {
+ const u = new URL(s);
+ s = u.pathname;
+ } catch {
+ // Not an absolute URL — leave as-is
+ }
+ s = s
+ .replace(/^\/api\/projects\/[^/]+\/preview\//, "")
+ .replace(/^\.?\//, "")
+ .split(/[?#]/)[0];
+ try {
+ s = decodeURIComponent(s);
+ } catch {
+ // Malformed encoding — use as-is
+ }
+ return s;
+}
+
+/** Drag-detection threshold in pixels — movements within this are treated as clicks. */
+export const DRAG_THRESHOLD_PX = 4;
+
+/**
+ * Determine whether a pointer-up event should be treated as a click given the
+ * total pointer displacement since pointer-down.
+ *
+ * @param dx Horizontal distance moved in pixels.
+ * @param dy Vertical distance moved in pixels.
+ */
+export function isPointerClick(dx: number, dy: number): boolean {
+ return Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX;
+}
diff --git a/packages/studio/src/utils/assetPreviewStore.ts b/packages/studio/src/utils/assetPreviewStore.ts
new file mode 100644
index 0000000000..44e807bc54
--- /dev/null
+++ b/packages/studio/src/utils/assetPreviewStore.ts
@@ -0,0 +1,33 @@
+/**
+ * Tiny Zustand slice that carries the "asset preview overlay" state.
+ *
+ * When a user clicks an asset card that has NOT yet been added to the
+ * timeline the overlay fires up: a dark scrim + centered media element
+ * (img / video / audio) + filename label rendered inside PreviewPane.
+ *
+ * State lives here so AssetsTab (sidebar) and PreviewPane (preview column)
+ * can communicate without prop-drilling through the multi-layer EditorShell
+ * tree. The store is project-scoped: NLEProvider (NLEContext.tsx) clears it
+ * whenever `projectId` changes, so a preview opened in one project can't
+ * bleed into another (the overlay itself stays mounted across project
+ * switches — EditorShell isn't keyed by projectId).
+ */
+import { create } from "zustand";
+
+interface AssetPreviewState {
+ /** Project-relative asset path currently being previewed, or null. */
+ previewAsset: string | null;
+ /** projectId for which the preview was opened (used to build the serve URL). */
+ previewProjectId: string | null;
+ /** Open a media preview for the given asset. */
+ setPreviewAsset: (asset: string, projectId: string) => void;
+ /** Close the preview overlay. */
+ clearPreviewAsset: () => void;
+}
+
+export const useAssetPreviewStore = create((set) => ({
+ previewAsset: null,
+ previewProjectId: null,
+ setPreviewAsset: (asset, projectId) => set({ previewAsset: asset, previewProjectId: projectId }),
+ clearPreviewAsset: () => set({ previewAsset: null, previewProjectId: null }),
+}));
diff --git a/packages/studio/src/utils/canvasNudgeGate.test.ts b/packages/studio/src/utils/canvasNudgeGate.test.ts
new file mode 100644
index 0000000000..d0e70c48ed
--- /dev/null
+++ b/packages/studio/src/utils/canvasNudgeGate.test.ts
@@ -0,0 +1,31 @@
+import { beforeEach, describe, expect, it } from "vitest";
+import { __resetForTests, acquireCanvasNudgeKeys, canvasNudgeKeysClaimed } from "./canvasNudgeGate";
+
+describe("canvasNudgeGate", () => {
+ // The claim counter is module-level state; reset it so an unbalanced claim in one
+ // test can't leak into the next.
+ beforeEach(() => {
+ __resetForTests();
+ });
+
+ it("reports claimed while at least one claim is held", () => {
+ expect(canvasNudgeKeysClaimed()).toBe(false);
+ const releaseA = acquireCanvasNudgeKeys();
+ const releaseB = acquireCanvasNudgeKeys();
+ expect(canvasNudgeKeysClaimed()).toBe(true);
+ releaseA();
+ expect(canvasNudgeKeysClaimed()).toBe(true);
+ releaseB();
+ expect(canvasNudgeKeysClaimed()).toBe(false);
+ });
+
+ it("makes release idempotent so an effect re-run cannot underflow", () => {
+ const release = acquireCanvasNudgeKeys();
+ release();
+ release();
+ expect(canvasNudgeKeysClaimed()).toBe(false);
+ const releaseNext = acquireCanvasNudgeKeys();
+ expect(canvasNudgeKeysClaimed()).toBe(true);
+ releaseNext();
+ });
+});
diff --git a/packages/studio/src/utils/canvasNudgeGate.ts b/packages/studio/src/utils/canvasNudgeGate.ts
new file mode 100644
index 0000000000..6d3e121c4c
--- /dev/null
+++ b/packages/studio/src/utils/canvasNudgeGate.ts
@@ -0,0 +1,32 @@
+/**
+ * Arrow-key ownership gate between the canvas nudge (DomEditOverlay) and the
+ * playback frame-step shortcuts (usePlaybackKeyboard). Both listen for arrow
+ * keys with window capture listeners, and their relative order depends on
+ * mount order (DomEditOverlay remounts when caption edit mode toggles), so
+ * `event.defaultPrevented` alone can't arbitrate. While a nudgeable canvas
+ * selection holds a claim, the playback handler skips ArrowLeft/ArrowRight.
+ */
+
+let claims = 0;
+
+/** Claim the arrow keys for canvas nudging. Returns an idempotent release. */
+export function acquireCanvasNudgeKeys(): () => void {
+ claims += 1;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ claims -= 1;
+ };
+}
+
+/** True while a nudgeable canvas selection owns the arrow keys. */
+export function canvasNudgeKeysClaimed(): boolean {
+ return claims > 0;
+}
+
+/** Test-only: reset the module-level claim counter so leaked claims from one test
+ * can't bleed into the next (call in a `beforeEach`). */
+export function __resetForTests(): void {
+ claims = 0;
+}
diff --git a/packages/studio/src/utils/rootDuration.test.ts b/packages/studio/src/utils/rootDuration.test.ts
index 795941ce54..b0f5ee8603 100644
--- a/packages/studio/src/utils/rootDuration.test.ts
+++ b/packages/studio/src/utils/rootDuration.test.ts
@@ -1,5 +1,10 @@
+// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
-import { extendRootDurationInSource } from "./rootDuration";
+import {
+ extendRootDurationInSource,
+ patchRootCompositionDuration,
+ readRootCompositionDuration,
+} from "./rootDuration";
describe("extendRootDurationInSource", () => {
it("extends data-duration when the new end is bigger than the root duration", () => {
@@ -31,4 +36,88 @@ describe("extendRootDurationInSource", () => {
expect(patched).toContain(`
`);
expect(patched).toContain(`
`);
});
+
+ // Reviewer round-2 finding #3: the old regex was attribute-ORDER-dependent and
+ // double-quotes-only, so these hand-authored variants silently no-op'd.
+ it("extends when data-duration is declared BEFORE data-composition-id", () => {
+ const source = `
`;
+ expect(extendRootDurationInSource(source, 9)).toBe(
+ `
`,
+ );
+ });
+
+ it("extends when attributes use single quotes", () => {
+ const source = `
`;
+ expect(extendRootDurationInSource(source, 9)).toBe(
+ `
`,
+ );
+ });
+
+ it("extends with swapped order AND single quotes AND extra whitespace", () => {
+ const source = `x
`;
+ expect(extendRootDurationInSource(source, 9)).toBe(
+ `x
`,
+ );
+ });
+});
+
+describe("readRootCompositionDuration", () => {
+ it("reads the root duration regardless of attribute order or quote style", () => {
+ expect(
+ readRootCompositionDuration(`
`),
+ ).toBe(4);
+ expect(
+ readRootCompositionDuration(`
`),
+ ).toBe(4);
+ expect(
+ readRootCompositionDuration(`
`),
+ ).toBe(4.5);
+ });
+
+ it("reads the FIRST composition when several are present", () => {
+ const source = [
+ `
`,
+ `
`,
+ ].join("\n");
+ expect(readRootCompositionDuration(source)).toBe(10);
+ });
+
+ it("returns null when there is no composition root", () => {
+ expect(readRootCompositionDuration(`
`)).toBeNull();
+ });
+
+ it("returns null when the root has no data-duration attribute", () => {
+ expect(readRootCompositionDuration(`
`)).toBeNull();
+ });
+});
+
+describe("patchRootCompositionDuration", () => {
+ it("rewrites only the root's data-duration value, preserving surrounding bytes", () => {
+ const source = [
+ ``,
+ ``,
+ `
`,
+ `
`,
+ ].join("\n");
+ const patched = patchRootCompositionDuration(source, "8");
+ expect(patched).toBe(
+ [
+ ``,
+ ``,
+ `
`,
+ `
`,
+ ].join("\n"),
+ );
+ });
+
+ it("keeps single-quote style when rewriting", () => {
+ expect(
+ patchRootCompositionDuration(`
`, "8"),
+ ).toBe(`
`);
+ });
+
+ it("is a no-op when the root has no data-duration attribute", () => {
+ const source = `
`;
+ expect(patchRootCompositionDuration(source, "8")).toBe(source);
+ });
});
diff --git a/packages/studio/src/utils/rootDuration.ts b/packages/studio/src/utils/rootDuration.ts
index 0a9350425e..32724c6a72 100644
--- a/packages/studio/src/utils/rootDuration.ts
+++ b/packages/studio/src/utils/rootDuration.ts
@@ -1,17 +1,80 @@
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
-export function extendRootDurationInSource(source: string, newEnd: number): string {
- const rootDurMatch = source.match(
- /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
+/**
+ * Matches the opening tag of the ROOT composition element — the first tag that
+ * carries a `data-composition-id` attribute, regardless of where the attribute
+ * sits in the tag or how its value is quoted. `[^>]*` keeps the match inside a
+ * single tag, so the first hit is the first composition in document order (the
+ * same element `doc.querySelector("[data-composition-id]")` resolves to).
+ */
+const ROOT_COMPOSITION_OPEN_TAG_RE = /<[^>]*\bdata-composition-id(?=[\s=/>])[^>]*>/i;
+
+/**
+ * Matches a `data-duration="..."` attribute inside a single opening tag. Quote
+ * style is captured (backreferenced), so both `"` and `'` round-trip, and the
+ * `\s*` around `=` tolerates author whitespace.
+ */
+const DATA_DURATION_ATTR_RE = /(\bdata-duration\s*=\s*)(["'])[^"']*\2/i;
+
+/**
+ * Read the ROOT composition's raw `data-duration`.
+ *
+ * Parses the source with DOMParser and locates the root the same way the rest of
+ * the timeline code does — the first `[data-composition-id]` element in document
+ * order — then reads its `data-duration`. Because it works on the parsed tree,
+ * attribute order and quote style are irrelevant, unlike the previous
+ * order-dependent, double-quotes-only regex.
+ *
+ * Returns `null` when there is no root composition or the root has no
+ * `data-duration` attribute at all. When the attribute is present but not a
+ * number the parsed `NaN` is returned as-is, so callers reproduce the old
+ * regex's "attribute matched but value unusable" behavior.
+ *
+ * Deterministic and render-safe: DOMParser is the only DOM global used.
+ */
+export function readRootCompositionDuration(source: string): number | null {
+ const root = new DOMParser()
+ .parseFromString(source, "text/html")
+ .querySelector("[data-composition-id]");
+ const raw = root?.getAttribute("data-duration");
+ if (raw == null) return null;
+ return Number.parseFloat(raw);
+}
+
+/**
+ * Rewrite the ROOT composition's `data-duration` value, preserving the rest of
+ * the document byte-for-byte.
+ *
+ * We deliberately do NOT re-serialize the parsed Document: a DOMParser round-trip
+ * injects `//`, forces double quotes, self-closes void
+ * elements, and drops the original indentation — it reformats unrelated markup.
+ * Instead we locate the root opening tag and rewrite only its `data-duration`
+ * value in place, keeping the author's quote style. The targeted splice is
+ * attribute-order-, quote-, and whitespace-agnostic.
+ *
+ * No-op (returns `source` unchanged) when there is no root composition tag or the
+ * root tag has no `data-duration` attribute to replace.
+ */
+export function patchRootCompositionDuration(source: string, newValue: string): string {
+ const rootTag = ROOT_COMPOSITION_OPEN_TAG_RE.exec(source);
+ if (!rootTag) return source;
+ const patchedTag = rootTag[0].replace(
+ DATA_DURATION_ATTR_RE,
+ (_full, prefix: string, quote: string) => `${prefix}${quote}${newValue}${quote}`,
);
- if (rootDurMatch) {
- const rootDur = parseFloat(rootDurMatch[2]!);
- if (newEnd > rootDur) {
- return source.replace(
- rootDurMatch[0],
- `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
- );
- }
- }
- return source;
+ if (patchedTag === rootTag[0]) return source;
+ return (
+ source.slice(0, rootTag.index) + patchedTag + source.slice(rootTag.index + rootTag[0].length)
+ );
+}
+
+/**
+ * Grow-only ratchet: extend the root composition's `data-duration` to `newEnd`
+ * when `newEnd` is larger than the current root duration. No-op otherwise (and
+ * when there is no root duration to compare against).
+ */
+export function extendRootDurationInSource(source: string, newEnd: number): string {
+ const current = readRootCompositionDuration(source);
+ if (current == null || !(newEnd > current)) return source;
+ return patchRootCompositionDuration(source, formatTimelineAttributeNumber(newEnd));
}
diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts
index 0565eea2ca..7b4ba8a479 100644
--- a/packages/studio/src/utils/studioHelpers.ts
+++ b/packages/studio/src/utils/studioHelpers.ts
@@ -308,3 +308,65 @@ export async function resolveDroppedAssetDuration(
media.load();
return duration;
}
+
+export async function resolveDroppedAssetDimensions(
+ projectId: string,
+ assetPath: string,
+ kind: TimelineAssetKind,
+): Promise<{ width: number; height: number } | null> {
+ if (kind === "audio") return null;
+ const src = `/api/projects/${projectId}/preview/${assetPath}`;
+
+ if (kind === "image") {
+ return new Promise((resolve) => {
+ const img = new Image();
+ const timeout = window.setTimeout(() => resolve(null), 3000);
+ img.addEventListener(
+ "load",
+ () => {
+ window.clearTimeout(timeout);
+ resolve(
+ img.naturalWidth > 0 && img.naturalHeight > 0
+ ? { width: img.naturalWidth, height: img.naturalHeight }
+ : null,
+ );
+ },
+ { once: true },
+ );
+ img.addEventListener(
+ "error",
+ () => {
+ window.clearTimeout(timeout);
+ resolve(null);
+ },
+ { once: true },
+ );
+ img.src = src;
+ });
+ }
+
+ return new Promise((resolve) => {
+ const video = document.createElement("video");
+ video.preload = "metadata";
+ const timeout = window.setTimeout(() => resolve(null), 3000);
+ const finalize = (value: { width: number; height: number } | null) => {
+ window.clearTimeout(timeout);
+ video.src = "";
+ video.load();
+ resolve(value);
+ };
+ video.addEventListener(
+ "loadedmetadata",
+ () => {
+ finalize(
+ video.videoWidth > 0 && video.videoHeight > 0
+ ? { width: video.videoWidth, height: video.videoHeight }
+ : null,
+ );
+ },
+ { once: true },
+ );
+ video.addEventListener("error", () => finalize(null), { once: true });
+ video.src = src;
+ });
+}
diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts
index 30abc5cfa6..5194f5899b 100644
--- a/packages/studio/src/utils/timelineAssetDrop.ts
+++ b/packages/studio/src/utils/timelineAssetDrop.ts
@@ -1,4 +1,5 @@
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
+import { patchRootCompositionDuration, readRootCompositionDuration } from "./rootDuration";
import { roundToCenti } from "./rounding";
import { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns";
@@ -98,6 +99,8 @@ export function resolveTimelineAssetInitialGeometry(source: string): {
export function buildTimelineAssetInsertHtml(input: {
id: string;
+ /** Stable hf-id stamped as data-hf-id by the NLE drop path (optional in the legacy path). */
+ hfId?: string;
assetPath: string;
kind: TimelineAssetKind;
start: number;
@@ -136,3 +139,62 @@ export function insertTimelineAssetIntoSource(source: string, assetHtml: string)
.join("\n");
return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
}
+
+/**
+ * Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
+ * timeline length tracks content — the content-driven counterpart to
+ * extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
+ * reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
+ * an empty timeline keeps its declared duration instead of collapsing to 0.
+ */
+export function setCompositionDurationToContent(source: string, contentEnd: number): string {
+ if (!Number.isFinite(contentEnd) || contentEnd <= 0) return source;
+ const rootDur = readRootCompositionDuration(source);
+ if (rootDur == null) return source;
+ const next = roundToCenti(contentEnd);
+ if (rootDur === next) return source;
+ return patchRootCompositionDuration(source, String(next));
+}
+
+export function extendCompositionDurationIfNeeded(source: string, requiredEnd: number): string {
+ const rootDur = readRootCompositionDuration(source);
+ if (rootDur == null || !Number.isFinite(rootDur) || requiredEnd <= rootDur) return source;
+ return patchRootCompositionDuration(source, String(roundToCenti(requiredEnd)));
+}
+
+/**
+ * Set the composition root's `data-duration` to `contentEnd` (grow OR shrink) so the
+ * timeline length tracks content — the content-driven counterpart to
+ * extendCompositionDurationIfNeeded's grow-only ratchet. Used after edits that can
+ * reduce the furthest clip end (delete/trim). No-op when `contentEnd` is not > 0, so
+ * an empty timeline keeps its declared duration instead of collapsing to 0.
+ */
+export function fitTimelineAssetGeometry(
+ natural: { width: number; height: number } | null,
+ comp: { width: number; height: number },
+): { left: number; top: number; width: number; height: number } {
+ if (!natural || natural.width <= 0 || natural.height <= 0) {
+ return { left: 0, top: 0, width: comp.width, height: comp.height };
+ }
+ const scale = Math.min(1, comp.width / natural.width, comp.height / natural.height);
+ const width = Math.round(natural.width * scale);
+ const height = Math.round(natural.height * scale);
+ return {
+ left: Math.round((comp.width - width) / 2),
+ top: Math.round((comp.height - height) / 2),
+ width,
+ height,
+ };
+}
+
+export function resolveTimelineAssetCompositionSize(source: string): {
+ width: number;
+ height: number;
+} {
+ const width = Number.parseFloat(source.match(/\bdata-width=(["'])([^"']+)\1/i)?.[2] ?? "");
+ const height = Number.parseFloat(source.match(/\bdata-height=(["'])([^"']+)\1/i)?.[2] ?? "");
+ return {
+ width: Number.isFinite(width) && width > 0 ? Math.round(width) : 640,
+ height: Number.isFinite(height) && height > 0 ? Math.round(height) : 360,
+ };
+}