fix(studio): make vertical lane moves persist correctly and harden the z/lane pipeline

Vertical clip moves committed in the store but never survived: two persist
bugs plus a runtime renumber all fought the stable-track-lanes model.

- timelineMoveAdapter deliberately stripped the track from lane-reorder
  persists ('z-only reorder path' — the old z-driven lane model). Lane =
  authored data-track-index now: lane-reorder and track-insert both persist
  the track; plain timing moves omit it to stay SDK-fast-path eligible.
- Display lanes and file tracks are different coordinate spaces:
  normalizeToZones packs sparse authored tracks (1,2,... or gaps, or DOM-index
  fallbacks) onto contiguous display lanes, and lane edits persisted the LANE
  number — silently re-targeting the wrong row in any non-0-contiguous file.
  Elements now record their authoredTrack when remapped; a lane change
  persists the target lane's authored track (store stays in lane space).
- The runtime split same-track clips of different kinds (video vs caption
  div) onto separate renumbered tracks at discovery, so authored indices
  never round-tripped ('drop onto an existing track' bounced back). Removed:
  data-track-index is honored verbatim (render never reads it); kind-based
  row presentation belongs in the display layer if ever wanted.

Adversarial review fixes on the same pipeline:
- runtime: parseInt(attr) || fallback dropped authored track 0 for GSAP and
  overlay clips (parseAuthoredTrack helper honors 0)
- single-clip move fallback persisted only data-start — lane changes snapped
  back on reload (now passes the track to the patch builder)
- lane-change z-sync candidate ignored a multi-selection's time shift, so
  patches were computed against stale overlap sets
- track insert around a locked clip persisted a colliding renumber (the next
  normalize merged lanes); the insert is now refused with a warning
- computeStackingPatches compared leaf z across CSS stacking contexts, where
  ancestor z decides paint order; the sync now partitions by
  stackingContextId and never patches across contexts

Timeline geometry (user-reported):
- fit zoom leaves 20% trailing headroom (FIT_ZOOM_HEADROOM in
  timelineLayout.ts; single fit-pps source, so ruler/lanes/playhead/drag all
  inherit it)
- playhead line center now sits exactly on GUTTER + t*pps at every zoom
  (wrapper had shrink-wrapped to the 9px diamond, off-centering the line);
  ruler ticks center on their timestamp
- ruler: frame-mode steps snap to whole frames (no duplicate labels), hour
  steps added for far zoom-out, tick positions computed as exact multiples
  (no float drift)
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 54f41b41b6
commit 19139b91ed
19 changed files with 547 additions and 236 deletions
@@ -139,6 +139,26 @@ describe("persistTimelineBatchEdit", () => {
);
}
function moveMember(
id: string,
start: number,
fromTrack: number,
toTrack: number,
): PersistTimelineBatchChange {
return {
element: el({ id, tag: "video", domId: id, start, track: fromTrack }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, start, 5, toTrack),
};
}
async function runBatch(changes: PersistTimelineBatchChange[]) {
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(batchInput(changes, writes));
return writes;
}
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -147,28 +167,12 @@ describe("persistTimelineBatchEdit", () => {
// A track-insert renumber can include a member whose attributes already
// hold the target values — its patch is string-identical. The batch must
// skip it and still persist the members that DID change.
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(
batchInput(
[
{
// no-op: data-start already "1", track already 0
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
},
{
// real change: track 1 -> 2
element: el({ id: "b", tag: "video", domId: "b", start: 2, track: 1 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 2, 5, 2),
},
],
writes,
),
);
const writes = await runBatch([
// no-op: data-start already "1", track already 0
moveMember("a", 1, 0, 0),
// real change: track 1 -> 2
moveMember("b", 2, 1, 2),
]);
expect(writes).toHaveLength(1);
expect(writes[0]![0]).toBe("index.html");
@@ -176,21 +180,7 @@ describe("persistTimelineBatchEdit", () => {
});
it("saves nothing when every member is a no-op", async () => {
stubReadFileContent(SOURCE);
const writes: Array<[string, string]> = [];
await persistTimelineBatchEdit(
batchInput(
[
{
element: el({ id: "a", tag: "video", domId: "a", start: 1, track: 0 }),
buildPatches: (original, target) =>
buildTimelineMoveTimingPatch(original, target, 1, 5, 0),
},
],
writes,
),
);
const writes = await runBatch([moveMember("a", 1, 0, 0)]);
expect(writes).toHaveLength(0);
});
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { persistTimelineMoveEditsAtomically } from "./timelineMoveAdapter";
type MoveArgs = Parameters<typeof persistTimelineMoveEditsAtomically>;
const element = (id: string, track: number): TimelineElement => ({
id,
key: id,
@@ -11,64 +13,66 @@ const element = (id: string, track: number): TimelineElement => ({
track,
});
const twoLaneEdits = (bTrack: number): MoveArgs[0] => [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", bTrack), updates: { start: 3, track: 2 } },
];
const movedPair = (edits: MoveArgs[0]) => [
{ element: edits[0].element, start: 1, track: 1 },
{ element: edits[1].element, start: 3, track: 2 },
];
const runMove = async (edits: MoveArgs[0], coalesceKey: MoveArgs[1], intent: MoveArgs[2]) => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
await persistTimelineMoveEditsAtomically(edits, coalesceKey, intent, {
handleTimelineGroupMove,
});
return handleTimelineGroupMove;
};
describe("persistTimelineMoveEditsAtomically", () => {
it("persists two vertical edits as one group with the gesture coalesce key", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 1), updates: { start: 3, track: 2 } },
];
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
handleTimelineGroupMove,
});
const edits = twoLaneEdits(1);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "track-insert");
expect(handleTimelineGroupMove).toHaveBeenCalledTimes(1);
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
[
{ element: edits[0].element, start: 1, track: 1 },
{ element: edits[1].element, start: 3, track: 2 },
],
{ coalesceKey: "clip-lane-move:7" },
);
});
it("does not persist track attrs for a single z-only lane reorder", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
await persistTimelineMoveEditsAtomically([edit], "clip-lane-move:7", "lane-reorder", {
handleTimelineGroupMove,
});
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("does not persist track attrs for a multi-selection lane drag", async () => {
const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 2), updates: { start: 3, track: 2 } },
];
await persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "lane-reorder", {
handleTimelineGroupMove,
it("omits track attrs for plain timing moves (keeps the SDK fast path eligible)", async () => {
const edit = { element: element("a", 0), updates: { start: 1, track: 0 } };
const handleTimelineGroupMove = await runMove([edit], undefined, "timing");
expect(handleTimelineGroupMove).toHaveBeenCalledWith([{ element: edit.element, start: 1 }], {
coalesceKey: undefined,
});
});
it("persists the track attr for a single lane reorder (stable track lanes)", async () => {
// Lane = authored data-track-index; a vertical move that never hits disk
// snaps back on the next normalize, so the lane change MUST persist.
const edit = { element: element("a", 0), updates: { start: 1, track: 1 } };
const handleTimelineGroupMove = await runMove([edit], "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(
[
{ element: edits[0].element, start: 1 },
{ element: edits[1].element, start: 3 },
],
[{ element: edit.element, start: 1, track: 1 }],
{ coalesceKey: "clip-lane-move:7" },
);
});
it("persists track attrs for a multi-selection lane drag (stable track lanes)", async () => {
const edits = twoLaneEdits(2);
const handleTimelineGroupMove = await runMove(edits, "clip-lane-move:7", "lane-reorder");
expect(handleTimelineGroupMove).toHaveBeenCalledWith(movedPair(edits), {
coalesceKey: "clip-lane-move:7",
});
});
it("rejects without retrying individual members when the atomic batch fails", async () => {
const failure = new Error("batch failed");
const handleTimelineGroupMove = vi.fn().mockRejectedValue(failure);
const edits = [
{ element: element("a", 0), updates: { start: 1, track: 1 } },
{ element: element("b", 1), updates: { start: 3, track: 2 } },
];
await expect(
persistTimelineMoveEditsAtomically(edits, "clip-lane-move:7", "track-insert", {
persistTimelineMoveEditsAtomically(twoLaneEdits(1), "clip-lane-move:7", "track-insert", {
handleTimelineGroupMove,
}),
).rejects.toBe(failure);
@@ -28,9 +28,11 @@ export function persistTimelineMoveEditsAtomically(
edits.map(({ element, updates }) => ({
element,
start: updates.start,
// A single vertical edit is the z-only reorder path. Multi-edit gestures
// are track inserts/ripples and must persist every resulting lane index.
track: operation === "track-insert" ? updates.track : undefined,
// Stable track lanes: a lane is the authored data-track-index, so every
// vertical gesture (lane-reorder AND track-insert) must persist the track;
// z is paint order only and is synced separately. Plain horizontal moves
// ("timing") omit it so they stay eligible for the SDK fast path.
track: operation === "timing" ? undefined : updates.track,
})),
{ coalesceKey },
);
+15 -13
View File
@@ -160,23 +160,27 @@ export function useTimelineEditing({
if (!startChanged) return reorderDone;
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + element.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const buildMovePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
return buildTimelineMoveTimingPatch(original, target, updates.start, element.duration);
// Persist lane changes too — data-start-only writes let reload snap the lane back.
const track = updates.track !== element.track ? updates.track : undefined;
return buildTimelineMoveTimingPatch(
original,
target,
updates.start,
element.duration,
track,
);
};
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
const moveFallback = () =>
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
// Soft-reload with the server's rewritten GSAP script instead of a full
// iframe reload — a timing-only move already patched the DOM + store, so
// swapping the script in place avoids the all-clips flash. Falls back to
// reloadPreview() when the soft path can't apply. (See timelineTimingSync.)
// Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
// DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
finishClipTimingFallback({
iframe: previewIframeRef.current,
reloadPreview,
@@ -249,11 +253,9 @@ export function useTimelineEditing({
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
}
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
// needsExtension gates the SDK path (setTiming can't grow the root duration),
// so read the store BEFORE the readout sync below optimistically updates it.
// needsExtension gates the SDK path (setTiming can't grow the root duration), so read the store BEFORE the readout sync below optimistically updates it.
const needsExtension = extendRootDurationIfNeeded(updates.start + updates.duration);
// Optimistic duration readout: content-driven (grow AND shrink), read from
// the just-patched live DOM. See syncPreviewContentDuration.
// Optimistic duration readout: content-driven (grow AND shrink), from the just-patched live DOM. See syncPreviewContentDuration.
syncPreviewContentDuration(previewIframeRef.current);
const targetPath = element.sourceFile || activeCompPath || "index.html";
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
@@ -77,6 +77,15 @@ function resizeCoalesceKey(changes: readonly TimelineGroupResizeChange[]): strin
return `timeline-group-resize:${changes.map((change) => change.element.hfId ?? change.element.id).join(",")}`;
}
function toSdkTimingChanges<T extends { element: TimelineElement }>(
changes: readonly T[],
timingUpdate: (change: T) => { start: number; duration?: number },
): Array<{ hfId: string; timingUpdate: { start: number; duration?: number } } | null> {
return changes.map((change) =>
change.element.hfId ? { hfId: change.element.hfId, timingUpdate: timingUpdate(change) } : null,
);
}
function resizeHasPlaybackStartAdjustment(change: TimelineGroupResizeChange): boolean {
return (
change.playbackStart != null ||
@@ -225,11 +234,7 @@ export function useTimelineGroupEditing({
await options?.beforeTiming;
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: changes.map((change) =>
change.element.hfId
? { hfId: change.element.hfId, timingUpdate: { start: change.start } }
: null,
),
sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
eligible: changes.every((change) => change.track == null),
needsExtension,
label: "Move timeline clips",
@@ -314,14 +319,10 @@ export function useTimelineGroupEditing({
await options?.beforeTiming;
const handledBySdk = await trySdkBatchPersist({
changes,
sdkChanges: changes.map((change) =>
change.element.hfId
? {
hfId: change.element.hfId,
timingUpdate: { start: change.start, duration: change.duration },
}
: null,
),
sdkChanges: toSdkTimingChanges(changes, (change) => ({
start: change.start,
duration: change.duration,
})),
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
needsExtension,
label: "Resize timeline clips",
@@ -9,6 +9,8 @@
* no matter how far the tracks are scrolled. The head is OUTLINE-only at rest and
* FILLED while the playhead is actively held/scrubbed (`scrubbing`).
*/
import { PLAYHEAD_HEAD_W } from "./timelineLayout";
interface PlayheadIndicatorProps {
/** CSS color, defaults to the HF accent variable */
color?: string;
@@ -31,8 +33,11 @@ export function PlayheadIndicator({
}: PlayheadIndicatorProps) {
// Head chip dimensions — used to compute the centering offset and the
// point where the vertical line starts (so it begins at the head's bottom
// edge rather than running through the hollow diamond center).
const HEAD_W = 9;
// edge rather than running through the hollow diamond center). The width is
// the shared PLAYHEAD_HEAD_W constant: getTimelinePlayheadLeft shifts the
// wrapper by -PLAYHEAD_HEAD_W/2 so the 1px line (centered at 50% of the
// wrapper) lands exactly on GUTTER + time * pps — the ruler ticks' center x.
const HEAD_W = PLAYHEAD_HEAD_W;
const HEAD_H = 9;
// marginTop(1) + HEAD_H = where the line should start.
const HEAD_TOTAL_H = 1 + HEAD_H;
@@ -19,8 +19,10 @@ import {
shouldAutoScrollTimeline,
} from "./Timeline";
import {
FIT_ZOOM_HEADROOM,
GUTTER,
MIN_TIMELINE_EXTENT_S,
PLAYHEAD_HEAD_W,
RULER_H,
TRACK_H,
getTimelineDisplayContentWidth,
@@ -327,6 +329,60 @@ describe("generateTicks", () => {
const { major } = generateTicks(180, 80);
expect(major[1] - major[0]).toBe(2);
});
it("picks 'nice' NLE steps across zoom levels (no 7s-style intervals)", () => {
// step = first nice interval whose px spacing >= 88 at that pps.
const cases: Array<[number, number]> = [
[2, 60], // 60s * 2pps = 120px
[10, 10], // 10s * 10pps = 100px
[20, 5], // 5s * 20pps = 100px
[50, 2], // 2s * 50pps = 100px
[100, 1], // 1s * 100pps = 100px
];
for (const [pps, expected] of cases) {
const { major } = generateTicks(600, pps);
expect(major[1] - major[0]).toBe(expected);
}
});
it("uses minute/hour steps when zoomed far out instead of colliding 10m labels", () => {
// 0.05 pps → 600s step would be 30px apart (labels collide); 1800s = 90px.
const { major } = generateTicks(7200, 0.05);
expect(major[1] - major[0]).toBe(1800);
expect(major).toContain(3600);
});
it("does not drift on long rulers (ticks are exact multiples of the step)", () => {
const { major } = generateTicks(600, 100); // 1s step, 601 ticks
expect(major[599]).toBe(599);
});
describe("frame display mode (frameRate provided)", () => {
it("snaps sub-frame steps up to one whole frame (no duplicate frame labels)", () => {
// 4400 pps would pick a 0.02s step = 0.6 frames at 30fps → snapped to 1 frame.
const { major } = generateTicks(2, 4400, 30);
const frames = major.map((t) => Math.round(t * 30));
// Frame labels are consecutive integers — no duplicates, no gaps.
frames.forEach((f, i) => expect(f).toBe(i));
});
it("keeps major AND minor ticks on whole frames", () => {
// 200 pps → 0.5s step (15 frames); quarters (3.75f) are rejected in
// frame mode in favour of fifths (3f).
const { major, minor } = generateTicks(20, 200, 30);
expect(major[1]).toBeCloseTo(0.5);
expect(minor).toContain(0.1); // 3 frames
for (const t of [...major, ...minor]) {
const frames = t * 30;
expect(Math.abs(frames - Math.round(frames))).toBeLessThan(1e-3);
}
});
it("leaves whole-second steps unchanged", () => {
const { major } = generateTicks(60, 100, 30);
expect(major[1] - major[0]).toBe(1);
});
});
});
describe("formatTime", () => {
@@ -397,19 +453,33 @@ describe("shouldAutoScrollTimeline", () => {
});
});
describe("getTimelineFitPps (min 60s extent)", () => {
describe("getTimelineFitPps (min 60s extent + fit headroom)", () => {
const viewport = 632; // usable width = 632 - GUTTER - 2 = 598
it("computes fit pps against the 60s floor for short compositions", () => {
// A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width.
// (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.)
const pps = getTimelineFitPps(viewport, 10);
expect(pps).toBeCloseTo((viewport - GUTTER - 2) / MIN_TIMELINE_EXTENT_S);
expect(10 * pps).toBeCloseTo((viewport - GUTTER - 2) / 6);
});
it("keeps filling the viewport with the composition when it is 60s or longer", () => {
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo((viewport - GUTTER - 2) / 60);
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo((viewport - GUTTER - 2) / 120);
it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => {
expect(getTimelineFitPps(viewport, 60)).toBeCloseTo(
(viewport - GUTTER - 2) / (60 * FIT_ZOOM_HEADROOM),
);
expect(getTimelineFitPps(viewport, 120)).toBeCloseTo(
(viewport - GUTTER - 2) / (120 * FIT_ZOOM_HEADROOM),
);
});
it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => {
const usable = viewport - GUTTER - 2;
const pps = getTimelineFitPps(viewport, 120);
// Composition content occupies usable/1.2 px; the remaining ~17% is empty
// droppable ruler/lane surface past the end.
expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM);
expect(120 * pps).toBeLessThan(usable);
});
it("falls back to 100 pps before the viewport is measured", () => {
@@ -529,13 +599,20 @@ describe("getTimelineScrollLeftForZoomAnchor", () => {
});
describe("getTimelinePlayheadLeft", () => {
it("converts time to a pixel offset from the gutter", () => {
expect(getTimelinePlayheadLeft(4, 20)).toBe(112);
it("offsets the wrapper by half the head width so the line CENTER = GUTTER + t*pps", () => {
// Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must
// equal GUTTER + t*pps at any zoom.
expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20);
expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75);
});
it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => {
expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER);
});
it("guards invalid input", () => {
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(32);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2);
});
});
@@ -26,6 +26,7 @@ import {
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
} from "./timelineLayout";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
import type { TimelineProps } from "./TimelineTypes";
@@ -95,6 +96,7 @@ export const Timeline = memo(function Timeline({
[beatAnalysis, musicElement, beatEdits],
);
const duration = usePlayerStore((s) => s.duration);
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
@@ -156,11 +158,8 @@ export const Timeline = memo(function Timeline({
containerRef.current = el;
}, []);
// Last horizontal scroll offset, tracked so it can be RESTORED across the
// post-edit iframe reload: an edit re-derives elements (and may shrink the
// content width), which the browser clamps into a scroll jump. Paired with the
// pinned zoom (which keeps pps constant so the pixel offset stays meaningful),
// restoring this keeps the user parked at the same spot after any edit.
// Last horizontal scroll offset, RESTORED across the post-edit iframe reload (which clamps into
// a scroll jump); with the pinned zoom this keeps the user parked at the same spot after edits.
const lastScrollLeftRef = useRef(0);
const setScrollRef = useCallback(
(el: HTMLDivElement | null) => {
@@ -395,9 +394,11 @@ export const Timeline = memo(function Timeline({
}
});
// Frame display mode labels ruler ticks as frame numbers — pass the fps so ticks snap to frames.
const tickFps = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
const { major, minor } = useMemo(
() => generateTicks(displayDuration, pps),
[displayDuration, pps],
() => generateTicks(displayDuration, pps, tickFps),
[displayDuration, pps, tickFps],
);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
@@ -526,8 +527,7 @@ export const Timeline = memo(function Timeline({
const elKey = el.key ?? el.id;
setSelectedElementId(elKey);
onSelectElement?.(el);
// Visually select the clicked diamond (matches shift-click / motion-path
// selection); cleared above so this single-selects it.
// Select the clicked diamond (matches shift-click); cleared above so this single-selects.
toggleSelectedKeyframe(`${elKey}:${pct}`);
const absTime = el.start + (pct / 100) * el.duration;
onSeek?.(absTime);
@@ -10,6 +10,8 @@ import {
CLIP_Y,
TRACKS_TOP_PAD,
TRACKS_BOTTOM_PAD,
PLAYHEAD_HEAD_W,
getTimelinePlayheadLeft,
getTimelineRowTop,
} from "./timelineLayout";
import { usePlayerStore } from "../store/playerStore";
@@ -262,12 +264,16 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
)}
{/* Playhead — hidden while dragging a beat so its guideline doesn't
track the scrub and clutter the beat being moved. */}
track the scrub and clutter the beat being moved. Explicit width +
the half-head offset baked into getTimelinePlayheadLeft keep the
inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler
ticks' center), instead of relying on shrink-wrap sizing. */}
<div
ref={props.playheadRef}
className="absolute top-0 bottom-0 pointer-events-none"
style={{
left: `${GUTTER}px`,
left: `${getTimelinePlayheadLeft(0, 0)}px`,
width: PLAYHEAD_HEAD_W,
zIndex: 100,
display: beatDragging ? "none" : undefined,
}}
@@ -95,14 +95,18 @@ export const TimelineRuler = memo(function TimelineRuler({
background: theme.shellBackground,
}}
>
{/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
t * pps — matching the playhead line, which is also centered on
GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
a tick spans [x, x+1) and its center is half a pixel right. */}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
<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 }}>
<div key={`M-${t}`} className="absolute top-0" style={{ left: t * pps - 0.5 }}>
<span
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
style={{
@@ -219,6 +219,26 @@ describe("commitDraggedClipMove", () => {
expect(map.b).toBeUndefined(); // the other clip is NOT rewritten
});
it("persists a lane change in AUTHORED track space when the file is sparse", () => {
// Discovery normalized authored tracks {1, 2} to display lanes {0, 1}
// (authoredTrack records the file value). Moving 'a' onto b's lane must
// write b's AUTHORED track (2) — writing the display lane (1) would target
// a's own authored row and silently no-op in the file.
const elements = [
{ ...el("a", 0, 0, 3), authoredTrack: 1 },
{ ...el("b", 1, 10, 3), authoredTrack: 2 },
];
const { updateElement, onMoveElements } = runClipMove(
drag(elements[0], { previewStart: 20, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
// Store stays in display-lane space...
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1 });
// ...while the persist is translated to the target lane's authored track.
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 20, track: 2 });
});
it("multi-selection time-move shifts EVERY selected clip by the drag delta (atomic)", () => {
const elements = [el("a", 0, 2, 3), el("b", 1, 10, 3), el("c", 2, 20, 3)];
// Drag 'a' +5s on its own lane while {a, b} are marquee-selected.
@@ -13,6 +13,13 @@ type StartTrack = Pick<TimelineElement, "start" | "track">;
export interface TimelineMoveEdit {
element: TimelineElement;
updates: StartTrack;
/**
* File-space track override for the persist. The store's `updates.track` is a
* DISPLAY lane; when the source file's numbering is sparse (authored tracks
* 1,2,... or gaps), the file write must target the lane's AUTHORED track or it
* silently re-targets the wrong row. Omitted persist `updates.track` as-is.
*/
persistTrack?: number;
}
export interface DragCommitDeps {
@@ -120,9 +127,16 @@ function persistMoveEdits(
edits.map((edit) => keyOf(edit.element)),
);
for (const e of edits) updateElement(keyOf(e.element), e.updates);
// The store above gets DISPLAY lanes; the file below gets the authored-space
// track when one was resolved (see TimelineMoveEdit.persistTrack).
const persistEdits = edits.map((e) =>
e.persistTrack == null || e.persistTrack === e.updates.track
? e
: { element: e.element, updates: { ...e.updates, track: e.persistTrack } },
);
const persisted = onMoveElements
? onMoveElements(edits, coalesceKey, operation)
: Promise.all(edits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
? onMoveElements(persistEdits, coalesceKey, operation)
: Promise.all(persistEdits.map((e) => Promise.resolve(onMoveElement?.(e.element, e.updates))));
return Promise.resolve(persisted).then(
() => true,
(error) => {
@@ -143,6 +157,24 @@ function persistMoveEdits(
* then compacts it to a distinct integer lane between its neighbours, and the
* clips at/below the insert shift down by one the sanctioned index-renumber.
*/
/**
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist.
* The lane's occupants all share one authored track by construction (lane =
* authored track after normalizeToZones; overlap sub-lane spills are display-only
* and never a lane-move target), so any occupant answers. A lane with no other
* occupant falls back to the lane value itself for already-contiguous files the
* two spaces coincide, and edge-created lanes (min-1 / max+1) route through the
* insert path, never here.
*/
function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
excludeKey: string,
): number {
const occupant = elements.find((e) => e.track === lane && keyOf(e) !== excludeKey);
return occupant ? (occupant.authoredTrack ?? occupant.track) : lane;
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
if (trackOrder.length === 0) return 0;
if (insertRow <= 0) return trackOrder[0] - 0.5;
@@ -251,6 +283,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
const dragEdit: TimelineMoveEdit = {
element: drag.element,
updates: { start: drag.previewStart, track: drag.previewTrack },
persistTrack: authoredTrackForLane(drag.previewTrack, elements, dragKey),
};
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
@@ -265,9 +298,13 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
// The drop-intent set for the z-sync: the dragged clip at its new lane, others
// as-is. Reasoning on this (not a re-normalize) keeps the sync seeing the user's
// move; computeStackingPatches only compares lanes relatively.
const candidate = elements.map((e) =>
keyOf(e) === dragKey ? { ...e, start: drag.previewStart, track: drag.previewTrack } : e,
);
const candidate = elements.map((e) => {
if (keyOf(e) === dragKey) return { ...e, start: drag.previewStart, track: drag.previewTrack };
// Selection members shift in time with the drag — the z-sync must reason on
// their POST-move overlap sets, same as the insert branch's candidate.
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
return e;
});
const multiKeys = multi ? multi.keys : null;
void persistMoveEdits(edits, deps, coalesceKey, "lane-reorder").then((moved) => {
if (moved && isVertical) {
@@ -292,6 +329,7 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
* The whole affected set is persisted atomically (single undo), and the deliberate
* vertical move syncs the dragged clip's stacking afterwards.
*/
// fallow-ignore-next-line complexity
function commitTrackInsert(
drag: DraggedClipState,
deps: DragCommitDeps,
@@ -314,12 +352,25 @@ function commitTrackInsert(
// shifts the at/below clips down by one — the sanctioned +1 index renumber.
const normalized = normalizeToZones(candidate);
const bySrc = new Map(elements.map((e) => [keyOf(e), e]));
// The renumber is only correct as a WHOLE-SET write: skipping an unwritable
// clip whose lane shifts leaves its track colliding with a renumbered
// neighbour, and the next normalize merges the two lanes. If any shifted clip
// can't be written, refuse the insert instead of persisting a broken layout.
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (src && !canMoveElement(src) && norm.track !== src.track) {
console.warn(
`[Timeline] Track insert refused: locked clip ${keyOf(src)} would need renumbering`,
);
return;
}
}
const edits: TimelineMoveEdit[] = [];
for (const norm of normalized) {
const src = bySrc.get(keyOf(norm));
if (!src) continue;
// Capabilities gate: never write a locked/implicit clip, even one only swept
// along by the renumber (not just a marquee member).
// Capabilities gate (unchanged-lane clips only reach here now): never write
// a locked/implicit clip.
if (!canMoveElement(src)) continue;
const start =
keyOf(norm) === dragKey || multi?.keys.has(keyOf(norm))
@@ -391,6 +442,7 @@ function syncStackingForEdit(
zIndex: readZIndex(el),
isAudio: classifyZone(el) === "audio",
domIndex,
stackingContextId: el.stackingContextId ?? null,
}));
const editedKeys = [dragKey];
@@ -67,51 +67,105 @@ export const DRAG_EXTEND_MARGIN_PX = 160;
* with 60s of ruler after it.
*/
export const MIN_TIMELINE_EXTENT_S = 60;
/**
* Fit-mode headroom (CapCut-style): "fit" maps `duration * 1.2` not the bare
* duration onto the viewport, so the composition ends at ~83% of the width
* and the trailing ~17% stays empty ruler + droppable lane surface (room to
* drag clips past the current end without first zooming out). Applied ONLY
* inside {@link getTimelineFitPps}, the single fit-pps source, so the ruler,
* lanes, playhead, marquee, and drag math all inherit it consistently. Manual
* zoom percentages stay defined relative to this fit basis (100% == fit).
*/
export const FIT_ZOOM_HEADROOM = 1.2;
/* ── Tick generation ──────────────────────────────────────────────── */
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
// fallow-ignore-next-line complexity
function getMajorTickInterval(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
// "Nice" NLE steps: 1-2-5 sub-second decades, then 1s/2s/5s/10s/15s/30s,
// minute multiples, and 15m/30m/1h so ultra-zoomed-out long comps still get
// readable (non-colliding) labels instead of the old 10m fallback everywhere.
const zoomIntervals = [
0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600,
];
let interval: number;
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 88;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
interval =
zoomIntervals.find((candidate) => candidate * (pixelsPerSecond ?? 0) >= targetMajorPx) ??
3600;
} else {
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
interval = durationIntervals.find((candidate) => candidate >= target) ?? 60;
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
// Frame display mode: labels are frame numbers, so a major step must be a
// WHOLE number of frames — sub-frame steps produce duplicate/uneven labels
// (e.g. 0.02s at 30fps is 0.6 frames → "0, 1, 1, 2, 2…"). Snap UP (ceil) so
// the label spacing never drops below the readability target.
if (Number.isFinite(frameRate) && (frameRate ?? 0) > 0) {
const fps = frameRate ?? 0;
return Math.max(1, Math.ceil(interval * fps - 1e-6)) / fps;
}
return interval;
}
// How many equal parts to split each major interval into for minor ticks. Prefer
// quarters (4) so the midpoint stays a minor tick; fall back to halves (2) then
// none (0) as ticks get too dense to read (< ~8px apart).
function getMinorSubdivisions(majorInterval: number, pixelsPerSecond?: number): number {
// none (0) as ticks get too dense to read (< ~8px apart). In frame display mode
// the subdivision must also keep minor ticks on WHOLE frames (a minor tick at a
// sub-frame time is not a seekable position), so only divisors of the major
// step's frame count qualify — quarters, then fifths (15/30-frame majors),
// thirds, halves.
// fallow-ignore-next-line complexity
function getMinorSubdivisions(
majorInterval: number,
pixelsPerSecond?: number,
frameRate?: number,
): number {
const pps = Number.isFinite(pixelsPerSecond) ? (pixelsPerSecond ?? 0) : 0;
if (pps <= 0) return 4; // no zoom info (duration-fit mode): quarter ticks
if ((majorInterval / 4) * pps >= 8) return 4;
if ((majorInterval / 2) * pps >= 8) return 2;
const fps = Number.isFinite(frameRate) ? (frameRate ?? 0) : 0;
const majorFrames = fps > 0 ? Math.round(majorInterval * fps) : 0;
const candidates = fps > 0 ? [4, 5, 3, 2] : [4, 2];
for (const parts of candidates) {
if (fps > 0 && majorFrames % parts !== 0) continue;
if ((majorInterval / parts) * pps >= 8) return parts;
}
return 0;
}
// Ticks are exact multiples of the interval (multiplied per index, never
// accumulated with `+=`, so long rulers don't drift), then rounded to 1µs to
// keep values/keys clean without disturbing frame-exact positions like 2/30s.
function roundTickValue(t: number): number {
return Math.round(t * 1e6) / 1e6;
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
frameRate?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
if (duration <= 0 || !Number.isFinite(duration) || duration > 14400)
return { major: [], minor: [] };
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond);
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond, frameRate);
const subdivisions = getMinorSubdivisions(majorInterval, pixelsPerSecond, frameRate);
const minorInterval = subdivisions > 0 ? majorInterval / subdivisions : 0;
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (let t = 0; t <= duration + 0.001 && major.length < maxTicks; t += majorInterval) {
const rounded = Math.round(t * 100) / 100;
major.push(rounded);
for (let i = 0; major.length < maxTicks; i++) {
const t = i * majorInterval;
if (t > duration + 0.001) break;
major.push(roundTickValue(t));
// Emit the (subdivisions - 1) minor ticks between this major and the next.
for (let k = 1; k < subdivisions && major.length + minor.length < maxTicks; k++) {
const m = Math.round((t + k * minorInterval) * 100) / 100;
if (m <= duration + 0.001) minor.push(m);
const m = t + k * minorInterval;
if (m <= duration + 0.001) minor.push(roundTickValue(m));
}
}
return { major, minor };
@@ -144,15 +198,17 @@ export function formatTimelineTickLabel(time: number, duration: number, majorInt
/* ── Width / duration derivation ──────────────────────────────────── */
/**
* 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.
* Fit-mode pixels-per-second: fill the viewport with the composition plus
* FIT_ZOOM_HEADROOM trailing headroom (CapCut-style the comp never slams
* into the right edge), and 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);
const span = Math.max(safeDuration * FIT_ZOOM_HEADROOM, MIN_TIMELINE_EXTENT_S);
if (!Number.isFinite(viewportWidth) || viewportWidth <= GUTTER) return 100;
return (viewportWidth - GUTTER - 2) / span;
}
@@ -227,9 +283,26 @@ export function getTimelineScrollLeftForZoomAnchor(input: {
}
/* ── Playhead / canvas ────────────────────────────────────────────── */
/**
* Width of the playhead wrapper element (== the diamond head chip's layout
* width, which the wrapper shrink-wraps to). The 1px vertical line inside
* PlayheadIndicator is centered at 50% of this wrapper, so the wrapper must be
* shifted LEFT by half this width for the line's center to land exactly on
* `GUTTER + time * pps` see {@link getTimelinePlayheadLeft}.
*/
export const PLAYHEAD_HEAD_W = 9;
/**
* The `left` for the playhead WRAPPER such that the vertical line's CENTER
* sits exactly on `GUTTER + time * pps` (the same x the ruler ticks center
* on) at every zoom level. Without the half-head offset the line sat
* `PLAYHEAD_HEAD_W / 2` px to the right of its ruler tick.
*/
export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): number {
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) return GUTTER;
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
if (!Number.isFinite(time) || !Number.isFinite(pixelsPerSecond)) {
return GUTTER - PLAYHEAD_HEAD_W / 2;
}
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond) - PLAYHEAD_HEAD_W / 2;
}
export function getTimelineCanvasHeight(trackCount: number): number {
@@ -19,6 +19,66 @@ function patchMap(elements: StackingElement[], edited: string[]): Record<string,
return out;
}
describe("stacking-context partitioning", () => {
it("never compares or patches across stacking contexts", () => {
// X lives in sub-comp context "scene-1" with a high leaf z; Y is a root clip
// with a lower leaf z, overlapping in time. Their leaf z values are NOT
// comparable (the ancestors' z decides paint order), so moving X's lane above
// Y must not reason on Y or patch either based on the 10-vs-5 comparison.
const x: StackingElement = {
key: "x",
track: 0,
start: 0,
duration: 5,
zIndex: 10,
isAudio: false,
stackingContextId: "scene-1",
};
const y: StackingElement = {
key: "y",
track: 1,
start: 0,
duration: 5,
zIndex: 5,
isAudio: false,
stackingContextId: null,
};
// X edited: only same-context neighbours participate — none here, so X keeps
// its z (nothing to fix WITHIN its context) and Y is never touched.
expect(patchMap([x, y], ["x"])).toEqual({});
});
it("still resolves within the edited clip's own context", () => {
const a: StackingElement = {
key: "a",
track: 1,
start: 0,
duration: 5,
zIndex: 1,
isAudio: false,
stackingContextId: "scene-1",
};
const b: StackingElement = {
key: "b",
track: 0,
start: 0,
duration: 5,
zIndex: 5,
isAudio: false,
stackingContextId: "scene-1",
};
// 'a' moved BELOW b's lane... a (track 1) is under b (track 0): a's z (1)
// is already below b's (5) — consistent, no patch. Move a ABOVE (track -1
// relative ordering) is covered by existing suites; here we just prove the
// same-context pair still participates (no patch ≠ no participation: verify
// by flipping z so a MUST be lifted).
const aWrong = { ...a, track: 0, zIndex: 1 };
const bLow = { ...b, track: 1, zIndex: 5 };
const patches = patchMap([aWrong, bLow], ["a"]);
expect(patches.a).toBeGreaterThan(5);
});
});
describe("laneIsAbove", () => {
it("lower track renders above (top of timeline wins)", () => {
expect(laneIsAbove({ track: 0 }, { track: 1 })).toBe(true);
@@ -43,6 +43,13 @@ export interface StackingElement {
zIndex: number;
/** Audio clips have no visual stacking and are excluded from the computation. */
isAudio: boolean;
/**
* CSS stacking context the clip's node lives in (TimelineElement.stackingContextId).
* Leaf z-indexes are only comparable WITHIN one context across contexts the
* ancestors' z decides paint order so the sync partitions by this key and
* never patches across contexts. Null/undefined the root context.
*/
stackingContextId?: string | null;
/**
* 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
@@ -284,7 +291,16 @@ export function computeStackingPatches(
// 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));
const allResolved = elements.filter((e) => Number.isFinite(e.zIndex));
// Leaf z is only meaningful within ONE stacking context: across contexts the
// ancestor contexts' z decides paint order, so comparing (or patching) leaf
// values across contexts is nonsense. Restrict the computation to the edited
// clips' own context(s); cross-context lane relations are out of scope.
const editedContexts = new Set(
allResolved.filter((e) => editedSet.has(e.key)).map((e) => e.stackingContextId ?? null),
);
const resolved = allResolved.filter((e) => editedContexts.has(e.stackingContextId ?? null));
// Mutable z snapshot so edits + cascaded bumps see each other's applied z.
const byKey = new Map<string, MutZ>(resolved.map((e) => [e.key, { ...e }]));
@@ -304,8 +320,12 @@ export function computeStackingPatches(
// 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 sameContext = (a: MutZ, b: MutZ) =>
(a.stackingContextId ?? null) === (b.stackingContextId ?? null);
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter((o) => o.key !== clip.key && !o.isAudio && overlapsInTime(clip, o));
all.filter(
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
);
for (const clip of edited) {
resolveEditedZ(clip, overlappersOf(clip), overlappersOf, patchZ);
@@ -128,7 +128,10 @@ export function normalizeToZones(elements: TimelineElement[]): TimelineElement[]
const lane = laneOf.get(keyOf(el));
if (lane == null || lane === el.track) return el;
changed = true;
return { ...el, track: lane };
// Record the source-file track the first time a clip is remapped so lane
// edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
// Re-normalizing already-remapped elements must keep the original value.
return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
});
return changed ? remapped : elements;
}
@@ -29,6 +29,13 @@ export interface TimelineElement {
start: number;
duration: number;
track: number;
/**
* The data-track-index as written in the source file, when it differs from
* the display lane in `track` (normalizeToZones packs sparse authored tracks
* onto contiguous display lanes). Lane edits must persist THIS space writing
* a display-lane number into a sparse file re-targets the wrong track.
*/
authoredTrack?: number;
/** Resolved z-index for stacking-aware timeline ordering. */
zIndex?: number;
/** True when the effective z-index was authored inline or through CSS, not auto. */