@hyperframes/studio 0.8.49 → 0.8.50

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 (95) hide show
  1. package/dist/assets/{hyperframes-player-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
  2. package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +12 -4
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5899 -5754
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useClipboard.test.ts +234 -0
  35. package/src/hooks/useClipboard.ts +264 -70
  36. package/src/hooks/useGroupCommits.test.ts +0 -1
  37. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  38. package/src/hooks/useRazorSplit.test.ts +0 -2
  39. package/src/hooks/useRazorSplit.test.tsx +0 -1
  40. package/src/hooks/useStudioContextValue.ts +5 -19
  41. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  42. package/src/player/components/ClipContextMenu.tsx +87 -7
  43. package/src/player/components/Timeline.tsx +14 -14
  44. package/src/player/components/TimelineCanvas.tsx +30 -0
  45. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  46. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  47. package/src/player/components/TimelineLanes.test.tsx +22 -1
  48. package/src/player/components/TimelineOverlays.tsx +12 -0
  49. package/src/player/components/TimelineTypes.ts +4 -0
  50. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  51. package/src/player/components/timelineClipChildren.test.ts +2 -2
  52. package/src/player/components/timelineClipChildren.tsx +2 -1
  53. package/src/player/components/timelineClipDragCommit.ts +2 -2
  54. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  55. package/src/player/components/timelineDragDrop.ts +30 -23
  56. package/src/player/components/timelineEditCapabilities.ts +1 -2
  57. package/src/player/components/timelineEditing.test.ts +0 -16
  58. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  59. package/src/player/components/timelineGroupEditing.ts +0 -1
  60. package/src/player/components/timelineTrackDisplay.ts +1 -1
  61. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  62. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  63. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  64. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  65. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  66. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  67. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  68. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  69. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  70. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  71. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  72. package/src/player/lib/describeClips.test.ts +95 -0
  73. package/src/player/lib/describeClips.ts +103 -0
  74. package/src/player/lib/timelineDOM.test.ts +0 -80
  75. package/src/player/lib/timelineDOM.ts +1 -61
  76. package/src/player/lib/timelineElementHelpers.ts +2 -53
  77. package/src/player/store/playerStore.ts +5 -0
  78. package/src/player/store/timelineElement.ts +1 -3
  79. package/src/shims/canvasBrowserStub.js +50 -0
  80. package/src/utils/clipboardPayload.test.ts +42 -2
  81. package/src/utils/clipboardPayload.ts +57 -32
  82. package/src/utils/previewSafeMargins.test.ts +34 -0
  83. package/src/utils/previewSafeMargins.ts +10 -0
  84. package/src/utils/razorSplitTransaction.test.ts +0 -1
  85. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  86. package/src/utils/studioUiPreferences.test.ts +43 -0
  87. package/src/utils/studioUiPreferences.ts +27 -6
  88. package/src/utils/timelineElementSplit.test.ts +1 -2
  89. package/src/utils/timelineElementSplit.ts +0 -1
  90. package/src/webmcp/tools/lookTools.test.ts +40 -0
  91. package/src/webmcp/tools/lookTools.ts +32 -1
  92. package/dist/assets/index-B9LfWNyM.css +0 -1
  93. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  94. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  95. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -1,774 +0,0 @@
1
- // @vitest-environment happy-dom
2
-
3
- import React, { act, useEffect } from "react";
4
- import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
6
- import {
7
- buildExpandedElements,
8
- resolveTimelineExpansionRawId,
9
- useExpandedTimelineElements,
10
- } from "./useExpandedTimelineElements";
11
- import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
12
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13
- import type { ClipManifestClip } from "../lib/playbackTypes";
14
-
15
- globalThis.IS_REACT_ACT_ENVIRONMENT = true;
16
-
17
- const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
18
- id: "x",
19
- label: "x",
20
- start: 0,
21
- duration: 1,
22
- track: 0,
23
- kind: "element",
24
- tagName: "div",
25
- compositionId: null,
26
- parentCompositionId: null,
27
- compositionSrc: null,
28
- assetUrl: null,
29
- ...over,
30
- });
31
-
32
- const el = (over: Partial<TimelineElement>): TimelineElement => ({
33
- id: "x",
34
- start: 0,
35
- duration: 1,
36
- track: 0,
37
- tag: "div",
38
- ...over,
39
- });
40
-
41
- function TimelineExpansionHarness({ onValue }: { onValue: (value: TimelineElement[]) => void }) {
42
- const value = useExpandedTimelineElements();
43
- useEffect(() => {
44
- onValue(value);
45
- }, [onValue, value]);
46
- return null;
47
- }
48
-
49
- afterEach(() => {
50
- document.body.innerHTML = "";
51
- usePlayerStore.getState().reset();
52
- });
53
-
54
- describe("buildExpandedElements", () => {
55
- it("rebases a 1-level child onto its sub-comp host (start + sourceFile)", () => {
56
- // host s3 at absolute 16 → stats-panel.html; children live in that file.
57
- const elements = [el({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" })];
58
- const manifest = [
59
- clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
60
- clip({ id: "stat-1", start: 16.5, duration: 5 }),
61
- clip({ id: "stat-2", start: 16.9, duration: 5 }),
62
- ];
63
- const parentMap = new Map([
64
- ["stat-1", "s3"],
65
- ["stat-2", "s3"],
66
- ]);
67
-
68
- const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
69
- const child = out.find((e) => e.domId === "stat-1")!;
70
- expect(child.expandedParentStart).toBe(16);
71
- expect(child.expandedHostKey).toBe("s3");
72
- expect(child.sourceFile).toBe("stats.html");
73
- });
74
-
75
- it("keeps repeated same-source composition hosts as distinct move identities", () => {
76
- const elements = [
77
- el({
78
- id: "host-a",
79
- key: "index.html#host-a",
80
- start: 0,
81
- duration: 5,
82
- compositionSrc: "scene.html",
83
- }),
84
- el({
85
- id: "host-b",
86
- key: "index.html#host-b",
87
- start: 8,
88
- duration: 5,
89
- compositionSrc: "scene.html",
90
- }),
91
- ];
92
- const manifest = [
93
- clip({ id: "host-a", start: 0, duration: 5, compositionSrc: "scene.html" }),
94
- clip({ id: "child-a", start: 1, duration: 2 }),
95
- clip({ id: "host-b", start: 8, duration: 5, compositionSrc: "scene.html" }),
96
- clip({ id: "child-b", start: 9, duration: 2 }),
97
- ];
98
- const parentMap = new Map([
99
- ["child-a", "host-a"],
100
- ["child-b", "host-b"],
101
- ]);
102
-
103
- const childA = buildExpandedElements(elements, manifest, parentMap, "host-a", "host-a").find(
104
- (element) => element.domId === "child-a",
105
- );
106
- const childB = buildExpandedElements(elements, manifest, parentMap, "host-b", "host-b").find(
107
- (element) => element.domId === "child-b",
108
- );
109
-
110
- expect(childA?.sourceFile).toBe("scene.html");
111
- expect(childB?.sourceFile).toBe("scene.html");
112
- expect(childA?.expandedHostKey).toBe("index.html#host-a");
113
- expect(childB?.expandedHostKey).toBe("index.html#host-b");
114
- });
115
-
116
- // fallow-ignore-next-line code-duplication
117
- it("rebases a 2-level child onto its NESTED host, not the top-level scene", () => {
118
- // top host A@10 (a.html) embeds host B@12 (b.html); child C lives in b.html.
119
- // Edits must rebase onto B (12 / b.html), not A (10 / a.html).
120
- const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
121
- const manifest = [
122
- clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
123
- clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
124
- clip({ id: "C", start: 13, duration: 2 }),
125
- clip({ id: "C2", start: 14, duration: 1 }),
126
- ];
127
- const parentMap = new Map([
128
- ["B", "A"],
129
- ["C", "B"],
130
- ["C2", "B"],
131
- ]);
132
-
133
- // Expanding C's siblings: topLevel A, immediate parent B.
134
- const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
135
- const child = out.find((e) => e.domId === "C")!;
136
- expect(child.expandedParentStart).toBe(12); // B's start, not A's 10
137
- expect(child.sourceFile).toBe("b.html"); // B's file, not a.html
138
- });
139
-
140
- // fallow-ignore-next-line code-duplication
141
- it("rebases a 3-level child onto its deepest host, not intermediate or top", () => {
142
- // A@10 (a.html) → B@12 (b.html) → C@13 (c.html); leaf D lives in c.html.
143
- // Edits must rebase onto C (13 / c.html), not B (12 / b.html) or A (10 / a.html).
144
- const elements = [el({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" })];
145
- const manifest = [
146
- clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
147
- clip({ id: "B", start: 12, duration: 5, compositionSrc: "b.html" }),
148
- clip({ id: "C", start: 13, duration: 3, compositionSrc: "c.html" }),
149
- clip({ id: "D", start: 13.5, duration: 1 }),
150
- clip({ id: "D2", start: 14, duration: 1 }),
151
- ];
152
- const parentMap = new Map([
153
- ["B", "A"],
154
- ["C", "B"],
155
- ["D", "C"],
156
- ["D2", "C"],
157
- ]);
158
-
159
- // Expanding D's siblings: topLevel A, immediate parent C.
160
- const out = buildExpandedElements(elements, manifest, parentMap, "A", "C");
161
- const child = out.find((e) => e.domId === "D")!;
162
- expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10
163
- expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
164
- });
165
-
166
- it("keeps the middle host's row when drilling two levels deep", () => {
167
- // A embeds B; C lives in B. Drilling into B must leave BOTH host rows
168
- // standing: sparing only the top-level one drops B's row, and its keyframe
169
- // lane goes with it because diamonds render per row.
170
- const elements = [
171
- el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
172
- el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }),
173
- ];
174
- const manifest = [
175
- clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
176
- clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
177
- clip({ id: "C", start: 13, duration: 2 }),
178
- ];
179
- const parentMap = new Map([
180
- ["B", "A"],
181
- ["C", "B"],
182
- ]);
183
-
184
- const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
185
- const rows = out.map((e) => e.domId ?? e.id);
186
- expect(rows).toContain("B");
187
- // The child sits under its own host, not under the top-level row.
188
- expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B"));
189
- });
190
-
191
- it("still drills a host that exists only in the manifest, without a row for it", () => {
192
- // Same shape, but B has no store element, so there is no row to spare. The
193
- // children stay anchored to the top-level row rather than vanishing.
194
- const elements = [
195
- el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
196
- ];
197
- const manifest = [
198
- clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
199
- clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
200
- clip({ id: "C", start: 13, duration: 2 }),
201
- ];
202
- const parentMap = new Map([
203
- ["B", "A"],
204
- ["C", "B"],
205
- ]);
206
-
207
- const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
208
- expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]);
209
- });
210
-
211
- // Regression: an expanded child must share one identity (`key`) with the flat
212
- // store element for the same DOM id. Before the fix the child key fell back to
213
- // the colon form (`index.html:eyebrow:N`) while the store/selection used the
214
- // hash form (`index.html#eyebrow`), so clicking an expanded child never
215
- // highlighted it (isSelected compares the two keys).
216
- it("keys expanded children in hash form, matching the flat store element", () => {
217
- // Single composition (no sub-comps): scene `s1` with same-file children.
218
- const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })];
219
- const manifest = [
220
- clip({ id: "s1", start: 0, duration: 14 }),
221
- clip({ id: "eyebrow", start: 0, duration: 14 }),
222
- clip({ id: "title", start: 0, duration: 14 }),
223
- ];
224
- const parentMap = new Map([
225
- ["eyebrow", "s1"],
226
- ["title", "s1"],
227
- ]);
228
-
229
- const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
230
- const child = out.find((e) => e.domId === "eyebrow")!;
231
-
232
- const expectedStoreKey = buildTimelineElementKey({
233
- id: "eyebrow",
234
- fallbackIndex: 0,
235
- domId: "eyebrow",
236
- selector: "#eyebrow",
237
- sourceFile: undefined,
238
- });
239
- expect(expectedStoreKey).toBe("index.html#eyebrow");
240
- expect(child.key).toBe("index.html#eyebrow");
241
- expect(child.key).toBe(expectedStoreKey);
242
- });
243
-
244
- // Regression: a child row is built from a manifest clip, which carries none of
245
- // the host element's attributes. Reading hidden off the manifest left every
246
- // expanded row reporting itself visible, so the eye wrote data-hidden a second
247
- // time instead of removing it and the element could never be shown again.
248
- it("inherits hidden and locked state from the flat store element", () => {
249
- const elements = [
250
- el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
251
- el({
252
- id: "eyebrow",
253
- key: "index.html#eyebrow",
254
- domId: "eyebrow",
255
- start: 0,
256
- duration: 14,
257
- hidden: true,
258
- timelineLocked: true,
259
- }),
260
- ];
261
- const manifest = [
262
- clip({ id: "s1", start: 0, duration: 14 }),
263
- clip({ id: "eyebrow", start: 0, duration: 14 }),
264
- ];
265
- const parentMap = new Map([["eyebrow", "s1"]]);
266
-
267
- const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
268
- const child = out.find((e) => e.domId === "eyebrow")!;
269
- expect(child.hidden).toBe(true);
270
- expect(child.timelineLocked).toBe(true);
271
- });
272
-
273
- // The test above only covers a host with no compositionSrc, where the child's
274
- // key falls back to the `index.html` scope and a flat store twin can exist. A
275
- // REAL sub-composition scopes the child key to the sub-comp file, and clips
276
- // whose parent composition is itself in the manifest are filtered out of the
277
- // flat store before it is built (`processTimelineMessage`). So the twin never
278
- // exists there and the inheritance above is dead code for the one case it was
279
- // written for: the eye reported every hidden child visible, clicking it
280
- // rewrote data-hidden, and nothing could be shown again.
281
- it("reads hidden and locked off the live element when the child has no flat twin", () => {
282
- // Only the host is in the flat store — exactly what the manifest filter leaves.
283
- const elements = [
284
- el({
285
- id: "scene-2",
286
- domId: "scene-2",
287
- start: 3.25,
288
- duration: 3.5,
289
- compositionSrc: "scene-2.html",
290
- }),
291
- ];
292
- const manifest = [
293
- clip({ id: "scene-2", start: 3.25, duration: 3.5, compositionSrc: "scene-2.html" }),
294
- clip({ id: "scene-2-video", start: 3.25, duration: 3.5, parentCompositionId: "scene-2" }),
295
- ];
296
- const parentMap = new Map([["scene-2-video", "scene-2"]]);
297
- const hostState = new Map([["scene-2-video", { hidden: true, timelineLocked: true }]]);
298
-
299
- const out = buildExpandedElements(
300
- elements,
301
- manifest,
302
- parentMap,
303
- "scene-2",
304
- "scene-2",
305
- [],
306
- hostState,
307
- );
308
- const child = out.find((e) => e.domId === "scene-2-video")!;
309
- // The child key is scoped to the sub-comp file, so no store element can match it.
310
- expect(child.key).toBe("scene-2.html#scene-2-video");
311
- expect(elements.some((element) => element.key === child.key)).toBe(false);
312
- expect(child.hidden).toBe(true);
313
- expect(child.timelineLocked).toBe(true);
314
- });
315
-
316
- // Sub-comp internals (group + pills) have no data-start, so they're not in the
317
- // manifest. They arrive as DOM children and must still expand under their host.
318
- it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
319
- const elements = [
320
- el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
321
- ];
322
- const manifest = [
323
- clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
324
- ];
325
- // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
326
- // are in the manifest; they're DOM children with parent links.
327
- const parentMap = new Map([
328
- ["group-1", "scene-host"],
329
- ["pill-1", "group-1"],
330
- ["pill-2", "group-1"],
331
- ["pill-3", "group-1"],
332
- ]);
333
- const domClipChildren = [
334
- {
335
- id: "group-1",
336
- parentId: "scene-host",
337
- hostId: "scene-host",
338
- label: "Group 1",
339
- stackingContextId: "css:0",
340
- },
341
- {
342
- id: "pill-1",
343
- parentId: "group-1",
344
- hostId: "scene-host",
345
- label: "pill-1",
346
- stackingContextId: "css:0.0",
347
- },
348
- {
349
- id: "pill-2",
350
- parentId: "group-1",
351
- hostId: "scene-host",
352
- label: "pill-2",
353
- stackingContextId: "css:0.1",
354
- },
355
- {
356
- id: "pill-3",
357
- parentId: "group-1",
358
- hostId: "scene-host",
359
- label: "pill-3",
360
- stackingContextId: "css:0.1",
361
- },
362
- ];
363
-
364
- // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
365
- const out = buildExpandedElements(
366
- elements,
367
- manifest,
368
- parentMap,
369
- "scene-host",
370
- "group-1",
371
- domClipChildren,
372
- );
373
- const pills = out.filter((e) => e.domId?.startsWith("pill-"));
374
- expect(pills).toHaveLength(3);
375
- // Children span the host's bounds and rebase onto the host's file.
376
- expect(pills[0]!.start).toBe(5);
377
- expect(pills[0]!.duration).toBe(6);
378
- expect(pills[0]!.sourceFile).toBe("scene.html");
379
- expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
380
- // The host row survives the expansion; its children are added under it.
381
- expect(out.some((e) => e.id === "scene-host")).toBe(true);
382
- });
383
-
384
- // fallow-ignore-next-line code-duplication
385
- it("keeps the host row and appends its children directly below it", () => {
386
- const elements = [
387
- el({
388
- id: "s3",
389
- domId: "s3",
390
- key: "index.html#s3",
391
- start: 16,
392
- duration: 7,
393
- compositionSrc: "stats.html",
394
- }),
395
- el({ id: "outro", start: 23, duration: 3, track: 1 }),
396
- ];
397
- const manifest = [
398
- clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
399
- clip({ id: "stat-1", start: 16.5, duration: 5 }),
400
- clip({ id: "stat-2", start: 16.9, duration: 5 }),
401
- ];
402
- const parentMap = new Map([
403
- ["stat-1", "s3"],
404
- ["stat-2", "s3"],
405
- ]);
406
-
407
- const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
408
-
409
- const hostIndex = out.findIndex((e) => e.id === "s3");
410
- expect(hostIndex).toBeGreaterThanOrEqual(0);
411
- // Host row untouched (same key → same keyframe lane), children nested under it.
412
- expect(out[hostIndex]!.key).toBe("index.html#s3");
413
- expect(out[hostIndex]!.track).toBe(0);
414
- expect(out[hostIndex + 1]!.domId).toBe("stat-1");
415
- expect(out[hostIndex + 2]!.domId).toBe("stat-2");
416
- // Exactly one more row than the old substitution behaviour (host + 2 children + outro).
417
- expect(out).toHaveLength(4);
418
- });
419
-
420
- it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
421
- // Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
422
- // seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
423
- // so the whole lane went with the host row whenever the store-time drill-in
424
- // substituted it for its children.
425
- const elements = [
426
- el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
427
- ];
428
- const manifest = [
429
- clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
430
- clip({ id: "headline", start: 0, duration: 12 }),
431
- ];
432
- const parentMap = new Map([["headline", "scene"]]);
433
-
434
- for (const currentTime of [0, 7.68, 0.2]) {
435
- const rawId = resolveTimelineExpansionRawId({
436
- selectedElementId: null,
437
- currentTime,
438
- manifest,
439
- parentMap,
440
- });
441
- const rows = rawId
442
- ? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
443
- : elements;
444
- expect(rows.map((row) => row.key)).toContain("index.html#scene");
445
- }
446
- });
447
-
448
- // Regression: DOM-only children were synthesized against the TOP-LEVEL element
449
- // instead of the sub-comp host they actually live in, so every child row read
450
- // the whole top-level window rather than its host's.
451
- it("spans DOM-only children over their nested host's window, not the top-level one", () => {
452
- const elements = [
453
- el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
454
- ];
455
- const manifest = [
456
- clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
457
- clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
458
- ];
459
- const parentMap = new Map([
460
- ["sub-host", "scene-host"],
461
- ["pill-1", "sub-host"],
462
- ]);
463
- const domClipChildren = [
464
- {
465
- id: "pill-1",
466
- parentId: "sub-host",
467
- hostId: "sub-host",
468
- label: "pill-1",
469
- stackingContextId: "css:0.0",
470
- },
471
- ];
472
-
473
- const out = buildExpandedElements(
474
- elements,
475
- manifest,
476
- parentMap,
477
- "scene-host",
478
- "sub-host",
479
- domClipChildren,
480
- );
481
- const pill = out.find((e) => e.domId === "pill-1")!;
482
- expect(pill.start).toBe(5);
483
- expect(pill.duration).toBe(6);
484
- expect(pill.sourceFile).toBe("sub.html");
485
- });
486
- });
487
-
488
- describe("resolveTimelineExpansionRawId", () => {
489
- it("returns null inside a childless top-level clip", () => {
490
- const manifest = [clip({ id: "title", start: 0, duration: 4 })];
491
-
492
- expect(
493
- resolveTimelineExpansionRawId({
494
- selectedElementId: null,
495
- currentTime: 2,
496
- manifest,
497
- parentMap: new Map(),
498
- }),
499
- ).toBeNull();
500
- });
501
-
502
- it("auto-expands an active composition with children when nothing is selected", () => {
503
- const manifest = [
504
- clip({ id: "scene", start: 1, duration: 5 }),
505
- clip({ id: "headline", start: 1.5, duration: 2 }),
506
- ];
507
- const parentMap = new Map([["headline", "scene"]]);
508
-
509
- expect(
510
- resolveTimelineExpansionRawId({
511
- selectedElementId: null,
512
- currentTime: 2,
513
- manifest,
514
- parentMap,
515
- }),
516
- ).toBe("scene");
517
- });
518
-
519
- it("THE BUG: keeps a composition expanded with the playhead parked on its end", () => {
520
- // Clip windows are half-open, so at the very end of the timeline the
521
- // playhead was inside nothing and every expanded row collapsed.
522
- const manifest = [
523
- clip({ id: "scene", start: 0, duration: 12 }),
524
- clip({ id: "headline", start: 0, duration: 12 }),
525
- ];
526
- const parentMap = new Map([["headline", "scene"]]);
527
-
528
- expect(
529
- resolveTimelineExpansionRawId({
530
- selectedElementId: null,
531
- currentTime: 12,
532
- manifest,
533
- parentMap,
534
- }),
535
- ).toBe("scene");
536
- });
537
-
538
- it("prefers the starting clip over the ending one on a shared seam", () => {
539
- const manifest = [
540
- clip({ id: "first", start: 0, duration: 5 }),
541
- clip({ id: "first-child", start: 0, duration: 5 }),
542
- clip({ id: "second", start: 5, duration: 5 }),
543
- clip({ id: "second-child", start: 5, duration: 5 }),
544
- ];
545
- const parentMap = new Map([
546
- ["first-child", "first"],
547
- ["second-child", "second"],
548
- ]);
549
-
550
- expect(
551
- resolveTimelineExpansionRawId({
552
- selectedElementId: null,
553
- currentTime: 5,
554
- manifest,
555
- parentMap,
556
- }),
557
- ).toBe("second");
558
- });
559
-
560
- it("auto-expands the innermost active nested composition", () => {
561
- const manifest = [
562
- clip({ id: "outer", start: 0, duration: 10 }),
563
- clip({ id: "inner", start: 2, duration: 5 }),
564
- clip({ id: "leaf", start: 3, duration: 1 }),
565
- ];
566
- const parentMap = new Map([
567
- ["inner", "outer"],
568
- ["leaf", "inner"],
569
- ]);
570
-
571
- expect(
572
- resolveTimelineExpansionRawId({
573
- selectedElementId: null,
574
- currentTime: 3.5,
575
- manifest,
576
- parentMap,
577
- }),
578
- ).toBe("inner");
579
- });
580
-
581
- it("resolves an active composition from the current store time", () => {
582
- const manifest = [
583
- clip({ id: "scene", start: 0, duration: 5 }),
584
- clip({ id: "headline", start: 1, duration: 2 }),
585
- ];
586
- const parentMap = new Map([["headline", "scene"]]);
587
-
588
- expect(
589
- resolveTimelineExpansionRawId({
590
- selectedElementId: null,
591
- currentTime: 2,
592
- manifest,
593
- parentMap,
594
- }),
595
- ).toBe("scene");
596
- });
597
-
598
- it("keeps inline children visible when the master timeline is playing", () => {
599
- const elements = [
600
- el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 5 }),
601
- ];
602
- const manifest = [
603
- clip({ id: "scene", start: 0, duration: 5, compositionSrc: "scene.html" }),
604
- clip({ id: "headline", start: 1, duration: 2 }),
605
- ];
606
- const parentMap = new Map([["headline", "scene"]]);
607
- let rows: TimelineElement[] | undefined;
608
- const host = document.createElement("div");
609
- document.body.append(host);
610
- const root = createRoot(host);
611
-
612
- act(() => {
613
- usePlayerStore.setState({
614
- elements,
615
- clipManifest: manifest,
616
- clipParentMap: parentMap,
617
- currentTime: 2,
618
- isPlaying: true,
619
- });
620
- root.render(
621
- React.createElement(TimelineExpansionHarness, {
622
- onValue: (value) => (rows = value),
623
- }),
624
- );
625
- });
626
-
627
- expect(usePlayerStore.getState().isPlaying).toBe(true);
628
- expect(rows?.map((row) => row.domId ?? row.id)).toEqual(["scene", "headline"]);
629
-
630
- act(() => root.unmount());
631
- });
632
-
633
- it("keeps selected elements ahead of active composition auto-expansion", () => {
634
- const manifest = [
635
- clip({ id: "scene", start: 0, duration: 6 }),
636
- clip({ id: "headline", start: 1, duration: 2 }),
637
- clip({ id: "caption", start: 4, duration: 1 }),
638
- ];
639
- const parentMap = new Map([
640
- ["headline", "scene"],
641
- ["caption", "scene"],
642
- ]);
643
-
644
- expect(
645
- resolveTimelineExpansionRawId({
646
- selectedElementId: "caption",
647
- currentTime: 1.5,
648
- manifest,
649
- parentMap,
650
- }),
651
- ).toBe("caption");
652
- });
653
- });
654
-
655
- describe("buildExpandedElements — collision-free synthetic rows (cross-file lane safety)", () => {
656
- it("expanded children NEVER share a display track with an unrelated top-level clip", () => {
657
- // The Deepwork regression: host on track 0 with two children used to put
658
- // child #2 on integer track 1 — the same lane as index.html#foreign. Lane
659
- // grouping merges purely by track number, so a gap-close on that "one"
660
- // lane batch-persisted a foreign file's clip.
661
- const elements = [
662
- el({ id: "host", start: 0, duration: 20, track: 0, compositionSrc: "scene.html" }),
663
- el({ id: "foreign", start: 20, duration: 5, track: 1 }),
664
- ];
665
- const manifest = [
666
- clip({ id: "host", start: 0, duration: 20, compositionSrc: "scene.html" }),
667
- clip({ id: "c1", start: 0, duration: 5 }),
668
- clip({ id: "c2", start: 10, duration: 5 }),
669
- ];
670
- const parentMap = new Map([
671
- ["c1", "host"],
672
- ["c2", "host"],
673
- ]);
674
-
675
- const out = buildExpandedElements(elements, manifest, parentMap, "host", "host");
676
- const foreign = out.find((e) => e.id === "foreign")!;
677
- const children = out.filter((e) => e.domId === "c1" || e.domId === "c2");
678
- expect(children).toHaveLength(2);
679
- for (const child of children) {
680
- // No lane sharing with the foreign clip…
681
- expect(child.track).not.toBe(foreign.track);
682
- // …and structurally impossible to collide with ANY normalized (integer)
683
- // lane: synthetic rows are strict fractions under the host's lane.
684
- expect(Number.isInteger(child.track)).toBe(false);
685
- expect(child.track).toBeGreaterThan(0);
686
- expect(child.track).toBeLessThan(1);
687
- }
688
- // Distinct ordered rows per child.
689
- expect(children[0].track).not.toBe(children[1].track);
690
- });
691
-
692
- /**
693
- * A sub-composition that declares BOTH a group and its members keeps those
694
- * members out of the flat store entirely — the store holds only the host.
695
- * So "inherit membership from the flat twin" had nothing to inherit from,
696
- * and the group produced no timeline row at all, for exactly the case group
697
- * support was extended to cover. Verified against a real studio session
698
- * before this test was written: the flat store held three elements (the
699
- * panel, the sub-comp host and an ungrouped bed) and neither voice.
700
- */
701
- it("takes group membership from the DOM child when there is no flat store twin", () => {
702
- const elements = [
703
- el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
704
- ];
705
- const manifest = [
706
- clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
707
- ];
708
- const parentMap = new Map([
709
- ["voice-1", "voices-host"],
710
- ["voice-2", "voices-host"],
711
- ]);
712
- const domClipChildren = [
713
- {
714
- id: "voice-1",
715
- parentId: "voices-host",
716
- hostId: "voices-host",
717
- label: "voice-1",
718
- stackingContextId: "css:0",
719
- audioGroup: "voiceover",
720
- audioGroupLabel: "Voiceover",
721
- audioGroupVolume: 0.8,
722
- audioGroupHidden: false,
723
- },
724
- {
725
- id: "voice-2",
726
- parentId: "voices-host",
727
- hostId: "voices-host",
728
- label: "voice-2",
729
- stackingContextId: "css:0",
730
- audioGroup: "voiceover",
731
- audioGroupLabel: "Voiceover",
732
- audioGroupVolume: 0.8,
733
- audioGroupHidden: false,
734
- },
735
- ];
736
-
737
- const out = buildExpandedElements(
738
- elements,
739
- manifest,
740
- parentMap,
741
- "voices-host",
742
- "voices-host",
743
- domClipChildren,
744
- );
745
-
746
- const voices = out.filter((e) => e.domId?.startsWith("voice-"));
747
- expect(voices).toHaveLength(2);
748
- for (const voice of voices) {
749
- expect(voice.audioGroup).toBe("voiceover");
750
- expect(voice.audioGroupLabel).toBe("Voiceover");
751
- expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6);
752
- }
753
- });
754
- });
755
-
756
- describe("sub-comp child rows never collide with a group anchor", () => {
757
- /**
758
- * A group row anchors at exactly `firstMemberTrack - 0.5`. The old child
759
- * scheme `k / (n + 2)` hit 0.5 dead on for a host with TWO children (2/4),
760
- * producing a duplicate row key and a duplicated group header.
761
- */
762
- it("keeps every child strictly below the host's half-lane", () => {
763
- for (const childCount of [1, 2, 3, 4, 7]) {
764
- const fractions = Array.from(
765
- { length: childCount },
766
- (_unused, i) => (0.5 * (i + 1)) / (childCount + 1),
767
- );
768
- expect(fractions.every((f) => f > 0 && f < 0.5)).toBe(true);
769
- // Still distinct and ordered, which is what makes them usable as rows.
770
- expect(new Set(fractions).size).toBe(childCount);
771
- expect([...fractions].sort((a, b) => a - b)).toEqual(fractions);
772
- }
773
- });
774
- });