mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
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:
@@ -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 manifest→element 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[],
|
||||
|
||||
Reference in New Issue
Block a user