@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.
- package/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-C4csZims.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/{index-CmVCjZjp.js → index-DfmYkU44.js} +196 -196
- package/dist/assets/{index-BWnOxAiH.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.html +2 -2
- package/dist/index.js +4345 -3812
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditOverlay.tsx +31 -62
- package/src/components/editor/LayersPanel.tsx +10 -1
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +36 -16
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +12 -1
- package/src/hooks/timelineEditingHelpers.test.ts +91 -0
- package/src/hooks/timelineEditingHelpers.ts +31 -244
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +5 -3
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +92 -1
- package/src/hooks/useDomEditCommits.ts +63 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +165 -0
- package/src/hooks/useElementLifecycleOps.ts +20 -7
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +378 -212
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineGroupEditing.ts +153 -132
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +85 -8
- package/src/player/components/Timeline.tsx +9 -9
- package/src/player/components/TimelineCanvas.tsx +11 -2
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/timelineClipDragCommit.test.ts +91 -8
- package/src/player/components/timelineClipDragCommit.ts +106 -9
- package/src/player/components/timelineLayout.ts +99 -26
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -1
- package/src/player/components/useTimelineStackingSync.ts +13 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.ts +9 -0
- package/src/player/lib/timelineElementHelpers.ts +19 -0
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +37 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- 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
|
|
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
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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-lane — so it never rewrites the source.
|
|
96
45
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
lanes.push(
|
|
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[
|
|
114
|
-
laneOf.set(keyOf(
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* the number of lanes used
|
|
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
|
|
127
|
-
clips:
|
|
86
|
+
function packZoneLanes(
|
|
87
|
+
clips: TimelineElement[],
|
|
128
88
|
base: number,
|
|
129
89
|
laneOf: Map<string, number>,
|
|
130
90
|
): number {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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) :
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 /
|
|
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
|
};
|
package/src/player/index.ts
CHANGED
|
@@ -11,7 +11,9 @@ export { resolveIframe } from "./lib/timelineDOM";
|
|
|
11
11
|
|
|
12
12
|
// Store
|
|
13
13
|
export { usePlayerStore, liveTime } from "./store/playerStore";
|
|
14
|
-
|
|
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";
|