fix(studio): restore golden-branch timeline behaviors dropped by the stack rebuild

The Studio stack rebuild (#2291) landed the remaining NLE layers but dropped
or regressed several final-wave behaviors from the reviewed studio-dnd stack,
and never repaired the stale timelineZones.ts that #2279 introduced. Restores:

- TimelineRuler: sticky under vertical scroll, full-height gridlines removed
  (beat lines only), frame-number tick labels via a persisted timeDisplayMode
  store preference (PlayerControls toggle now store-backed)
- timelineZones: stable track lanes — lane = authored data-track-index
  ascending; z is paint order only (replaces the stale z-driven lane pack,
  which broke track insert-band commits that contractually depend on it)
- persistTimelineBatchEdit: a batch member whose patch is a no-op (attributes
  already at target values, e.g. in a track-insert renumber) is skipped
  instead of aborting and rolling back the whole batch — this alone made
  new-track creation (incl. the top insert band) fail silently
- useTimelineStackingSync: unresolvable clips read as NaN again so
  timelineStackingSync's Number.isFinite exclusion contract holds (z=0
  fabrications skewed stacking boundaries)
- timelineAssetDrop: drops land on the drop track (no overlap bump to
  max-track+1), data-hf-id stamped, audio gets data-volume
- timing edits: soft-reload the server's rewritten GSAP script instead of a
  full iframe remount (no all-clips flash on move/resize); full reload only
  when no scriptText or the soft path can't apply, and one full reload when a
  group edit touches non-active files (new hooks/timelineTimingSync.ts)
- duration: content-driven grow-AND-shrink on move/resize/delete, synced
  optimistically to the store and the live root data-duration at release
  (was a grow-only ratchet; shrink never updated the readout)

New UX: sidebar asset click opens a compact non-modal preview over the canvas
(dismiss on outside click, Escape, playback, or seek), and clicking an
already-added asset reveals its clip in the timeline (smooth minimal scroll
to its time and lane; vertical-only in fit zoom).

Verified by pointer-driving a real project: sticky ruler + gridline removal,
no iframe remount on move/resize (marker survives, GSAP tween positions
rewritten in place), duration readout 40->37->40 on shrink/stretch, and
top-insert-band track creation renumbering lanes correctly on disk.
This commit is contained in:
ukimsanov
2026-07-13 16:48:51 -07:00
parent d04bcbf7c4
commit 54f41b41b6
25 changed files with 1752 additions and 1394 deletions
@@ -9,10 +9,8 @@ import { Tooltip } from "../../components/ui";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
import { useState } from "react";
export { resolveSeekPercent };
type TimeDisplayMode = "time" | "frame";
/* ── Icon sub-components ─────────────────────────────────────────── */
@@ -369,7 +367,8 @@ export const PlayerControls = memo(function PlayerControls({
const outPoint = usePlayerStore((s) => s.outPoint);
const setInPoint = usePlayerStore.getState().setInPoint;
const setOutPoint = usePlayerStore.getState().setOutPoint;
const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
const progressFillRef = useRef<HTMLDivElement>(null);
const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -428,10 +427,11 @@ export const PlayerControls = memo(function PlayerControls({
return (
<div
// No own background/border: the transport blends into the preview
// panel's surface — buttons carry their own chrome.
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
aria-disabled={disabled || undefined}
style={{
borderTop: "1px solid rgba(255,255,255,0.04)",
paddingBottom: "calc(0.5rem + env(safe-area-inset-bottom))",
}}
>
@@ -456,7 +456,7 @@ export const PlayerControls = memo(function PlayerControls({
>
<button
type="button"
onClick={() => setTimeDisplayMode((m) => (m === "time" ? "frame" : "time"))}
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
disabled={disabled}
className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px] text-left transition-colors disabled:pointer-events-none hover:opacity-80"
style={{ color: "#A1A1AA", cursor: "pointer" }}
@@ -20,6 +20,7 @@ import type { Rect } from "../../utils/marqueeGeometry";
import { TimelineClip } from "./TimelineClip";
import { TimelineLanes, type TimelineLaneBaseProps } from "./TimelineLanes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
@@ -41,6 +42,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const { onResizeElement, onMoveElement, onToggleTrackHidden, onRazorSplit, onRazorSplitAll } =
useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
useTimelineRevealClip(scrollRef);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -1,6 +1,8 @@
import { memo } from "react";
import type { TimelineTheme } from "./timelineTheme";
import { GUTTER, RULER_H, formatTimelineTickLabel } from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
import { secondsToFrame } from "../lib/time";
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
interface TimelineRulerProps {
@@ -26,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
theme,
beatAnalysis,
}: TimelineRulerProps) {
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const beatTimes = beatAnalysis?.beatTimes ?? [];
const beatStrengths = beatAnalysis?.beatStrengths ?? [];
@@ -38,27 +41,13 @@ export const TimelineRuler = memo(function TimelineRuler({
return (
<>
{/* Grid lines (major ticks + beat lines) — behind the tracks (background).
Opaque track rows hide them; only the beat dots show on tracks. */}
{/* Background SVG — beat lines only; major-tick gridlines removed so only
the ruler's own small ticks mark intervals (no full-height lines). */}
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER, width: trackContentWidth, zIndex: 0 }}
height={totalH}
>
{major.map((t) => {
const x = t * pps;
return (
<line
key={`g-${t}`}
x1={x}
y1={RULER_H}
x2={x}
y2={totalH}
stroke={theme.tickMinor}
strokeWidth="1"
/>
);
})}
{showBeats &&
beatTimes.map((t, i) => {
const x = t * pps;
@@ -79,41 +68,58 @@ export const TimelineRuler = memo(function TimelineRuler({
})}
</svg>
{/* Ruler. The bar fills the full panel width (canvas is min 100% wide);
calc(100% - GUTTER) equals trackContentWidth when zoomed in and extends
past the content when zoomed out. Ticks stay at composition coordinates. */}
{/* Ruler — sticky so the timestamps stay visible while the tracks scroll
vertically. Opaque background (plus the gutter corner block) so clips
scrolling underneath don't bleed through; z-index sits above the track
rows and drag overlays but below the playhead (z 100). */}
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
marginLeft: GUTTER,
width: `calc(100% - ${GUTTER}px)`,
background: theme.gutterBackground,
borderBottom: `1px solid ${theme.rulerBorder}`,
}}
className="sticky top-0 flex"
style={{ height: RULER_H, width: GUTTER + trackContentWidth, zIndex: 70 }}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
<div
className="sticky left-0 z-[12] flex-shrink-0"
style={{
width: GUTTER,
// Ruler corner uses the panel surface — same as the ruler strip itself.
background: theme.shellBackground,
borderRight: `1px solid ${theme.gutterBorder}`,
}}
/>
<div
className="relative overflow-hidden"
style={{
height: RULER_H,
width: trackContentWidth,
// Ruler background = panel surface (#0A0A0B) — no bottom border,
// no tick lines (CapCut-style clean ruler, labels only).
background: theme.shellBackground,
}}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-2" style={{ background: theme.tickMinor }} />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
color: theme.tickText,
left: 5,
top: 5,
fontSize: 10,
}}
>
{timeDisplayMode === "frame"
? secondsToFrame(t)
: formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
</div>
))}
</div>
</div>
</>
);
@@ -283,8 +283,9 @@ describe("commitDraggedClipMove", () => {
const map = editMap(onMoveElements.mock.calls[0][0]);
// Lanes are contiguous and distinct (no two overlapping clips share a lane).
expect(new Set([map.a.track, map.b.track, map.c.track])).toEqual(new Set([0, 1, 2]));
// The z-aware normalization may reverse the authored lane numbers, but the
// insert must still leave three distinct, contiguous visual lanes.
expect(map.a.track).toBe(0); // above the insert → unchanged
expect(map.c.track).toBe(1); // dragged clip lands on the new lane
expect(map.b.track).toBe(2); // at/below the insert → +1 shift
});
describe("lane ↔ stacking sync", () => {
@@ -440,9 +441,9 @@ describe("commitDraggedClipMove", () => {
drag(elements[2], { previewStart: 30, previewTrack: 1, insertRow: 0 }),
[0, 1],
);
// Non-overlapping clips retain their authored/z-derived ordering; a lane
// gesture cannot invent a DOM stacking relationship where none overlaps.
expect(lane.dragged).toBe(2);
expect(lane.dragged).toBe(0); // aimed at the very top
expect(lane.top).toBe(1);
expect(lane.mid).toBe(2);
});
it("BETWEEN-insert of a non-overlapping clip lands it between its neighbours", async () => {
@@ -453,8 +454,8 @@ describe("commitDraggedClipMove", () => {
[0, 1, 2],
);
expect(lane.a).toBe(0);
expect(lane.b).toBe(1);
expect(lane.x).toBe(2);
expect(lane.x).toBe(1); // between a and b, as aimed
expect(lane.b).toBe(2);
});
it("TOP-insert clears a NON-overlapping clip that currently tops the timeline", async () => {
@@ -466,7 +467,7 @@ describe("commitDraggedClipMove", () => {
drag(elements[2], { previewStart: 10, previewTrack: 2, insertRow: 0 }),
[0, 1, 2],
);
expect(lane.X).toBe(1); // X reorders against overlapping M, not disjoint T
expect(lane.X).toBe(0); // aimed top, cleared the non-overlapping T
});
it("dragging X among overlapping neighbours preserves the RELATIVE order of the others (symptom 2)", async () => {
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
computeRevealScroll,
REVEAL_SCROLL_PADDING_PX,
type RevealScrollInput,
} from "./timelineRevealScroll";
/** A 1000×400 viewport with a 32px sticky gutter and 24px sticky ruler. */
function makeInput(overrides: Partial<RevealScrollInput> = {}): RevealScrollInput {
return {
scrollLeft: 0,
scrollTop: 0,
viewportWidth: 1000,
viewportHeight: 400,
clipLeft: 100,
clipRight: 200,
clipTop: 100,
clipBottom: 148,
stickyLeft: 32,
stickyTop: 24,
allowHorizontal: true,
...overrides,
};
}
describe("computeRevealScroll", () => {
it("returns null on both axes when the clip is fully visible", () => {
expect(computeRevealScroll(makeInput())).toEqual({ left: null, top: null });
});
it("scrolls right minimally when the clip end is past the right edge", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 1500, clipRight: 1600 }));
// Clip end lands padding px inside the right edge.
expect(result.left).toBe(1600 - 1000 + REVEAL_SCROLL_PADDING_PX);
expect(result.top).toBeNull();
});
it("scrolls left when the clip start is hidden (including under the sticky gutter)", () => {
const result = computeRevealScroll(
makeInput({ scrollLeft: 500, clipLeft: 510, clipRight: 610 }),
);
// clipLeft 510 sits under the 32px sticky gutter (window starts at 500+32+pad).
expect(result.left).toBe(510 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("aligns the start edge when the clip is wider than the viewport", () => {
const result = computeRevealScroll(makeInput({ clipLeft: 2000, clipRight: 4000 }));
expect(result.left).toBe(2000 - 32 - REVEAL_SCROLL_PADDING_PX);
});
it("never returns a negative scroll target", () => {
const result = computeRevealScroll(
makeInput({
scrollLeft: 300,
clipLeft: 10,
clipRight: 60,
scrollTop: 200,
clipTop: 4,
clipBottom: 20,
}),
);
expect(result.left).toBe(0);
expect(result.top).toBe(0);
});
it("suppresses horizontal scroll when allowHorizontal is false (fit zoom)", () => {
const result = computeRevealScroll(
makeInput({
allowHorizontal: false,
clipLeft: 1500,
clipRight: 1600,
clipTop: 700,
clipBottom: 748,
}),
);
expect(result.left).toBeNull();
// Vertical reveal still happens.
expect(result.top).toBe(748 - 400 + REVEAL_SCROLL_PADDING_PX);
});
it("scrolls up when the clip lane is hidden under the sticky ruler", () => {
const result = computeRevealScroll(
makeInput({ scrollTop: 200, clipTop: 210, clipBottom: 258 }),
);
// clipTop 210 sits under the 24px sticky ruler (window starts at 200+24+pad).
expect(result.top).toBe(210 - 24 - REVEAL_SCROLL_PADDING_PX);
});
it("scrolls down minimally when the clip lane is below the viewport", () => {
const result = computeRevealScroll(makeInput({ clipTop: 500, clipBottom: 548 }));
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
expect(result.left).toBeNull();
});
});
@@ -0,0 +1,88 @@
/**
* Pure scroll-target math for revealing a timeline clip inside the timeline's
* scroll container (the overflow div in Timeline.tsx).
*
* Coordinates are content-space: a clip edge measured from the scroll
* container's content origin (rect delta + current scroll offset). The visible
* window on each axis is reduced by the sticky chrome that occludes it — the
* track gutter on the left (GUTTER) and the ruler on top (RULER_H) — so a clip
* "hidden" under the sticky gutter still counts as off-screen.
*
* Scrolls minimally: an axis already fully visible returns null for that axis;
* otherwise the nearest edge is brought just inside the window (plus padding).
* A clip larger than the window aligns its start edge. Pure — unit-tested.
*/
export interface RevealScrollInput {
scrollLeft: number;
scrollTop: number;
/** Scroll container clientWidth / clientHeight. */
viewportWidth: number;
viewportHeight: number;
/** Clip bounds in content-space (relative to the scroll content origin). */
clipLeft: number;
clipRight: number;
clipTop: number;
clipBottom: number;
/** Width of the sticky left gutter occluding the viewport's left edge. */
stickyLeft: number;
/** Height of the sticky ruler occluding the viewport's top edge. */
stickyTop: number;
/** False in "fit" zoom mode, where horizontal scrolling is disabled. */
allowHorizontal: boolean;
}
export interface RevealScrollTarget {
/** Target scrollLeft, or null when the horizontal axis needs no scroll. */
left: number | null;
/** Target scrollTop, or null when the vertical axis needs no scroll. */
top: number | null;
}
/** Breathing room between the revealed clip edge and the window edge. */
export const REVEAL_SCROLL_PADDING_PX = 12;
/**
* Minimal scroll on one axis to bring [start, end] inside the visible window
* [scroll + stickyStart, scroll + viewport], with padding. Returns null when
* the range is already fully visible.
*/
function revealAxis(
scroll: number,
viewport: number,
stickyStart: number,
start: number,
end: number,
): number | null {
const windowStart = scroll + stickyStart + REVEAL_SCROLL_PADDING_PX;
const windowEnd = scroll + viewport - REVEAL_SCROLL_PADDING_PX;
if (start >= windowStart && end <= windowEnd) return null;
const windowSize = windowEnd - windowStart;
// Oversized range (or start hidden): align the start edge to the window start.
if (end - start > windowSize || start < windowStart) {
return Math.max(0, start - stickyStart - REVEAL_SCROLL_PADDING_PX);
}
// Only the end is clipped: pull it just inside the window's far edge.
return Math.max(0, end - viewport + REVEAL_SCROLL_PADDING_PX);
}
export function computeRevealScroll(input: RevealScrollInput): RevealScrollTarget {
return {
left: input.allowHorizontal
? revealAxis(
input.scrollLeft,
input.viewportWidth,
input.stickyLeft,
input.clipLeft,
input.clipRight,
)
: null,
top: revealAxis(
input.scrollTop,
input.viewportHeight,
input.stickyTop,
input.clipTop,
input.clipBottom,
),
};
}
@@ -1,7 +1,6 @@
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 };
@@ -29,16 +28,6 @@ function expectZoningIdempotent(input: TimelineElement[]): void {
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");
@@ -66,52 +55,77 @@ describe("classifyZone", () => {
});
});
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.)
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
// img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
// index: the LOWER visual track owns the upper lane. z is irrelevant (absent).
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)
expect(trackOf(out, "img")).toBe(0); // track 0 → top lane
expect(trackOf(out, "vid")).toBe(1); // track 2 → below it
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("compacts sparse visual track-indexes to contiguous lanes, preserving ascending order", () => {
// Distinct authored tracks 0, 3, 7 → three adjacent lanes in the same order.
const out = normalizeToZones([el("a", "video", 7), el("b", "img", 0), el("c", "video", 3)]);
expect(trackOf(out, "b")).toBe(0); // track 0
expect(trackOf(out, "c")).toBe(1); // track 3
expect(trackOf(out, "a")).toBe(2); // track 7
});
it("drops audio below the visual lanes even when it holds a LOWER authored index", () => {
// Audio authored at track 0, video at track 5 — audio must still sink below.
const out = normalizeToZones([zClip("a", 0, 10, 0, 0, "audio"), zClip("v", 0, 10, 5, 0)]);
expect(trackOf(out, "v")).toBe(0); // visual on top
expect(trackOf(out, "a")).toBe(1); // audio below, despite its lower track index
});
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
expect(trackOf(out, "v")).toBe(0);
expect(trackOf(out, "a")).toBe(1);
});
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)]);
it("groups multiple audio tracks at the bottom, ordered by their track index", () => {
const out = normalizeToZones([el("v", "video", 0), el("a2", "audio", 4), el("a1", "audio", 1)]);
expect(trackOf(out, "v")).toBe(0);
expect(trackOf(out, "a1")).toBe(1);
expect(trackOf(out, "a1")).toBe(1); // audio track 1 above audio track 4
expect(trackOf(out, "a2")).toBe(2);
});
it("ignores z-index entirely — a high-z clip does NOT jump above a lower-track clip", () => {
// lo on track 0 with z=1; hi on track 1 with z=99. They fully overlap in time.
// The old z-rank pack lifted hi above lo; the CapCut rule keeps lane = track.
const out = normalizeToZones([zClip("lo", 0, 10, 0, 1), zClip("hi", 0, 10, 1, 99)]);
expect(trackOf(out, "lo")).toBe(0); // track 0 stays on top
expect(trackOf(out, "hi")).toBe(1); // higher z does NOT lift it
});
it("ignores z-index across authored tracks (scattered z, lanes still by track)", () => {
const out = normalizeToZones([
zClip("t0", 0, 10, 0, 3),
zClip("t1", 0, 10, 1, 26),
zClip("t2", 0, 10, 2, 0),
]);
expect(trackOf(out, "t0")).toBe(0);
expect(trackOf(out, "t1")).toBe(1);
expect(trackOf(out, "t2")).toBe(2);
});
it("sequential (non-overlapping) same-track clips share a lane", () => {
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 z
});
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)];
// i on track 0, v on track 1, a (audio) on track 2 — already contiguous, visual
// above audio, so re-zoning is a no-op and the SAME reference comes back.
const input = [el("i", "img", 0), el("v", "video", 1), el("a", "audio", 2)];
expect(normalizeToZones(input)).toBe(input);
});
@@ -125,112 +139,11 @@ describe("normalizeToZones", () => {
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.
it("re-derives identical lanes from fresh objects carrying the same tracks (reload-stable)", () => {
const build = (): TimelineElement[] => [
zClip("hi", 0, 10, 0, 9),
zClip("lo", 0, 10, 0, 1),
zClip("mid", 3, 5, 0, 5),
zClip("lo", 0, 10, 1, 1),
zClip("mid", 3, 5, 2, 5),
];
const first = normalizeToZones(build());
const second = normalizeToZones(build());
@@ -238,188 +151,87 @@ describe("normalizeToZones — reverse z→lane mapping", () => {
});
});
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"),
describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
// a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
// The editor forbids per-track overlap, but a legacy file can carry it; the
// spill orders by stable id (a, b, c) and first-fits: a→lane0, b overlaps a→
// lane1, c fits back on lane0 (no overlap with a).
const clip = (id: string, start: number, duration: number): TimelineElement => ({
id,
tag: "video",
start,
duration,
track: 1,
});
const out = normalizeToZones([clip("a", 0, 5), clip("b", 2, 5), clip("c", 6, 3)]);
expect(trackOf(out, "a")).toBe(0);
expect(trackOf(out, "b")).toBe(1); // overlaps a → adjacent sub-lane
expect(trackOf(out, "c")).toBe(0); // sequential to a → shares lane 0
// No two time-overlapping clips share a lane.
expect(trackOf(out, "a")).not.toBe(trackOf(out, "b"));
// 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);
});
it("spills two fully-overlapping same-track clips by stable id (a above b)", () => {
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);
// Survives re-normalization (stable id tie-break, never the mutated lane).
const twice = normalizeToZones(out);
for (const e of out) expect(trackOf(twice, e.id)).toBe(e.track);
});
});
describe("normalizeToZones — legacy file with scattered z (requirement 6)", () => {
// Mirrors /tmp/hf-fixwave/userproj/index.html: many visual clips on contiguous
// authored tracks (0..17) each carrying an unrelated, scattered inline z-index,
// and audio on the highest tracks (18..). The display must follow the
// track-index, NOT the z, and a well-formed (contiguous, audio-last) legacy file
// must not be re-laned at all — normalize is the identity.
const legacy = (): TimelineElement[] => [
zClip("sub-0", 3, 1.15, 0, 0, "div"), // no explicit z (0)
zClip("cap-hit", 4.51, 1.73, 3, 26, "div"), // scattered z
zClip("cap-send", 5.85, 1.27, 4, 25, "div"),
zClip("avatar", 6.4, 1.148, 1, 26),
zClip("v-opener", 0, 3, 15, 12),
zClip("v-letters", 30.08, 4.39, 5, 25),
zClip("music", 3, 42.95, 18, 10, "audio"),
zClip("vo", 3.2, 10.3, 19, 4, "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("lanes follow the track-index, not the scattered z", () => {
const out = normalizeToZones(legacy());
// Visual tracks 0,1,3,4,5,15 compact to lanes 0..5 in ascending track order —
// z (0,26,25,26,12,25) is ignored.
expect(trackOf(out, "sub-0")).toBe(0); // track 0
expect(trackOf(out, "avatar")).toBe(1); // track 1
expect(trackOf(out, "cap-hit")).toBe(2); // track 3
expect(trackOf(out, "cap-send")).toBe(3); // track 4
expect(trackOf(out, "v-letters")).toBe(4); // track 5
expect(trackOf(out, "v-opener")).toBe(5); // track 15
// Audio stays below every visual lane.
expect(trackOf(out, "music")).toBe(6);
expect(trackOf(out, "vo")).toBe(7);
// The z=26 caption does NOT ride above the z=0 subtitle on track 0.
expect(trackOf(out, "cap-hit")).toBeGreaterThan(trackOf(out, "sub-0"));
});
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("does not rewrite a well-formed (contiguous, audio-last) legacy set — identity", () => {
// Same shape but authored tracks already contiguous 0..7 with audio last.
const input = [
zClip("a", 0, 3, 0, 12),
zClip("b", 0, 3, 1, 26),
zClip("c", 0, 3, 2, 3),
zClip("m", 0, 3, 3, 9, "audio"),
];
// Every clip already sits on its track-index lane, so normalize is a no-op.
expect(normalizeToZones(input)).toBe(input);
});
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.379.37
// ralu image authored track 0, z=0, 6.379.37 (shares track 0 with blue-logo)
// black-logo authored track 1, z=1, 11.9214.92
// video authored track 3, z=2, 0.8420.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.9214.92 ∩ 0.8420.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);
it("is idempotent on the scattered-z legacy shape (no drift on re-discovery)", () => {
expectZoningIdempotent(legacy());
});
});
@@ -3,8 +3,8 @@ 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.
* — canvas 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";
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
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. */
@@ -26,166 +23,105 @@ 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;
/** Deterministic order on the stable clip id (never the mutated lane/track). */
function byStableId(a: TimelineElement, b: TimelineElement): number {
const ka = keyOf(a);
const kb = keyOf(b);
return ka < kb ? -1 : ka > kb ? 1 : 0;
}
/**
* 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.
* Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
* share a lane. Clips are ordered by their STABLE id (a function of the input, not
* of the lane being computed — the historical oscillation bug tie-broke on the
* mutated track) and placed first-fit, so sequential (non-overlapping) clips
* collapse onto a single lane and only genuine time overlaps spill onto adjacent
* sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
* number of lanes used (≥ 1 when non-empty).
*
* 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).
* The editor enforces no per-track time overlap, so the spill only fires on legacy
* files. It is DISPLAY-ONLY — a drag commit persists just the dragged clip, never
* this re-lane — so it never rewrites the source.
*/
function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): 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[],
function packTrackLanes(
clips: TimelineElement[],
base: number,
laneOf: Map<string, number>,
): 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)));
const ordered = [...clips].sort(byStableId);
const lanes: TimelineElement[][] = [];
for (const el of ordered) {
let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
if (sub === -1) {
sub = lanes.length;
lanes.push([]);
}
lanes[sub].push(item);
laneOf.set(keyOf(item.el), base + sub);
lanes[sub].push(el);
laneOf.set(keyOf(el), base + sub);
}
return Math.max(1, lanes.length);
}
/**
* Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
* follow the authored `data-track-index` (ASCENDING; ties by stable id) — NEVER a
* z-rank. Each distinct authored track owns its own lane (in ascending order);
* sequential same-track clips share it; time-overlapping same-track clips spill to
* adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
*
* This REPLACES the old global-z-rank interval pack. That pack ordered visual
* lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
* whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
* product decision is the opposite: a clip's lane is its track, period — z is
* canvas paint order only, and lane assignment must ignore it.
*/
function packZoneLanes(
clips: TimelineElement[],
base: number,
laneOf: Map<string, number>,
): number {
const byTrack = new Map<number, TimelineElement[]>();
for (const el of clips) {
const list = byTrack.get(el.track);
if (list) list.push(el);
else byTrack.set(el.track, [el]);
}
let used = 0;
for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
}
return used;
}
/**
* 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.
* Both zones are packed the SAME way — by authored track-index, ascending (see
* packZoneLanes) — so the timeline's vertical order follows each clip's track and
* nothing else. z-index does not participate in lane assignment (it is canvas
* paint order only; the lane ↔ z stacking sync in timelineStackingSync runs the
* other direction, only on a deliberate vertical edit). Time-overlapping same-track
* clips still split onto separate sub-lanes (legacy files only — the editor forbids
* per-track overlap), and that split is display-only, never persisted.
*
* 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).
* its own output is a fixed point): the lanes it emits are contiguous integers in
* ascending track order, and re-running groups by those same integers unchanged.
*/
export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
if (elements.length === 0) return elements;
const laneOf = new Map<string, number>();
const visual: TimelineElement[] = [];
const audio: TimelineElement[] = [];
for (const el of elements) {
(classifyZone(el) === "audio" ? audio : visual).push(el);
}
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<number, IndexedClip[]>();
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);
}
packZoneLanes(audio, nextLane, laneOf);
let changed = false;
const remapped = elements.map((el) => {
@@ -0,0 +1,56 @@
/**
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
* timeline's scroll container so that clip is visible — horizontally to its
* time and vertically to its lane.
*
* The request is consumed (cleared) whether or not the clip node is found, so
* a stale request can never replay a scroll later. Respects zoom mode: in
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
* only the vertical axis is scrolled there.
*/
import { useEffect } from "react";
import { usePlayerStore } from "../store/playerStore";
import { GUTTER, RULER_H } from "./timelineLayout";
import { computeRevealScroll } from "./timelineRevealScroll";
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
useEffect(() => {
if (!revealRequest) return;
// Consume the request first — reveal is one-shot, even when the clip node
// isn't currently rendered (e.g. drilled into a different composition).
usePlayerStore.getState().clearClipRevealRequest();
const container = scrollRef.current;
if (!container) return;
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
if (!(clip instanceof HTMLElement)) return;
const containerRect = container.getBoundingClientRect();
const clipRect = clip.getBoundingClientRect();
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
const target = computeRevealScroll({
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
viewportWidth: container.clientWidth,
viewportHeight: container.clientHeight,
clipLeft,
clipRight: clipLeft + clipRect.width,
clipTop,
clipBottom: clipTop + clipRect.height,
stickyLeft: GUTTER,
stickyTop: RULER_H,
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
});
if (target.left === null && target.top === null) return;
container.scrollTo({
left: target.left ?? container.scrollLeft,
top: target.top ?? container.scrollTop,
behavior: "smooth",
});
}, [revealRequest, scrollRef]);
}
@@ -45,10 +45,16 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
[zSyncPreviewIframeRef, zSyncActiveCompPath],
);
// NaN (NOT 0) when the element can't be resolved in the preview iframe — a
// nested / unmounted sub-comp node, or one outside the active file. Fabricating
// z=0 would enter computeStackingPatches as a real overlapping neighbour at the
// z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
// clip instead. NaN (rather than null) keeps the return assignable to the
// `(el) => number` reader contract the drag hook / commit deps declare.
const readClipZIndex = useCallback(
(el: TimelineElement): number => {
const node = resolveIframeElement(el);
return node ? readEffectiveZIndex(node) : 0;
return node ? readEffectiveZIndex(node) : Number.NaN;
},
[resolveIframeElement],
);
@@ -71,7 +77,9 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
},
];
});
if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
// Forward the drag-commit's shared coalesce key so the z-reorder history
// entry merges with the lane change's move entry into one undo step.
if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
},
[handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
);
@@ -1,6 +1,16 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
/** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
}
describe("usePlayerStore", () => {
beforeEach(() => {
usePlayerStore.getState().reset();
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
describe("initial state", () => {
it("has correct defaults", () => {
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expectResettableDefaults(state);
expect(state.playbackRate).toBe(1);
expect(state.audioMuted).toBe(false);
expect(state.loopEnabled).toBe(false);
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
});
});
describe("clipRevealRequest", () => {
it("starts null and carries the requested element id", () => {
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
usePlayerStore.getState().requestClipReveal("el-1");
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
});
it("bumps the nonce on repeat requests for the same clip", () => {
usePlayerStore.getState().requestClipReveal("el-1");
const first = usePlayerStore.getState().clipRevealRequest;
usePlayerStore.getState().requestClipReveal("el-1");
const second = usePlayerStore.getState().clipRevealRequest;
expect(second?.nonce).not.toBe(first?.nonce);
});
it("clears via clearClipRevealRequest and on reset", () => {
usePlayerStore.getState().requestClipReveal("el-1");
usePlayerStore.getState().clearClipRevealRequest();
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
usePlayerStore.getState().requestClipReveal("el-2");
usePlayerStore.getState().reset();
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
});
});
describe("reset", () => {
it("resets all state to defaults", () => {
// Mutate everything
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
// Reset
usePlayerStore.getState().reset();
const state = usePlayerStore.getState();
expect(state.isPlaying).toBe(false);
expect(state.currentTime).toBe(0);
expect(state.duration).toBe(0);
expect(state.timelineReady).toBe(false);
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expectResettableDefaults(usePlayerStore.getState());
});
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
@@ -153,6 +153,9 @@ interface PlayerState {
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
timelineSnapEnabled: boolean;
setTimelineSnapEnabled: (enabled: boolean) => void;
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
timeDisplayMode: "time" | "frame";
setTimeDisplayMode: (mode: "time" | "frame") => 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
@@ -210,6 +213,16 @@ interface PlayerState {
requestSeek: (time: number) => void;
clearSeekRequest: () => void;
/**
* Request the timeline to scroll a clip into view (e.g. clicking an
* already-added asset card in the sidebar). Consumed and cleared by
* useTimelineRevealClip. The nonce makes repeat requests for the same
* clip observable so a second click re-reveals after the user scrolls away.
*/
clipRevealRequest: { elementId: string; nonce: number } | null;
requestClipReveal: (elementId: string) => void;
clearClipRevealRequest: () => void;
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
@@ -341,6 +354,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
requestSeek: (time) => set({ requestedSeekTime: time }),
clearSeekRequest: () => set({ requestedSeekTime: null }),
clipRevealRequest: null,
requestClipReveal: (elementId) =>
set((s) => ({
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
})),
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
lintFindingsByElement: new Map(),
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
@@ -416,6 +436,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
set({ timelineSnapEnabled: enabled });
},
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
setTimeDisplayMode: (mode) => {
writeStudioUiPreferences({ timeDisplayMode: mode });
set({ timeDisplayMode: mode });
},
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
set((s) => {
// Already pinned (or the user manually zoomed) — never clobber that.
@@ -522,6 +547,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
activeTool: "select",
selectedKeyframes: new Set(),
selectedElementIds: new Set(),
clipRevealRequest: null,
keyframeCache: new Map(),
beatAnalysis: null,
beatEdits: null,