mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
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:
@@ -39,6 +39,44 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(result.clips[0].id).toBe("hf-headline");
|
||||
});
|
||||
|
||||
// Regression: the authored data-track-index must round-trip verbatim, even
|
||||
// when clips of DIFFERENT kinds (video vs element) share a track. The old
|
||||
// mixed-kind renumber split them onto separate tracks, which made the
|
||||
// written track drift from the displayed one on every editor move.
|
||||
it("honors authored track indices verbatim for mixed-kind tracks", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "20");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.id = "clip-video";
|
||||
video.setAttribute("data-start", "1");
|
||||
video.setAttribute("data-duration", "3");
|
||||
video.setAttribute("data-track-index", "1");
|
||||
root.appendChild(video);
|
||||
|
||||
const caption = document.createElement("div");
|
||||
caption.id = "clip-caption";
|
||||
caption.setAttribute("data-start", "8");
|
||||
caption.setAttribute("data-duration", "3");
|
||||
caption.setAttribute("data-track-index", "1");
|
||||
root.appendChild(caption);
|
||||
|
||||
const other = document.createElement("div");
|
||||
other.id = "clip-other";
|
||||
other.setAttribute("data-start", "0");
|
||||
other.setAttribute("data-duration", "3");
|
||||
other.setAttribute("data-track-index", "2");
|
||||
root.appendChild(other);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
const trackOf = (id: string) => result.clips.find((c) => c.id === id)?.track;
|
||||
expect(trackOf("clip-video")).toBe(1);
|
||||
expect(trackOf("clip-caption")).toBe(1);
|
||||
expect(trackOf("clip-other")).toBe(2);
|
||||
});
|
||||
|
||||
it("collects clips from elements with data-start and data-duration", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -52,57 +52,15 @@ function maxDefinedNumber(...values: Array<number | null>): number | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* When multiple content kinds share the same track number, split them
|
||||
* onto separate tracks so the timeline UI shows distinct rows.
|
||||
*
|
||||
* Preferred kind order (top → bottom): composition, video, image, element, audio.
|
||||
* Tracks that contain only one kind are left untouched.
|
||||
* Parse an authored track attribute, honoring 0 (a valid top-lane index).
|
||||
* `parseInt(...) || fallback` silently replaced authored track 0 with the
|
||||
* synthetic fallback, so track-0 clips drifted to the bottom of the timeline.
|
||||
*/
|
||||
const KIND_ORDER: Record<string, number> = {
|
||||
composition: 0,
|
||||
video: 1,
|
||||
image: 2,
|
||||
element: 3,
|
||||
audio: 4,
|
||||
};
|
||||
|
||||
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
|
||||
if (clips.length === 0) return;
|
||||
|
||||
// Group clips by their raw track number and detect which tracks have mixed kinds
|
||||
const trackKinds = new Map<number, Set<string>>();
|
||||
for (const clip of clips) {
|
||||
const kinds = trackKinds.get(clip.track) ?? new Set();
|
||||
kinds.add(clip.kind);
|
||||
trackKinds.set(clip.track, kinds);
|
||||
}
|
||||
|
||||
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
|
||||
if (!hasMixedTracks) return;
|
||||
|
||||
// Build new contiguous track numbers, splitting mixed tracks by kind
|
||||
let nextTrack = 0;
|
||||
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
|
||||
|
||||
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
|
||||
for (const track of sortedTracks) {
|
||||
const kinds = trackKinds.get(track)!;
|
||||
if (kinds.size === 1) {
|
||||
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
|
||||
} else {
|
||||
// Split by kind in preferred order
|
||||
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
|
||||
for (const kind of sorted) {
|
||||
newTrackMap.set(`${track}:${kind}`, nextTrack++);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const clip of clips) {
|
||||
const key = `${clip.track}:${clip.kind}`;
|
||||
const newTrack = newTrackMap.get(key);
|
||||
if (newTrack != null) clip.track = newTrack;
|
||||
}
|
||||
function parseAuthoredTrack(el: Element, fallback: number): number {
|
||||
const raw = el.getAttribute("data-track-index") ?? el.getAttribute("data-track");
|
||||
if (raw == null) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
|
||||
@@ -441,11 +399,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
label: buildTimelineClipLabel(node, kind, clips.length),
|
||||
start,
|
||||
duration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
|
||||
10,
|
||||
) || 0,
|
||||
track: parseAuthoredTrack(node, i),
|
||||
zIndex: readInlineZIndex(node),
|
||||
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
|
||||
kind,
|
||||
@@ -554,11 +508,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.id,
|
||||
start: range.start,
|
||||
duration: clampedDuration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || gsapTrack,
|
||||
track: parseAuthoredTrack(el, gsapTrack),
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
@@ -613,11 +563,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.id,
|
||||
start: 0,
|
||||
duration: clampedDuration,
|
||||
track:
|
||||
Number.parseInt(
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || overlayTrack,
|
||||
track: parseAuthoredTrack(el, overlayTrack),
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
@@ -637,11 +583,12 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Track normalization ────────────────────────────────────────────────
|
||||
// When multiple content kinds (composition, audio, video, …) share the same
|
||||
// data-track-index value, split them onto separate tracks so the timeline UI
|
||||
// shows distinct rows for each kind.
|
||||
normalizeTrackAssignments(clips);
|
||||
// Track assignment honors the authored data-track-index verbatim: a clip stays
|
||||
// on the track it was placed on, regardless of kind. (Previously mixed-kind
|
||||
// tracks were split onto separate rows, but that renumbered tracks — breaking
|
||||
// "drop a clip onto an existing track" and causing the written track to drift
|
||||
// from the displayed one on every move. Track index is display-only; render
|
||||
// never reads it, so honoring it verbatim is the correct NLE behavior.)
|
||||
|
||||
for (const compositionNode of compositionNodes) {
|
||||
if (compositionNode === root) continue;
|
||||
|
||||
Reference in New Issue
Block a user