@hyperframes/studio 0.7.56 → 0.7.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
  2. package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
  3. package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
  4. package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
  5. package/dist/assets/index-_pqzyxB1.css +1 -0
  6. package/dist/index.d.ts +26 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +4345 -3812
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
  12. package/src/components/editor/CanvasContextMenu.tsx +33 -18
  13. package/src/components/editor/DomEditOverlay.tsx +31 -62
  14. package/src/components/editor/LayersPanel.tsx +10 -1
  15. package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
  16. package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
  18. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  19. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  20. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  21. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  22. package/src/components/nle/PreviewOverlays.tsx +36 -16
  23. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  24. package/src/components/sidebar/AssetCard.tsx +19 -2
  25. package/src/components/sidebar/AudioRow.tsx +19 -2
  26. package/src/hooks/domEditCommitTypes.ts +12 -1
  27. package/src/hooks/timelineEditingHelpers.test.ts +91 -0
  28. package/src/hooks/timelineEditingHelpers.ts +31 -244
  29. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  30. package/src/hooks/timelineMoveAdapter.ts +5 -3
  31. package/src/hooks/timelineTimingSync.test.ts +150 -0
  32. package/src/hooks/timelineTimingSync.ts +412 -0
  33. package/src/hooks/useCaptionDetection.ts +2 -0
  34. package/src/hooks/useCompositionDimensions.ts +32 -5
  35. package/src/hooks/useDomEditCommits.test.tsx +92 -1
  36. package/src/hooks/useDomEditCommits.ts +63 -1
  37. package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
  38. package/src/hooks/useElementLifecycleOps.ts +20 -7
  39. package/src/hooks/useElementPicker.ts +7 -8
  40. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  41. package/src/hooks/useTimelineEditing.test.tsx +378 -212
  42. package/src/hooks/useTimelineEditing.ts +181 -265
  43. package/src/hooks/useTimelineGroupEditing.ts +153 -132
  44. package/src/player/components/PlayerControls.tsx +5 -5
  45. package/src/player/components/PlayheadIndicator.tsx +7 -2
  46. package/src/player/components/Timeline.test.ts +85 -8
  47. package/src/player/components/Timeline.tsx +9 -9
  48. package/src/player/components/TimelineCanvas.tsx +11 -2
  49. package/src/player/components/TimelineRuler.tsx +58 -48
  50. package/src/player/components/timelineClipDragCommit.test.ts +91 -8
  51. package/src/player/components/timelineClipDragCommit.ts +106 -9
  52. package/src/player/components/timelineLayout.ts +99 -26
  53. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  54. package/src/player/components/timelineRevealScroll.ts +91 -0
  55. package/src/player/components/timelineStackingSync.test.ts +60 -0
  56. package/src/player/components/timelineStackingSync.ts +27 -2
  57. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  58. package/src/player/components/timelineZones.test.ts +122 -310
  59. package/src/player/components/timelineZones.ts +82 -135
  60. package/src/player/components/useTimelineRevealClip.ts +56 -0
  61. package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
  62. package/src/player/components/useTimelineStackingSync.ts +13 -2
  63. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  64. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  65. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  66. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  67. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  68. package/src/player/index.ts +3 -1
  69. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  70. package/src/player/lib/runtimeProtocol.ts +65 -0
  71. package/src/player/lib/timelineDOM.ts +9 -0
  72. package/src/player/lib/timelineElementHelpers.ts +19 -0
  73. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  74. package/src/player/store/playerStore.test.ts +38 -13
  75. package/src/player/store/playerStore.ts +37 -0
  76. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  77. package/src/utils/assetPreviewDismiss.ts +29 -0
  78. package/src/utils/timelineAssetDrop.test.ts +144 -52
  79. package/src/utils/timelineAssetDrop.ts +62 -86
  80. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  81. package/dist/assets/index-D-GyYi2d.css +0 -1
@@ -3,8 +3,8 @@ import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
3
 
4
4
  /**
5
5
  * Free-form vertical zones, top → bottom: visual, audio. There is no "main track"
6
- * — layering is CSS z-index (the renderer ignores track index), so the timeline's
7
- * only job is to keep visual clips grouped above audio clips.
6
+ * — canvas layering is CSS z-index (the renderer ignores track index), so the
7
+ * timeline's only job is to keep visual clips grouped above audio clips.
8
8
  */
9
9
  export type TrackZone = "visual" | "audio";
10
10
 
@@ -16,9 +16,6 @@ export function classifyZone(el: TimelineElement): TrackZone {
16
16
 
17
17
  const keyOf = (el: TimelineElement) => el.key ?? el.id;
18
18
 
19
- /** Stacking order for a clip: missing / "auto" z is treated as 0. */
20
- const zOf = (el: TimelineElement) => (Number.isFinite(el.zIndex) ? (el.zIndex as number) : 0);
21
-
22
19
  const EPS = 1e-6;
23
20
 
24
21
  /** Two clips overlap when their half-open [start, end) intervals intersect. */
@@ -26,173 +23,123 @@ function overlaps(a: TimelineElement, b: TimelineElement): boolean {
26
23
  return a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
27
24
  }
28
25
 
29
- /** A clip paired with its position in the discovery/document (input) order. */
30
- interface IndexedClip {
31
- el: TimelineElement;
32
- /** Index in the input `elements` array = discovery/DOM order. */
33
- domIndex: number;
34
- }
35
-
36
- /** One display lane: the clips packed onto it, in placement order. */
37
- interface Lane {
38
- occupants: IndexedClip[];
39
- /** The single authored track all occupants share, or null once mixed (never
40
- * happens — we only ever add same-track clips to an existing lane). */
41
- track: number;
42
- }
43
-
44
- /**
45
- * Lowest lane index a clip may occupy: strictly above every already-placed lane
46
- * holding a clip it overlaps in time (all of which out-stack it by the z-desc
47
- * placement order).
48
- */
49
- function lowestAllowedLane(lanes: Lane[], item: IndexedClip): number {
50
- let minLane = 0;
51
- for (let i = 0; i < lanes.length; i++) {
52
- if (lanes[i].occupants.some((o) => overlaps(o.el, item.el))) minLane = i + 1;
53
- }
54
- return minLane;
55
- }
56
-
57
- /**
58
- * First lane at index ≥ minLane that holds solely this clip's authored track and
59
- * nothing overlapping (so sequential same-track clips share a lane); -1 when none
60
- * qualifies and a fresh lane must open.
61
- */
62
- function findReusableLane(lanes: Lane[], minLane: number, item: IndexedClip): number {
63
- for (let i = minLane; i < lanes.length; i++) {
64
- const lane = lanes[i];
65
- if (lane.track !== item.el.track) continue;
66
- if (lane.occupants.some((o) => overlaps(o.el, item.el))) continue;
67
- return i;
68
- }
69
- return -1;
26
+ /** Deterministic order on the stable clip id (never the mutated lane/track). */
27
+ function byStableId(a: TimelineElement, b: TimelineElement): number {
28
+ const ka = keyOf(a);
29
+ const kb = keyOf(b);
30
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
70
31
  }
71
32
 
72
33
  /**
73
- * Pack a WHOLE zone's clips onto display lanes with a single constrained pass so
74
- * that, for EVERY pair of time-overlapping clips, lane order (upper = lower index)
75
- * equals canvas stacking order. This replaces the old two-stage
76
- * `orderTrackBlocksByZ` + per-track `packTrackLanes`, which ordered whole authored
77
- * tracks by their MAX z and so lifted a low-z clip above a clip that covers it
78
- * whenever it shared a track with a high-z clip (the qa-clean ralu/video bug — a
79
- * low-z image rode its z=3 trackmate above the z=2 video that paints over it). No
80
- * whole-track mapping can fix that; the mapping must be per-clip.
34
+ * Pack ONE authored track's clips onto sub-lanes so no two time-overlapping clips
35
+ * share a lane. Clips are ordered by their STABLE id (a function of the input, not
36
+ * of the lane being computed the historical oscillation bug tie-broke on the
37
+ * mutated track) and placed first-fit, so sequential (non-overlapping) clips
38
+ * collapse onto a single lane and only genuine time overlaps spill onto adjacent
39
+ * sub-lanes. Writes each clip's absolute display lane into `laneOf`; returns the
40
+ * number of lanes used (≥ 1 when non-empty).
81
41
  *
82
- * Algorithm:
83
- * 1. Order clips by z DESC; z-tie INPUT-ARRAY-INDEX (DOM order) DESC (CSS
84
- * paints equal-z siblings by DOM order LATER in DOM paints on top, so it
85
- * must place first / upper); final tie → stable key. NEVER tie-break on the
86
- * mutated lane/track index (historic oscillation bug — the tie-break must be
87
- * a stable function of the input, not of the output being computed).
88
- * 2. Place each clip at lane ≥ (1 + highest lane among already-placed clips it
89
- * OVERLAPS IN TIME). By z-desc placement every already-placed overlapping
90
- * clip out-stacks this one (higher z, or equal z but later in DOM), so this
91
- * guarantees lane order == stacking order for every overlapping pair.
92
- * 3. To preserve the "distinct authored tracks stay distinct / sequential
93
- * same-track clips share a lane" feel, reuse an existing lane at index ≥ that
94
- * minimum ONLY when the lane's occupants are all from the SAME authored track
95
- * AND none overlaps this clip in time; otherwise open a fresh lane.
42
+ * The editor enforces no per-track time overlap, so the spill only fires on legacy
43
+ * files. It is DISPLAY-ONLY a drag commit persists just the dragged clip, never
44
+ * this re-laneso it never rewrites the source.
96
45
  *
97
- * Writes each clip's absolute display lane (`base + laneIndex`) into `laneOf` and
98
- * returns the number of lanes used (≥ 1 when non-empty).
46
+ * Spill sub-lanes ARE legal drop targets (Timeline's trackOrder lists every
47
+ * display lane). Because every occupant of a sub-lane shares the base lane's
48
+ * authored track by construction, dropping a clip onto one persists that shared
49
+ * authored track — a legitimate same-track join. On the next normalize the
50
+ * joined track re-packs (stable-id first-fit), so the clip may display on a
51
+ * DIFFERENT sub-lane than it was dropped on; the packing is deterministic, and
52
+ * the persisted source value is correct either way.
99
53
  */
100
- function packZoneLanes(clips: IndexedClip[], base: number, laneOf: Map<string, number>): number {
101
- const ordered = [...clips].sort(
102
- (a, b) =>
103
- zOf(b.el) - zOf(a.el) || b.domIndex - a.domIndex || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
104
- );
105
- const lanes: Lane[] = [];
106
- for (const item of ordered) {
107
- const minLane = lowestAllowedLane(lanes, item);
108
- let placed = findReusableLane(lanes, minLane, item);
109
- if (placed === -1) {
110
- placed = lanes.length;
111
- lanes.push({ occupants: [], track: item.el.track });
54
+ function packTrackLanes(
55
+ clips: TimelineElement[],
56
+ base: number,
57
+ laneOf: Map<string, number>,
58
+ ): number {
59
+ const ordered = [...clips].sort(byStableId);
60
+ const lanes: TimelineElement[][] = [];
61
+ for (const el of ordered) {
62
+ let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
63
+ if (sub === -1) {
64
+ sub = lanes.length;
65
+ lanes.push([]);
112
66
  }
113
- lanes[placed].occupants.push(item);
114
- laneOf.set(keyOf(item.el), base + placed);
67
+ lanes[sub].push(el);
68
+ laneOf.set(keyOf(el), base + sub);
115
69
  }
116
- return lanes.length;
70
+ return Math.max(1, lanes.length);
117
71
  }
118
72
 
119
73
  /**
120
- * Legacy per-track interval packing for the AUDIO zone (no z semantics): pack one
121
- * authored track's clips onto sub-lanes so no two overlap in time sequential
122
- * clips share a lane, overlapping ones spill onto the next (first-fit). Ordered by
123
- * start (then stable key) so the layout is deterministic and idempotent. Returns
124
- * the number of lanes used (≥ 1 when non-empty).
74
+ * Pack a whole zone's clips onto contiguous display lanes, CapCut-stable: lanes
75
+ * follow the authored `data-track-index` (ASCENDING; ties by stable id)NEVER a
76
+ * z-rank. Each distinct authored track owns its own lane (in ascending order);
77
+ * sequential same-track clips share it; time-overlapping same-track clips spill to
78
+ * adjacent sub-lanes (packTrackLanes). Returns the number of lanes used.
79
+ *
80
+ * This REPLACES the old global-z-rank interval pack. That pack ordered visual
81
+ * lanes by z-index and interval-packed overlaps, so editing one clip's z (or the
82
+ * whole-set re-pack a lane drag triggered) silently re-laned OTHER clips. The
83
+ * product decision is the opposite: a clip's lane is its track, period — z is
84
+ * canvas paint order only, and lane assignment must ignore it.
125
85
  */
126
- function packAudioTrackLanes(
127
- clips: IndexedClip[],
86
+ function packZoneLanes(
87
+ clips: TimelineElement[],
128
88
  base: number,
129
89
  laneOf: Map<string, number>,
130
90
  ): number {
131
- const ordered = [...clips].sort(
132
- (a, b) => a.el.start - b.el.start || (keyOf(a.el) < keyOf(b.el) ? -1 : 1),
133
- );
134
- const lanes: IndexedClip[][] = [];
135
- for (const item of ordered) {
136
- let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o.el, item.el)));
137
- if (sub === -1) {
138
- sub = lanes.length;
139
- lanes.push([]);
140
- }
141
- lanes[sub].push(item);
142
- laneOf.set(keyOf(item.el), base + sub);
91
+ const byTrack = new Map<number, TimelineElement[]>();
92
+ for (const el of clips) {
93
+ const list = byTrack.get(el.track);
94
+ if (list) list.push(el);
95
+ else byTrack.set(el.track, [el]);
143
96
  }
144
- return Math.max(1, lanes.length);
97
+ let used = 0;
98
+ for (const track of [...byTrack.keys()].sort((a, b) => a - b)) {
99
+ used += packTrackLanes(byTrack.get(track)!, base + used, laneOf);
100
+ }
101
+ return used;
145
102
  }
146
103
 
147
104
  /**
148
105
  * Assign display lanes for the timeline: visual lanes on top, audio lanes below.
149
106
  *
150
- * The VISUAL zone is packed per-clip (packZoneLanes) so the timeline's vertical
151
- * order matches the canvas's CSS stacking for EVERY time-overlapping pair — a
152
- * low-z clip sinks below a clip that covers it even if it shares an authored track
153
- * with a higher-z clip. Time-overlapping clips still split onto separate lanes
154
- * (standard NLE), sequential same-track clips still share a lane, and distinct
155
- * authored tracks stay distinct.
156
- *
157
- * The AUDIO zone keeps the original behavior — authored-track order, per-track
158
- * interval packing — because audio has no z / stacking semantics.
107
+ * Both zones are packed the SAME way by authored track-index, ascending (see
108
+ * packZoneLanes) so the timeline's vertical order follows each clip's track and
109
+ * nothing else. z-index does not participate in lane assignment (it is canvas
110
+ * paint order only; the lane z stacking sync in timelineStackingSync runs the
111
+ * other direction, only on a deliberate vertical edit). Time-overlapping same-track
112
+ * clips still split onto separate sub-lanes (legacy files only — the editor forbids
113
+ * per-track overlap), and that split is display-only, never persisted.
159
114
  *
160
115
  * Pure — returns a new array; unchanged clips keep their identity. Display-only
161
116
  * (runs on discovery); it does not rewrite the source. Idempotent (running it on
162
- * its own output is a fixed point).
117
+ * its own output is a fixed point): the lanes it emits are contiguous integers in
118
+ * ascending track order, and re-running groups by those same integers unchanged.
163
119
  */
164
120
  export function normalizeToZones(elements: TimelineElement[]): TimelineElement[] {
165
121
  if (elements.length === 0) return elements;
166
122
 
167
123
  const laneOf = new Map<string, number>();
168
- let nextLane = 0;
169
-
170
- const visual: IndexedClip[] = [];
171
- const audio: IndexedClip[] = [];
172
- elements.forEach((el, domIndex) => {
173
- (classifyZone(el) === "audio" ? audio : visual).push({ el, domIndex });
174
- });
124
+ const visual: TimelineElement[] = [];
125
+ const audio: TimelineElement[] = [];
126
+ for (const el of elements) {
127
+ (classifyZone(el) === "audio" ? audio : visual).push(el);
128
+ }
175
129
 
130
+ let nextLane = 0;
176
131
  nextLane += packZoneLanes(visual, nextLane, laneOf);
177
-
178
- // Audio: preserve legacy behavior — group by authored track (ascending), pack
179
- // each track's overlapping clips onto sub-lanes.
180
- const audioByTrack = new Map<number, IndexedClip[]>();
181
- for (const item of audio) {
182
- const list = audioByTrack.get(item.el.track);
183
- if (list) list.push(item);
184
- else audioByTrack.set(item.el.track, [item]);
185
- }
186
- for (const track of [...audioByTrack.keys()].sort((a, b) => a - b)) {
187
- nextLane += packAudioTrackLanes(audioByTrack.get(track)!, nextLane, laneOf);
188
- }
132
+ packZoneLanes(audio, nextLane, laneOf);
189
133
 
190
134
  let changed = false;
191
135
  const remapped = elements.map((el) => {
192
136
  const lane = laneOf.get(keyOf(el));
193
137
  if (lane == null || lane === el.track) return el;
194
138
  changed = true;
195
- return { ...el, track: lane };
139
+ // Record the source-file track the first time a clip is remapped so lane
140
+ // edits can persist in AUTHORED space (see TimelineElement.authoredTrack).
141
+ // Re-normalizing already-remapped elements must keep the original value.
142
+ return { ...el, track: lane, authoredTrack: el.authoredTrack ?? el.track };
196
143
  });
197
144
  return changed ? remapped : elements;
198
145
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Consumes playerStore.clipRevealRequest: when another surface (the sidebar
3
+ * asset card / audio row) asks for a clip to be revealed, smooth-scroll the
4
+ * timeline's scroll container so that clip is visible — horizontally to its
5
+ * time and vertically to its lane.
6
+ *
7
+ * The request is consumed (cleared) whether or not the clip node is found, so
8
+ * a stale request can never replay a scroll later. Respects zoom mode: in
9
+ * "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
10
+ * only the vertical axis is scrolled there.
11
+ */
12
+ import { useEffect } from "react";
13
+ import { usePlayerStore } from "../store/playerStore";
14
+ import { GUTTER, RULER_H } from "./timelineLayout";
15
+ import { computeRevealScroll } from "./timelineRevealScroll";
16
+
17
+ export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
18
+ const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
19
+
20
+ useEffect(() => {
21
+ if (!revealRequest) return;
22
+ // Consume the request first — reveal is one-shot, even when the clip node
23
+ // isn't currently rendered (e.g. drilled into a different composition).
24
+ usePlayerStore.getState().clearClipRevealRequest();
25
+
26
+ const container = scrollRef.current;
27
+ if (!container) return;
28
+ const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
29
+ if (!(clip instanceof HTMLElement)) return;
30
+
31
+ const containerRect = container.getBoundingClientRect();
32
+ const clipRect = clip.getBoundingClientRect();
33
+ const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
34
+ const clipTop = clipRect.top - containerRect.top + container.scrollTop;
35
+
36
+ const target = computeRevealScroll({
37
+ scrollLeft: container.scrollLeft,
38
+ scrollTop: container.scrollTop,
39
+ viewportWidth: container.clientWidth,
40
+ viewportHeight: container.clientHeight,
41
+ clipLeft,
42
+ clipRight: clipLeft + clipRect.width,
43
+ clipTop,
44
+ clipBottom: clipTop + clipRect.height,
45
+ stickyLeft: GUTTER,
46
+ stickyTop: RULER_H,
47
+ allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
48
+ });
49
+ if (target.left === null && target.top === null) return;
50
+ container.scrollTo({
51
+ left: target.left ?? container.scrollLeft,
52
+ top: target.top ?? container.scrollTop,
53
+ behavior: "smooth",
54
+ });
55
+ }, [revealRequest, scrollRef]);
56
+ }
@@ -57,7 +57,17 @@ describe("useTimelineStackingSync", () => {
57
57
  });
58
58
 
59
59
  expect(commit).toHaveBeenCalledWith(
60
- [expect.objectContaining({ element: node, zIndex: 8, sourceFile: "nested.html" })],
60
+ [
61
+ expect.objectContaining({
62
+ element: node,
63
+ zIndex: 8,
64
+ sourceFile: "nested.html",
65
+ // The patch's store key rides along so the commit updates the store
66
+ // zIndex synchronously on the lane-sync path (and rolls it back on
67
+ // failure) instead of waiting for a preview reload.
68
+ key: "a",
69
+ }),
70
+ ],
61
71
  "clip-lane-move:7",
62
72
  );
63
73
  act(() => root.unmount());
@@ -45,10 +45,16 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
45
45
  [zSyncPreviewIframeRef, zSyncActiveCompPath],
46
46
  );
47
47
 
48
+ // NaN (NOT 0) when the element can't be resolved in the preview iframe — a
49
+ // nested / unmounted sub-comp node, or one outside the active file. Fabricating
50
+ // z=0 would enter computeStackingPatches as a real overlapping neighbour at the
51
+ // z-floor and skew the boundary math; a non-finite value tells it to EXCLUDE this
52
+ // clip instead. NaN (rather than null) keeps the return assignable to the
53
+ // `(el) => number` reader contract the drag hook / commit deps declare.
48
54
  const readClipZIndex = useCallback(
49
55
  (el: TimelineElement): number => {
50
56
  const node = resolveIframeElement(el);
51
- return node ? readEffectiveZIndex(node) : 0;
57
+ return node ? readEffectiveZIndex(node) : Number.NaN;
52
58
  },
53
59
  [resolveIframeElement],
54
60
  );
@@ -68,10 +74,15 @@ export function useTimelineStackingSync({ expandedElementsRef }: UseTimelineStac
68
74
  selector: el.selector,
69
75
  selectorIndex: el.selectorIndex,
70
76
  sourceFile: el.sourceFile ?? zSyncActiveCompPath ?? "index.html",
77
+ // The store key: lets the commit update the store's zIndex
78
+ // synchronously (and roll it back on failure).
79
+ key: p.key,
71
80
  },
72
81
  ];
73
82
  });
74
- if (entries.length) return handleDomZIndexReorderCommit(entries, coalesceKey);
83
+ // Forward the drag-commit's shared coalesce key so the z-reorder history
84
+ // entry merges with the lane change's move entry into one undo step.
85
+ if (entries.length) handleDomZIndexReorderCommit(entries, coalesceKey);
75
86
  },
76
87
  [handleDomZIndexReorderCommit, resolveIframeElement, zSyncActiveCompPath, expandedElementsRef],
77
88
  );
@@ -163,7 +163,14 @@ function buildChildElements(
163
163
  key,
164
164
  start: clamped.start,
165
165
  duration: clamped.duration,
166
+ // `track` becomes a synthetic display row under the expanded host, but the
167
+ // factory-set `authoredTrack` (the child's data-track-index in ITS OWN
168
+ // file's coordinate space) and the runtime-computed `stackingContextId`
169
+ // must survive verbatim — lane persists and z-sync read them, they are
170
+ // never reconstructed from display lanes.
166
171
  track: display.track + result.length,
172
+ authoredTrack: base.authoredTrack,
173
+ stackingContextId: base.stackingContextId,
167
174
  expandedParentStart: editBasis.start,
168
175
  domId,
169
176
  selector,
@@ -153,6 +153,61 @@ describe("useTimelinePlayer seek hydration", () => {
153
153
  unmountWithAct(root);
154
154
  unsubscribe();
155
155
  });
156
+
157
+ it("does not settle from an unsupported runtime protocol message", () => {
158
+ const { api, root } = renderTimelinePlayerHarness();
159
+ const iframe = document.createElement("iframe");
160
+ const iframeWindow = {
161
+ postMessage: vi.fn(),
162
+ scrollTo: vi.fn(),
163
+ addEventListener: vi.fn(),
164
+ removeEventListener: vi.fn(),
165
+ } as Record<string, unknown>;
166
+ Object.defineProperty(iframe, "contentWindow", {
167
+ value: iframeWindow,
168
+ configurable: true,
169
+ });
170
+ Object.defineProperty(iframe, "contentDocument", {
171
+ value: document.implementation.createHTMLDocument("preview"),
172
+ configurable: true,
173
+ });
174
+
175
+ act(() => {
176
+ api.iframeRef.current = iframe;
177
+ api.onIframeLoad();
178
+ });
179
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
180
+
181
+ iframeWindow.__player = {
182
+ play: vi.fn(),
183
+ pause: vi.fn(),
184
+ seek: vi.fn(),
185
+ getTime: () => 0,
186
+ getDuration: () => 30,
187
+ isPlaying: () => false,
188
+ };
189
+ act(() => {
190
+ window.dispatchEvent(
191
+ new MessageEvent("message", {
192
+ source: iframeWindow as unknown as Window,
193
+ data: { source: "hf-preview", type: "state", protocolVersion: 999 },
194
+ }),
195
+ );
196
+ });
197
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
198
+
199
+ act(() => {
200
+ window.dispatchEvent(
201
+ new MessageEvent("message", {
202
+ source: iframeWindow as unknown as Window,
203
+ data: { source: "hf-preview", type: "state" },
204
+ }),
205
+ );
206
+ });
207
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
208
+
209
+ unmountWithAct(root);
210
+ });
156
211
  });
157
212
 
158
213
  describe("useTimelinePlayer audio controls (#835)", () => {
@@ -437,12 +437,19 @@ describe("anonymous timeline identity", () => {
437
437
  });
438
438
 
439
439
  describe("mergeTimelineElementsPreservingDowngrades", () => {
440
- it("preserves missing current elements when a shorter manifest arrives", () => {
440
+ it("preserves missing sub-composition elements when a shorter manifest arrives", () => {
441
441
  expect(
442
442
  mergeTimelineElementsPreservingDowngrades(
443
443
  [
444
444
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
445
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
445
+ {
446
+ id: "cta",
447
+ tag: "div",
448
+ start: 4,
449
+ duration: 2,
450
+ track: 1,
451
+ compositionSrc: "scenes/cta.html",
452
+ },
446
453
  ],
447
454
  [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
448
455
  8,
@@ -450,10 +457,31 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
450
457
  ),
451
458
  ).toEqual([
452
459
  { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
453
- { id: "cta", tag: "div", start: 4, duration: 2, track: 1 },
460
+ {
461
+ id: "cta",
462
+ tag: "div",
463
+ start: 4,
464
+ duration: 2,
465
+ track: 1,
466
+ compositionSrc: "scenes/cta.html",
467
+ },
454
468
  ]);
455
469
  });
456
470
 
471
+ it("drops missing top-level elements so undo does not leave ghost clips", () => {
472
+ expect(
473
+ mergeTimelineElementsPreservingDowngrades(
474
+ [
475
+ { id: "hero", tag: "div", start: 0, duration: 4, track: 0 },
476
+ { id: "split-clone", tag: "div", start: 4, duration: 2, track: 1 },
477
+ ],
478
+ [{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }],
479
+ 8,
480
+ 8,
481
+ ),
482
+ ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
483
+ });
484
+
457
485
  it("accepts longer-duration or same-size updates as authoritative", () => {
458
486
  expect(
459
487
  mergeTimelineElementsPreservingDowngrades(
@@ -477,6 +505,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
477
505
  start: 0,
478
506
  duration: 3,
479
507
  track: 0,
508
+ compositionSrc: "scenes/cards.html",
480
509
  },
481
510
  {
482
511
  id: "Card",
@@ -486,6 +515,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
486
515
  start: 3,
487
516
  duration: 3,
488
517
  track: 1,
518
+ compositionSrc: "scenes/cards.html",
489
519
  },
490
520
  ],
491
521
  [
@@ -497,6 +527,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
497
527
  start: 0,
498
528
  duration: 3,
499
529
  track: 0,
530
+ compositionSrc: "scenes/cards.html",
500
531
  },
501
532
  ],
502
533
  8,
@@ -511,6 +542,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
511
542
  start: 0,
512
543
  duration: 3,
513
544
  track: 0,
545
+ compositionSrc: "scenes/cards.html",
514
546
  },
515
547
  {
516
548
  id: "Card",
@@ -520,6 +552,7 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
520
552
  start: 3,
521
553
  duration: 3,
522
554
  track: 1,
555
+ compositionSrc: "scenes/cards.html",
523
556
  },
524
557
  ]);
525
558
  });
@@ -46,6 +46,7 @@ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
46
46
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
47
47
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
48
48
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
49
+ import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
49
50
 
50
51
  /**
51
52
  * Whether the derived elements differ from the current ones in any field that
@@ -492,6 +493,9 @@ export function useTimelinePlayer() {
492
493
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
493
494
  return;
494
495
  }
496
+ if (data?.source === "hf-preview") {
497
+ if (!acceptStudioRuntimeMessage(data)) return;
498
+ }
495
499
  if (data?.source === "hf-preview" && data?.type === "state") {
496
500
  try {
497
501
  if (usePlayerStore.getState().elements.length === 0) {
@@ -26,6 +26,7 @@ import {
26
26
  autoHealMissingCompositionIds,
27
27
  buildMissingCompositionElements,
28
28
  } from "../lib/timelineIframeHelpers";
29
+ import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
29
30
 
30
31
  interface UseTimelineSyncCallbacksParams {
31
32
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -132,6 +133,9 @@ export function useTimelineSyncCallbacks({
132
133
  clips: ClipManifestClip[];
133
134
  durationInFrames: number;
134
135
  scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
136
+ protocolVersion?: unknown;
137
+ capabilities?: unknown;
138
+ fps?: unknown;
135
139
  }) => {
136
140
  if (!data.clips || data.clips.length === 0) {
137
141
  return;
@@ -222,7 +226,7 @@ export function useTimelineSyncCallbacks({
222
226
  hostEl,
223
227
  });
224
228
  });
225
- const rawDuration = data.durationInFrames / 30;
229
+ const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
226
230
  // Clamp non-finite or absurdly large durations — the runtime can emit
227
231
  // Infinity when it detects a loop-inflated GSAP timeline without an
228
232
  // explicit data-duration on the root composition. Floor the manifest total
@@ -418,6 +422,10 @@ export function useTimelineSyncCallbacks({
418
422
  if (e.source && iframe && e.source !== iframe.contentWindow) return;
419
423
  const data = e.data;
420
424
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
425
+ // The main message handler owns protocol-error diagnostics. This readiness-only
426
+ // listener mirrors its acceptance gate without dispatching a duplicate event:
427
+ // an unsupported runtime must not make the iframe appear successfully settled.
428
+ if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
421
429
  trySettle();
422
430
  }
423
431
  };
@@ -11,7 +11,9 @@ export { resolveIframe } from "./lib/timelineDOM";
11
11
 
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
- export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
14
+ // Public library surface; external consumers are invisible to the workspace analyzer.
15
+ // fallow-ignore-next-line unused-exports
16
+ export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
15
17
 
16
18
  // Utils
17
19
  export { formatTime } from "./lib/time";