fix(studio): address PR #2347 review findings (rounds 1-2)

Review 1 (restore commit):
- asset reveal now clears any open preview overlay (stuck-overlay repro:
  preview on A, click already-added B — A stayed open over the reveal)
- duration readout rolls back on failed persist: captureDurationRollback
  snapshots store + live root data-duration before the optimistic sync and
  restores both in every move/resize/delete/group catch (golden's
  previousDuration pattern)
- asset preview opened during running playback dismisses immediately (the
  RAF loop bypasses the store, so the subscription alone never fired)
- persistTimelineBatchEdit resolves the target (findTagByTarget) before
  treating identical output as a no-op — a mistargeted member now throws
  like the single-element path instead of being silently dropped
- a post-mutation history-fold failure no longer suppresses the preview
  sync: fold errors are surfaced separately and the rewritten script still
  syncs (previously the preview kept stale GSAP positions with no recovery)
- timelineRevealScroll guards degenerate viewports (windowSize <= 0)
- CodeQL: encodeURIComponent(projectId) at all timelineTimingSync fetches

Review 2 (single-source-of-truth pass):
- createTimelineElementFromManifestClip — the one manifest->element
  boundary — now carries authoredTrack and stackingContextId; expanded
  sub-comp children preserve both (authoredTrack in their OWN file's space)
- authoredTrackForLane scopes occupants to the dragged clip's sourceFile
  (a foreign file's authored values are a different coordinate space);
  nearest-same-file-lane offset fallback
- optimistic store updates mirror the persisted track into authoredTrack
  (and roll it back on failure), so consecutive drags before a reload
  resolve from fresh data
- spill sub-lanes: documented decision — dropping onto a spill lane is a
  legitimate same-track join (occupants share the authored track by
  construction); false 'never a lane-move target' docstring rewritten
- single-element fallback persists vertical-only moves (early return now
  requires neither start nor track changed; live DOM patch includes
  data-track-index)
- canonical contextKey helper for stacking-context normalization
- new pipeline test crosses the REAL factory boundary (sparse authored
  tracks -> factory -> expansion -> normalize -> drag commit -> persisted
  attribute), no injected fields
This commit is contained in:
ukimsanov
2026-07-13 16:48:52 -07:00
parent 760b88a6f3
commit a33b3f35e1
23 changed files with 1326 additions and 253 deletions
@@ -232,13 +232,75 @@ describe("commitDraggedClipMove", () => {
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 });
// Store stays in display-lane space, but authoredTrack is refreshed to the
// value just written to the file so a SECOND drag before any reload resolves
// authored tracks from current data, not the stale pre-edit value.
expect(updateElement).toHaveBeenCalledWith("a", { start: 20, track: 1, authoredTrack: 2 });
// ...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("resolves the authored track from occupants of the dragged clip's OWN source file", () => {
// Expanded sub-comp children live on synthetic display lanes next to host
// clips. Their authoredTrack is in THEIR file's coordinate space, so a lane
// occupied by a clip from a DIFFERENT file must never lend its authored
// value. Here lane 1 holds both a host-file clip (authored 12) and a
// same-file sibling (authored 7): the sibling answers.
const child3 = { ...el("c3", 0, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 1, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const hostClip = { ...el("h", 1, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, hostClip];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 1, desiredTrack: 1 }),
{ elements, trackOrder: [0, 1] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3).toEqual({ start: 0, track: 7 });
});
it("never persists the display-lane integer when the target lane has no same-file occupant", () => {
// Reviewer scenario: an expanded child of a SPARSE file (authored tracks 3
// and 7 → display lanes 4 and 5) dragged onto display lane 6, which holds
// only another file's clip. Persisting 6 (the display integer) or 12 (the
// foreign authored value) would corrupt the sparse file; the fallback
// offsets from the NEAREST same-file lane instead: authored 7 at lane 5,
// one lane further down → 8.
const child3 = { ...el("c3", 4, 0, 3), authoredTrack: 3, sourceFile: "scene.html" };
const child7 = { ...el("c7", 5, 10, 3), authoredTrack: 7, sourceFile: "scene.html" };
const foreign = { ...el("f", 6, 20, 3), authoredTrack: 12, sourceFile: "index.html" };
const elements = [child3, child7, foreign];
const { onMoveElements } = runClipMove(
drag(child3, { previewStart: 0, previewTrack: 6, desiredTrack: 6 }),
{ elements, trackOrder: [4, 5, 6] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.c3.track).not.toBe(6); // not the display-lane integer
expect(map.c3.track).not.toBe(12); // not the foreign file's authored value
expect(map.c3).toEqual({ start: 0, track: 8 });
});
it("dropping onto an overlap spill sub-lane persists the base lane's shared authored track", () => {
// Authored track 2 holds two time-overlapping clips, which packTrackLanes
// spills onto display sub-lanes 1 and 2 (both authoredTrack 2). Dropping
// 'a' onto the spill sub-lane (2) is a legitimate same-track join: the
// persisted value is the shared authored track (2), even though the clip
// may re-pack onto a different sub-lane on the next normalize.
const elements = normalizeToZones([
{ ...el("a", 0, 30, 3), authoredTrack: 1 },
{ ...el("b1", 2, 0, 5), authoredTrack: 2 },
{ ...el("b2", 2, 3, 5), authoredTrack: 2 }, // overlaps b1 → spills
]);
expect(elements.map((e) => e.track)).toEqual([0, 1, 2]); // spill happened
const spillLane = elements.find((e) => e.id === "b2")!.track;
const { onMoveElements } = runClipMove(
drag(elements[0], { previewStart: 30, previewTrack: spillLane, desiredTrack: spillLane }),
{ elements, trackOrder: [0, 1, 2] },
);
const map = editMap(onMoveElements.mock.calls[0][0]);
expect(map.a).toEqual({ start: 30, 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.
@@ -121,12 +121,25 @@ function persistMoveEdits(
key: keyOf(e.element),
start: e.element.start,
track: e.element.track,
authoredTrack: e.element.authoredTrack,
}));
const revision = beginTimelineOptimisticGesture(
updateElement,
edits.map((edit) => keyOf(edit.element)),
);
for (const e of edits) updateElement(keyOf(e.element), e.updates);
// The file write below targets `persistTrack` (authored space) when supplied,
// or `updates.track` on a genuine lane write (track insert renumber). Mirror
// that written value into the store's `authoredTrack` so a SECOND drag before
// any reload resolves authored tracks from what the file now says, not stale
// pre-edit data. Pure time-moves leave authoredTrack untouched.
for (const e of edits) {
const writtenTrack =
e.persistTrack ?? (e.updates.track !== e.element.track ? e.updates.track : undefined);
updateElement(
keyOf(e.element),
writtenTrack == null ? e.updates : { ...e.updates, authoredTrack: writtenTrack },
);
}
// 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) =>
@@ -142,7 +155,7 @@ function persistMoveEdits(
(error) => {
for (const p of prev) {
if (isLatestTimelineOptimisticGesture(updateElement, revision, p.key)) {
updateElement(p.key, { start: p.start, track: p.track });
updateElement(p.key, { start: p.start, track: p.track, authoredTrack: p.authoredTrack });
}
}
console.error("[Timeline] Failed to persist clip edits", error);
@@ -157,22 +170,54 @@ 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.
*/
/** Same-source-file predicate: authored track numbers only compare within ONE
* file's coordinate space (an expanded sub-comp child's authoredTrack is in ITS
* file, not the host timeline's). `undefined` means the active composition. */
const sameSourceFile = (a: TimelineElement, b: TimelineElement): boolean =>
(a.sourceFile ?? null) === (b.sourceFile ?? null);
/**
* 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.
* Translate a DISPLAY lane into the AUTHORED (source-file) track to persist for
* `dragged`. Occupants are consulted ONLY from the dragged clip's own source
* file — an occupant from a different file (e.g. an expanded sub-comp child, or
* a host clip next to expanded rows) carries authored values in a different
* coordinate space, and borrowing them would write a foreign file's numbering.
*
* Lane semantics after normalizeToZones: each distinct authored track owns one
* base lane, and time-overlapping same-track clips spill onto adjacent display
* sub-lanes (packTrackLanes). A spill sub-lane IS a legal drop target (Timeline's
* trackOrder lists it): its occupants share the base lane's authored track by
* construction, so the same-file occupant lookup returns that authored track and
* the drop persists as a same-track join. The clip may then DISPLAY on a
* different sub-lane than it was dropped on — the spill re-packs
* deterministically by stable id, first-fit — but the persisted track is
* correct.
*
* Fallbacks when the lane has no same-file occupant (e.g. an expanded child
* dropped on a lane holding only other files' clips — the display-lane integer
* must NOT be persisted into a sparse file):
* 1. Offset from the NEAREST same-file lane: authored(nearest) + lane distance,
* preserving "one lane up = one authored track up" in the clip's own file.
* 2. No same-file peers at all → the lane value itself (single-clip files:
* display and authored spaces coincide for want of any other anchor).
* Edge-created lanes (min-1 / max+1 inserts) route through the insert path,
* never here.
*/
function authoredTrackForLane(
lane: number,
elements: TimelineElement[],
excludeKey: string,
dragged: TimelineElement,
): number {
const occupant = elements.find((e) => e.track === lane && keyOf(e) !== excludeKey);
return occupant ? (occupant.authoredTrack ?? occupant.track) : lane;
const dragKey = keyOf(dragged);
const peers = elements.filter((e) => keyOf(e) !== dragKey && sameSourceFile(e, dragged));
const occupant = peers.find((e) => e.track === lane);
if (occupant) return occupant.authoredTrack ?? occupant.track;
let nearest: TimelineElement | null = null;
for (const p of peers) {
if (!nearest || Math.abs(p.track - lane) < Math.abs(nearest.track - lane)) nearest = p;
}
if (!nearest) return lane;
return (nearest.authoredTrack ?? nearest.track) + (lane - nearest.track);
}
function insertTrackValue(trackOrder: number[], insertRow: number): number {
@@ -283,7 +328,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),
persistTrack: authoredTrackForLane(drag.previewTrack, elements, drag.element),
};
const coalesceKey = isVertical ? `clip-lane-move:${laneChangeGestureSeq++}` : undefined;
@@ -91,4 +91,43 @@ describe("computeRevealScroll", () => {
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
expect(result.left).toBeNull();
});
it("never scrolls on an axis whose visible window is degenerate", () => {
// Horizontal window collapses: 40px viewport minus the 32px gutter and
// 2x12px padding is negative; vertical is intact and still reveals.
const result = computeRevealScroll(
makeInput({
viewportWidth: 40,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(result.left).toBeNull();
expect(result.top).toBe(548 - 400 + REVEAL_SCROLL_PADDING_PX);
// Exactly-zero window (viewport == sticky + 2x padding) is degenerate too.
const zero = computeRevealScroll(
makeInput({
viewportWidth: 32 + 2 * REVEAL_SCROLL_PADDING_PX,
clipLeft: 1500,
clipRight: 1600,
}),
);
expect(zero.left).toBeNull();
// Both axes degenerate: no scroll at all.
const both = computeRevealScroll(
makeInput({
viewportWidth: 10,
viewportHeight: 10,
clipLeft: 1500,
clipRight: 1600,
clipTop: 500,
clipBottom: 548,
}),
);
expect(both).toEqual({ left: null, top: null });
});
});
@@ -56,8 +56,11 @@ function revealAxis(
): 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;
// Degenerate viewport (container smaller than the sticky chrome + padding):
// there is no visible window to reveal into, so never scroll on this axis.
if (windowSize <= 0) return null;
if (start >= windowStart && end <= windowEnd) return null;
// 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);
@@ -69,6 +69,14 @@ export interface StackingPatch {
const EPS = 1e-6;
/**
* Canonical stacking-context key: null/undefined both mean the root context.
* The ONLY place the normalization lives — context partitioning, membership
* checks, and pairwise equality must all go through it.
*/
const contextKey = (el: { stackingContextId?: string | null }): string | null =>
el.stackingContextId ?? null;
/**
* Two clips overlap in time when their half-open [start, end) intervals intersect.
*
@@ -297,10 +305,8 @@ export function computeStackingPatches(
// 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));
const editedContexts = new Set(allResolved.filter((e) => editedSet.has(e.key)).map(contextKey));
const resolved = allResolved.filter((e) => editedContexts.has(contextKey(e)));
// 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 }]));
@@ -320,8 +326,7 @@ 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 sameContext = (a: MutZ, b: MutZ) => contextKey(a) === contextKey(b);
const overlappersOf = (clip: MutZ): MutZ[] =>
all.filter(
(o) => o.key !== clip.key && !o.isAudio && sameContext(clip, o) && overlapsInTime(clip, o),
@@ -0,0 +1,168 @@
import { describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip } from "../lib/playbackTypes";
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
import { buildExpandedElements } from "../hooks/useExpandedTimelineElements";
import { normalizeToZones } from "./timelineZones";
import { commitDraggedClipMove, type TimelineMoveEdit } from "./timelineClipDragCommit";
import type { DraggedClipState } from "./useTimelineClipDrag";
/**
* Pipeline test across the REAL manifestelement boundary: no hand-injected
* `authoredTrack` / `stackingContextId`. A runtime-manifest-shaped payload with
* SPARSE authored tracks flows through createTimelineElementFromManifestClip
* (normalizeToZones | buildChildElements) commitDraggedClipMove, and the
* persisted data-track-index must be the AUTHORED target, never the display lane.
*/
const manifestClip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
id: "x",
label: "x",
start: 0,
duration: 2,
track: 0,
stackingContextId: "root",
kind: "element",
tagName: "div",
compositionId: null,
parentCompositionId: null,
compositionSrc: null,
assetUrl: null,
...over,
});
function fromManifest(clips: ClipManifestClip[]): TimelineElement[] {
return clips.map((clip, index) =>
createTimelineElementFromManifestClip({ clip, fallbackIndex: index }),
);
}
function drag(
element: TimelineElement,
opts: { previewStart: number; previewTrack: number },
): DraggedClipState {
return {
element,
originClientX: 0,
originClientY: 0,
originScrollLeft: 0,
originScrollTop: 0,
pointerClientX: 0,
pointerClientY: 0,
pointerOffsetX: 0,
pointerOffsetY: 0,
previewStart: opts.previewStart,
previewTrack: opts.previewTrack,
desiredTrack: opts.previewTrack,
insertRow: null,
snapTime: null,
snapType: null,
started: true,
};
}
/** Commit a lane-change drag and return the single persisted edit batch. */
function commitLaneChange(
element: TimelineElement,
previewTrack: number,
elements: TimelineElement[],
trackOrder: number[],
): TimelineMoveEdit[] {
const onMoveElements = vi.fn();
commitDraggedClipMove(drag(element, { previewStart: element.start, previewTrack }), {
elements,
trackOrder,
updateElement: vi.fn(),
onMoveElements,
});
expect(onMoveElements).toHaveBeenCalledTimes(1);
return onMoveElements.mock.calls[0][0] as TimelineMoveEdit[];
}
describe("track persist pipeline (manifest → factory → lanes → drag commit)", () => {
// Sparse authored tracks 3 and 7 (mixed kinds), plus audio on 5, exactly as a
// runtime manifest would ship them (clip.track is the verbatim data-track-index).
const sparseManifest = [
manifestClip({ id: "v", kind: "video", tagName: "video", track: 3, start: 0 }),
manifestClip({ id: "g", kind: "element", tagName: "div", track: 7, start: 10 }),
manifestClip({ id: "m", kind: "audio", tagName: "audio", track: 5, start: 0 }),
];
it("factory records the authored track and stacking context from the manifest clip", () => {
const [v, g, m] = fromManifest(sparseManifest);
expect([v.authoredTrack, g.authoredTrack, m.authoredTrack]).toEqual([3, 7, 5]);
expect(v.stackingContextId).toBe("root");
});
it("a lane change on a sparse file persists the AUTHORED target track, not the display lane", () => {
// normalizeToZones packs visual tracks {3, 7} onto display lanes {0, 1} and
// the audio track 5 onto lane 2, preserving the factory-set authoredTrack.
const elements = normalizeToZones(fromManifest(sparseManifest));
const byId = new Map(elements.map((e) => [e.id, e]));
expect(byId.get("v")).toMatchObject({ track: 0, authoredTrack: 3 });
expect(byId.get("g")).toMatchObject({ track: 1, authoredTrack: 7 });
expect(byId.get("m")).toMatchObject({ track: 2, authoredTrack: 5 });
// Drag the video (lane 0) onto the div's lane (display 1, authored 7).
const down = commitLaneChange(byId.get("v")!, 1, elements, [0, 1, 2]);
expect(down).toHaveLength(1);
expect(down[0].updates.track).toBe(7); // authored, NOT display lane 1
expect(down[0].updates.track).not.toBe(1);
// And the reverse: the div (lane 1) onto the video's lane (display 0, authored 3).
const up = commitLaneChange(byId.get("g")!, 0, elements, [0, 1, 2]);
expect(up[0].updates.track).toBe(3); // authored, NOT display lane 0
});
it("an expanded sub-comp child's lane change persists the sibling's authored track from ITS file", () => {
// Host timeline: the sub-comp host plus a root clip, discovered through the
// factory and lane-normalized like the store does.
const hostManifest = [
manifestClip({
id: "scene",
kind: "composition",
tagName: "div",
track: 0,
start: 0,
duration: 10,
compositionId: "scene",
compositionSrc: "scene.html",
}),
manifestClip({ id: "root-clip", kind: "video", tagName: "video", track: 1, start: 0 }),
];
// scene.html has SPARSE authored tracks 3 and 7.
const childClips = [
manifestClip({ id: "c3", track: 3, start: 1, duration: 2, parentCompositionId: "scene" }),
manifestClip({ id: "c7", track: 7, start: 4, duration: 2, parentCompositionId: "scene" }),
];
const storeElements = normalizeToZones(fromManifest(hostManifest));
const parentMap = new Map([
["c3", "scene"],
["c7", "scene"],
]);
const expanded = buildExpandedElements(
storeElements,
[...hostManifest, ...childClips],
parentMap,
"scene",
"scene",
);
// The children replaced the host row: synthetic display lanes, but the
// authored track (in scene.html's coordinate space) survived the expansion.
const c3 = expanded.find((e) => e.domId === "c3")!;
const c7 = expanded.find((e) => e.domId === "c7")!;
expect(c3).toMatchObject({ authoredTrack: 3, sourceFile: "scene.html" });
expect(c7).toMatchObject({ authoredTrack: 7, sourceFile: "scene.html" });
expect(c3.stackingContextId).toBe("root");
expect(c3.track).not.toBe(3); // display row is synthetic
// Drag c3 onto c7's display lane: the persist target is c3's OWN file, so
// the written track must be c7's authored 7 — not the display-lane integer.
const trackOrder = [...new Set(expanded.map((e) => e.track))].sort((a, b) => a - b);
const edits = commitLaneChange(c3, c7.track, expanded, trackOrder);
expect(edits).toHaveLength(1);
expect(edits[0].updates.track).toBe(7);
expect(edits[0].updates.track).not.toBe(c7.track);
});
});
@@ -42,6 +42,14 @@ function byStableId(a: TimelineElement, b: TimelineElement): number {
* 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.
*
* Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
* display lane). Because every occupant of a sub-lane shares the base lane's
* authored track by construction, dropping a clip onto one persists that shared
* authored track a legitimate same-track join. On the next normalize the
* joined track re-packs (stable-id first-fit), so the clip may display on a
* DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
* the persisted source value is correct either way.
*/
function packTrackLanes(
clips: TimelineElement[],