@hyperframes/studio 0.8.75 → 0.8.77

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 (130) hide show
  1. package/dist/assets/hyperframes-player-BSDTJ9EF.js +464 -0
  2. package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
  3. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  4. package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
  5. package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
  6. package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
  7. package/dist/chunk-5VANA2TG.js.map +1 -0
  8. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
  9. package/dist/index.d.ts +34 -69
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5287 -5277
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +7 -7
  15. package/src/components/StudioRightPanels.types.ts +0 -2
  16. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  17. package/src/components/editor/LayersPanel.tsx +3 -14
  18. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  19. package/src/components/editor/domEditing.ts +1 -0
  20. package/src/components/editor/domEditingElement.ts +13 -0
  21. package/src/components/editor/domEditingLayers.test.ts +27 -0
  22. package/src/components/editor/domEditingLayers.ts +9 -0
  23. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  24. package/src/components/editor/gsapLivePreview.ts +2 -14
  25. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  26. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  27. package/src/components/editor/overlayFrameLoop.ts +11 -0
  28. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  29. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  30. package/src/components/nle/NLEPreview.test.ts +202 -22
  31. package/src/components/nle/NLEPreview.tsx +65 -46
  32. package/src/components/nle/PreviewPoster.tsx +99 -0
  33. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  34. package/src/components/nle/previewZoom.test.ts +42 -0
  35. package/src/components/nle/previewZoom.ts +33 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  37. package/src/components/panels/VariablesPanel.tsx +0 -2
  38. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  39. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  40. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  41. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  42. package/src/components/studioColorGradingScope.ts +0 -3
  43. package/src/hooks/domEditDeleteMembers.ts +0 -2
  44. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  45. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  46. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  47. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  48. package/src/hooks/timelineEditingHelpers.ts +0 -3
  49. package/src/hooks/timelineTimingSync.ts +0 -1
  50. package/src/hooks/timelineTrackVisibility.ts +0 -1
  51. package/src/hooks/useBlockHandlers.ts +0 -2
  52. package/src/hooks/useClipboard.test.ts +69 -10
  53. package/src/hooks/useClipboard.ts +28 -19
  54. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  55. package/src/hooks/useDomEditCommits.ts +0 -4
  56. package/src/hooks/useDomEditWiring.ts +1 -3
  57. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  58. package/src/hooks/useEditHistoryActions.ts +12 -7
  59. package/src/hooks/useEditorSave.ts +0 -3
  60. package/src/hooks/useElementLifecycleOps.ts +0 -1
  61. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  62. package/src/hooks/useFileManager.ts +0 -2
  63. package/src/hooks/useGestureRecording.test.tsx +1 -1
  64. package/src/hooks/useGestureRecording.ts +6 -10
  65. package/src/hooks/useGroupCommits.ts +0 -1
  66. package/src/hooks/useGsapScriptCommits.ts +0 -1
  67. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  68. package/src/hooks/useGsapTweenCache.ts +5 -13
  69. package/src/hooks/useLintModal.test.tsx +34 -1
  70. package/src/hooks/useLintModal.ts +32 -30
  71. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  72. package/src/hooks/usePersistentEditHistory.ts +155 -342
  73. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  74. package/src/hooks/usePreviewPersistence.ts +0 -2
  75. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  76. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  77. package/src/hooks/useRazorSplit.test.ts +17 -47
  78. package/src/hooks/useRenderClipContent.test.ts +29 -0
  79. package/src/hooks/useRenderClipContent.ts +6 -3
  80. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  81. package/src/hooks/useSdkSession.ts +8 -2
  82. package/src/hooks/useSlideshowPersist.ts +0 -2
  83. package/src/hooks/useStudioContextValue.ts +6 -1
  84. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  85. package/src/hooks/useTimelineDeleteOps.ts +0 -1
  86. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  87. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  88. package/src/hostMount.test.tsx +53 -22
  89. package/src/main.tsx +3 -0
  90. package/src/player/components/CompositionThumbnail.tsx +14 -0
  91. package/src/player/components/Player.test.ts +49 -0
  92. package/src/player/components/Player.tsx +21 -7
  93. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  94. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  95. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  96. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  97. package/src/player/hooks/useShadowPreviewReload.ts +1 -0
  98. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  99. package/src/player/hooks/useTimelinePlayer.ts +1 -0
  100. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  101. package/src/player/lib/automationStoreSync.test.ts +24 -0
  102. package/src/player/lib/automationStoreSync.ts +3 -9
  103. package/src/player/lib/playbackScrub.ts +1 -1
  104. package/src/player/lib/timelineDOM.test.ts +28 -0
  105. package/src/player/lib/timelineDOM.ts +4 -2
  106. package/src/player/lib/timelineElementHelpers.ts +78 -9
  107. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  108. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  109. package/src/player/store/playerStore.test.ts +35 -1
  110. package/src/player/store/playerStore.ts +29 -0
  111. package/src/player/store/readinessSlice.ts +34 -6
  112. package/src/utils/blockInstaller.ts +0 -3
  113. package/src/utils/previewPrefetch.test.ts +22 -0
  114. package/src/utils/previewPrefetch.ts +14 -0
  115. package/src/utils/razorSplitTransaction.test.ts +0 -1
  116. package/src/utils/razorSplitTransaction.ts +1 -1
  117. package/src/utils/sdkEditTransaction.ts +0 -3
  118. package/src/utils/studioFileHistory.test.ts +0 -9
  119. package/src/utils/studioFileHistory.ts +1 -5
  120. package/src/utils/studioUiPreferences.test.ts +9 -3
  121. package/src/utils/studioUiPreferences.ts +0 -20
  122. package/src/utils/timelineCompositionInsert.ts +0 -1
  123. package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
  124. package/dist/chunk-7BP32G62.js.map +0 -1
  125. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  126. package/src/utils/editHistory.test.ts +0 -534
  127. package/src/utils/editHistory.ts +0 -236
  128. package/src/utils/editHistoryStorage.test.ts +0 -37
  129. package/src/utils/editHistoryStorage.ts +0 -99
  130. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
@@ -33,6 +33,30 @@ beforeEach(() => {
33
33
  });
34
34
 
35
35
  describe("syncStoredAutomationFromPreview", () => {
36
+ it("reads the element the timeline bound, not an earlier same-id copy in a sub-composition", () => {
37
+ usePlayerStore.setState({ elements: [el({ hfId: "hf-root" })] });
38
+ const doc = document.implementation.createHTMLDocument("preview");
39
+ doc.body.innerHTML =
40
+ '<div data-composition-id="strip"><audio id="bgm" data-hf-id="hf-inner"></audio></div>' +
41
+ '<audio id="bgm" data-hf-id="hf-root"></audio>';
42
+ doc.querySelector('[data-hf-id="hf-inner"]')?.setAttribute("data-automation", TWO_POINTS);
43
+ doc.querySelector('[data-hf-id="hf-root"]')?.setAttribute("data-automation", RESTORED);
44
+ syncStoredAutomationFromPreview(doc);
45
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
46
+ });
47
+
48
+ it("reads the copy in the element's own file when a sub-composition repeats its hf-id", () => {
49
+ usePlayerStore.setState({ elements: [el({ hfId: "hf-bgm" })] });
50
+ const doc = document.implementation.createHTMLDocument("preview");
51
+ doc.body.innerHTML =
52
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
53
+ '<audio id="bgm" data-hf-id="hf-bgm"></audio></div><audio id="bgm" data-hf-id="hf-bgm"></audio>';
54
+ doc.querySelectorAll("audio")[0]?.setAttribute("data-automation", TWO_POINTS);
55
+ doc.querySelectorAll("audio")[1]?.setAttribute("data-automation", RESTORED);
56
+ syncStoredAutomationFromPreview(doc);
57
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
58
+ });
59
+
36
60
  it("reads back an envelope an undo restored on the preview", () => {
37
61
  // The bug: a soft undo patches the preview document and re-runs the timeline, but
38
62
  // the store keeps its own copy of the attributes and that copy is what a lane
@@ -17,14 +17,7 @@ import { HF_AUDIO_AUTOMATION_ATTR } from "@hyperframes/core/audio-automation";
17
17
  import { HF_AUDIO_FX_ATTR } from "@hyperframes/core/audio-fx";
18
18
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
19
19
  import { groupInfoFor } from "./timelineGroupInfo";
20
-
21
- /** The preview node an element stands for, by dom id and then by `data-hf-id`. */
22
- function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
23
- const domId = element.domId ?? element.id;
24
- const byId = domId ? doc.getElementById(domId) : null;
25
- if (byId) return byId;
26
- return element.hfId ? doc.querySelector(`[data-hf-id="${element.hfId}"]`) : null;
27
- }
20
+ import { previewElementFinder } from "./timelineElementHelpers";
28
21
 
29
22
  /**
30
23
  * Re-read every element's automation and FX-chain attributes from the preview
@@ -62,10 +55,11 @@ function syncedFields(doc: Document, element: TimelineElement, node: Element) {
62
55
 
63
56
  export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
64
57
  if (!doc) return;
58
+ const findNode = previewElementFinder(doc);
65
59
  usePlayerStore.setState((state) => {
66
60
  let changed = false;
67
61
  const elements = state.elements.map((element) => {
68
- const node = previewNodeFor(doc, element);
62
+ const node = findNode(element);
69
63
  if (!node) return element;
70
64
  const fields = syncedFields(doc, element, node);
71
65
  // Same array back when nothing moved: `elements` keys memos all over the
@@ -12,5 +12,5 @@ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: num
12
12
  if (!music || s.audioMuted) return;
13
13
  const rel = nextTime - music.start;
14
14
  const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
15
- scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id, s.audioVolume);
15
+ scrubPreviewAudio(iframe, audioFileTime, music, s.audioVolume);
16
16
  }
@@ -285,6 +285,34 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
285
285
  });
286
286
  });
287
287
 
288
+ it("keeps the host it was given when a sub-composition is mounted twice", () => {
289
+ const doc = makeDoc(`
290
+ <div data-composition-id="root" data-composition-file="index.html">
291
+ <div id="a" data-hf-id="hf-a" data-composition-id="card" data-composition-src="card.html"></div>
292
+ <div id="b" data-hf-id="hf-b" data-composition-id="card" data-composition-src="card.html"></div>
293
+ </div>
294
+ `);
295
+ const element = createTimelineElementFromManifestClip({
296
+ clip: {
297
+ id: "b",
298
+ label: "Card",
299
+ kind: "composition",
300
+ tagName: "div",
301
+ start: 4,
302
+ duration: 4,
303
+ track: 0,
304
+ compositionId: "card",
305
+ parentCompositionId: "root",
306
+ compositionSrc: null,
307
+ assetUrl: null,
308
+ },
309
+ fallbackIndex: 1,
310
+ doc,
311
+ hostEl: doc.getElementById("b"),
312
+ });
313
+ expect(element).toMatchObject({ domId: "b", hfId: "hf-b", compositionSrc: "card.html" });
314
+ });
315
+
288
316
  it("ignores an index.html duplicate when indexing a scene.html selector", () => {
289
317
  const doc = makeDoc(`
290
318
  <div data-composition-id="root" data-composition-file="index.html">
@@ -171,8 +171,10 @@ export function createTimelineElementFromManifestClip(params: {
171
171
  entry.playbackRate ??= 1;
172
172
  let resolvedSrc = clip.compositionSrc;
173
173
  if (!resolvedSrc) {
174
- hostEl =
175
- doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
174
+ if (hostEl?.getAttribute("data-composition-id") !== clip.compositionId) {
175
+ hostEl =
176
+ doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
177
+ }
176
178
  resolvedSrc =
177
179
  hostEl?.getAttribute("data-composition-src") ??
178
180
  hostEl?.getAttribute("data-composition-file") ??
@@ -428,14 +428,83 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
428
428
  });
429
429
  }
430
430
 
431
- function findTimelineDomNode(doc: Document, id: string): Element | null {
432
- return (
433
- doc.getElementById(id) ??
434
- doc.querySelector(`[data-hf-id="${CSS.escape(id)}"]`) ??
435
- doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
436
- doc.querySelector(`.${CSS.escape(id)}`) ??
437
- null
438
- );
431
+ /** Whether `node` sits in the composition the clip was read from, the chain the runtime records outermost first
432
+ * (`resolveNearestCompositionContext` in core's runtime/timeline.ts). A clip without that scope accepts any node. */
433
+ function nodeInClipScope(node: Element, clip: ClipManifestClip): boolean {
434
+ const scope = clip.compositionAncestors;
435
+ if (!scope) return true;
436
+ const ids: string[] = [];
437
+ for (let cursor = node.parentElement; cursor; cursor = cursor.parentElement) {
438
+ const id = cursor.getAttribute("data-composition-id");
439
+ if (id) ids.unshift(id);
440
+ }
441
+ return ids.length === scope.length && ids.every((id, index) => id === scope[index]);
442
+ }
443
+
444
+ /** The first match in the clip's composition across `selectors`; a lone match stands only when none is in scope, as a
445
+ * healed host can stale the clip's chain for a pass. An id can repeat in a sub-composition earlier in the document. */
446
+ function findInClipScope(
447
+ doc: Document,
448
+ clip: ClipManifestClip,
449
+ selectors: string[],
450
+ ): Element | null {
451
+ let lone: Element | null = null;
452
+ for (const selector of selectors) {
453
+ const nodes = Array.from(doc.querySelectorAll(selector));
454
+ const scoped = nodes.find((node) => nodeInClipScope(node, clip));
455
+ if (scoped) return scoped;
456
+ if (nodes.length === 1) lone ??= nodes[0];
457
+ }
458
+ return lone;
459
+ }
460
+
461
+ export type PreviewTarget = Pick<TimelineElement, "hfId" | "domId" | "id" | "sourceFile">;
462
+
463
+ /** Finds a row's preview element by `data-hf-id`, then id, preferring one in the row's own file: both repeat across
464
+ * files. Indexes the document once, so a pass over every row costs one scan. */
465
+ export function previewElementFinder(
466
+ doc: Document,
467
+ selector = "[data-hf-id], [id]",
468
+ ): (target: PreviewTarget) => Element | null {
469
+ const byKey = new Map<string, Element[]>();
470
+ const add = (key: string, node: Element) => {
471
+ const nodes = byKey.get(key);
472
+ if (nodes) nodes.push(node);
473
+ else byKey.set(key, [node]);
474
+ };
475
+ for (const node of doc.querySelectorAll(selector)) {
476
+ const hfId = node.getAttribute("data-hf-id");
477
+ const id = node.getAttribute("id");
478
+ if (hfId) add(`hf:${hfId}`, node);
479
+ if (id) add(`id:${id}`, node);
480
+ }
481
+ return (target) => {
482
+ const matches = [
483
+ ...((target.hfId && byKey.get(`hf:${target.hfId}`)) || []),
484
+ ...(byKey.get(`id:${target.domId ?? target.id}`) ?? []),
485
+ ];
486
+ return (
487
+ matches.find((node) => getTimelineElementSourceFile(node) === target.sourceFile) ??
488
+ matches[0] ??
489
+ null
490
+ );
491
+ };
492
+ }
493
+
494
+ export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
495
+ if (!clip.id) return null;
496
+ const first = doc.getElementById(clip.id);
497
+ if (!first || nodeInClipScope(first, clip)) return first;
498
+ return findInClipScope(doc, clip, [`[id="${CSS.escape(clip.id)}"]`]);
499
+ }
500
+
501
+ function findTimelineDomNode(doc: Document, clip: ClipManifestClip): Element | null {
502
+ if (!clip.id) return null;
503
+ const first = doc.getElementById(clip.id);
504
+ if (first && nodeInClipScope(first, clip)) return first;
505
+ const id = CSS.escape(clip.id);
506
+ const byOtherKeys = [`[data-hf-id="${id}"]`, `[data-composition-id="${id}"]`, `.${id}`];
507
+ return findInClipScope(doc, clip, first ? [`[id="${id}"]`, ...byOtherKeys] : byOtherKeys);
439
508
  }
440
509
 
441
510
  export function findTimelineDomNodeForClip(
@@ -445,7 +514,7 @@ export function findTimelineDomNodeForClip(
445
514
  usedNodes = new Set<Element>(),
446
515
  getCandidates = () => getTimelineDomNodes(doc),
447
516
  ): Element | null {
448
- const byIdentity = clip.id ? findTimelineDomNode(doc, clip.id) : null;
517
+ const byIdentity = findTimelineDomNode(doc, clip);
449
518
  if (byIdentity && !usedNodes.has(byIdentity) && nodeMatchesClipTag(byIdentity, clip))
450
519
  return byIdentity;
451
520
 
@@ -17,6 +17,31 @@ function makeDoc(html: string): Document {
17
17
  }
18
18
 
19
19
  describe("buildMissingCompositionElements — hfId (R7)", () => {
20
+ it("labels a row from its own host when a sub-composition repeats the host's id", () => {
21
+ const doc = makeDoc(`
22
+ <div data-composition-id="main">
23
+ <div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="scene"></div></div>
24
+ <div id="scene" data-hf-id="hf-scene" data-composition-id="scene" data-composition-file="compositions/scene.html"></div>
25
+ </div>
26
+ `);
27
+ const row = {
28
+ id: "scene",
29
+ key: "scene",
30
+ tag: "div",
31
+ start: 0,
32
+ duration: 4,
33
+ track: 0,
34
+ hfId: "hf-scene",
35
+ };
36
+ const { updatedEls, patched } = buildMissingCompositionElements(
37
+ doc,
38
+ window as IframeWindow,
39
+ [row],
40
+ 10,
41
+ );
42
+ expect([patched, updatedEls[0]?.compositionSrc]).toEqual([true, "compositions/scene.html"]);
43
+ });
44
+
20
45
  it("harvests hfId from data-hf-id on composition host elements", () => {
21
46
  const doc = makeDoc(`
22
47
  <div data-composition-id="root">
@@ -104,7 +129,7 @@ describe("scrubPreviewAudio", () => {
104
129
  audio.pause = vi.fn();
105
130
  iframe.contentDocument.body.append(audio);
106
131
 
107
- scrubPreviewAudio(iframe, 0.5, "music", 0.4);
132
+ scrubPreviewAudio(iframe, 0.5, { id: "music" }, 0.4);
108
133
 
109
134
  expect(audio.volume).toBeCloseTo(0.1);
110
135
  stopScrubPreviewAudio();
@@ -112,13 +137,13 @@ describe("scrubPreviewAudio", () => {
112
137
 
113
138
  /**
114
139
  * The preview document is a different realm, so `instanceof HTMLAudioElement`
115
- * is false for every node in it. That threw the `musicId` hint away and left
140
+ * is false for every node in it. That threw the `music` hint away and left
116
141
  * the first `<audio>` in the document as the only route — and the first
117
142
  * `<audio>` is often the voiceover, so scrubbing previewed the wrong track.
118
143
  * Two elements, music second, is what tells the two paths apart: with one
119
144
  * element the fallback reaches the right node by accident.
120
145
  */
121
- it("previews the track named by musicId, not the first audio in the document", () => {
146
+ it("previews the track named by the music row, not the first audio in the document", () => {
122
147
  const iframe = document.createElement("iframe");
123
148
  document.body.append(iframe);
124
149
  const previewDoc = iframe.contentDocument;
@@ -139,13 +164,32 @@ describe("scrubPreviewAudio", () => {
139
164
  // The node really is cross-realm; this is the condition, not a contrivance.
140
165
  expect(music instanceof HTMLAudioElement).toBe(false);
141
166
 
142
- scrubPreviewAudio(iframe, 0.5, "music-bed", 1);
167
+ scrubPreviewAudio(iframe, 0.5, { id: "music-bed" }, 1);
143
168
 
144
169
  expect(music.play).toHaveBeenCalled();
145
170
  expect(voiceover.play).not.toHaveBeenCalled();
146
171
  stopScrubPreviewAudio();
147
172
  });
148
173
 
174
+ it("previews the music row's own track when a sub-composition repeats its id", () => {
175
+ const iframe = document.createElement("iframe");
176
+ document.body.append(iframe);
177
+ const previewDoc = iframe.contentDocument;
178
+ if (!previewDoc?.body) throw new Error("expected an iframe document");
179
+ previewDoc.body.innerHTML =
180
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><audio id="music"></audio></div>' +
181
+ '<audio id="music" class="root"></audio>';
182
+ const [inner, root] = Array.from(previewDoc.querySelectorAll("audio"));
183
+ for (const audio of [inner, root])
184
+ Object.assign(audio!, { play: vi.fn(async () => {}), pause: vi.fn() });
185
+
186
+ scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
187
+
188
+ expect(root!.play).toHaveBeenCalled();
189
+ expect(inner!.play).not.toHaveBeenCalled();
190
+ stopScrubPreviewAudio();
191
+ });
192
+
149
193
  /** A scrub audition is media running under a paused clock, which the runtime now
150
194
  * stops on sight. So it borrows the element. That a leased element survives the
151
195
  * tick is asserted runtime-side in core's `transportPark.test.ts`; here the
@@ -166,7 +210,7 @@ describe("scrubPreviewAudio", () => {
166
210
  const releasePausedMedia = vi.fn();
167
211
  (previewDoc.defaultView as IframeWindow).__hf = { leasePausedMedia, releasePausedMedia };
168
212
 
169
- scrubPreviewAudio(iframe, 0.5, "music", 1);
213
+ scrubPreviewAudio(iframe, 0.5, { id: "music" }, 1);
170
214
 
171
215
  expect(leasePausedMedia).toHaveBeenCalledWith(music);
172
216
  expect(releasePausedMedia).not.toHaveBeenCalled();
@@ -20,6 +20,8 @@ import {
20
20
  getTimelineElementDisplayLabel,
21
21
  buildTimelineElementIdentity,
22
22
  readTimelineElementZIndex,
23
+ previewElementFinder,
24
+ type PreviewTarget,
23
25
  } from "./timelineElementHelpers";
24
26
  import { postRuntimeControlMessage } from "./runtimeProtocol";
25
27
  import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
@@ -216,14 +218,14 @@ let scrubStopTimer: ReturnType<typeof setTimeout> | null = null;
216
218
  let scrubPrevMuted: boolean | null = null;
217
219
  let scrubPrevVolume: number | null = null;
218
220
 
219
- // Resolve the SAME element the store identified as music: prefer its id, then
220
- // the role attribute, and only fall back to the first <audio> (which could be a
221
- // voiceover, so the id hint matters).
221
+ // Resolve the SAME element the store identified as music: prefer that row's own
222
+ // element, then the role attribute, and only fall back to the first <audio>
223
+ // (which could be a voiceover, so the row hint matters).
222
224
  /**
223
225
  * `doc` is the preview iframe's document, so its `<audio>` nodes are instances of
224
226
  * the IFRAME's `HTMLAudioElement`, never this module's. `instanceof
225
227
  * HTMLAudioElement` here is false for every one of them, which silently threw the
226
- * `musicId` hint away and fell through to "first `<audio>` in the document" — the
228
+ * `music` hint away and fell through to "first `<audio>` in the document" — the
227
229
  * very thing the comment above warns can be a voiceover. Ask what the node IS.
228
230
  * Same rule and same reasoning as packages/core/src/runtime/domRealm.ts.
229
231
  */
@@ -235,9 +237,9 @@ function isAudioNode(node: Element | null): node is HTMLAudioElement {
235
237
  );
236
238
  }
237
239
 
238
- function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioElement | null {
239
- if (musicId) {
240
- const byId = doc.getElementById(musicId);
240
+ function resolveScrubAudioEl(doc: Document, music?: PreviewTarget | null): HTMLAudioElement | null {
241
+ if (music) {
242
+ const byId = previewElementFinder(doc, "audio")(music);
241
243
  if (isAudioNode(byId)) return byId;
242
244
  }
243
245
  return (
@@ -283,7 +285,7 @@ function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume:
283
285
  export function scrubPreviewAudio(
284
286
  iframe: HTMLIFrameElement | null,
285
287
  audioFileTime: number | null,
286
- musicId?: string | null,
288
+ music?: PreviewTarget | null,
287
289
  previewVolume = 1,
288
290
  ): void {
289
291
  if (!iframe) return;
@@ -298,7 +300,7 @@ export function scrubPreviewAudio(
298
300
  return;
299
301
  }
300
302
  if (!doc) return;
301
- const el = resolveScrubAudioEl(doc, musicId);
303
+ const el = resolveScrubAudioEl(doc, music);
302
304
  if (el) applyScrub(el, audioFileTime, previewVolume);
303
305
  }
304
306
 
@@ -512,12 +514,11 @@ export function buildMissingCompositionElements(
512
514
 
513
515
  // Patch existing elements that are missing compositionSrc
514
516
  let patched = false;
517
+ const findHost = previewElementFinder(doc);
515
518
  const updatedEls = (currentEls as TimelineElement[]).map((existing) => {
516
519
  if (existing.compositionSrc) return existing;
517
- // Find the matching DOM host by element id or composition id
518
520
  const host =
519
- doc.getElementById(existing.id) ??
520
- doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
521
+ findHost(existing) ?? doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
521
522
  if (!host) return existing;
522
523
  const compSrc =
523
524
  host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { describe, it, expect, beforeEach, vi } from "vitest";
3
- import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
+ import { usePlayerStore, liveTime, whenPreviewBooted, type TimelineElement } from "./playerStore";
4
4
  import { thumbnailRevisionOf } from "./thumbnailSlice";
5
5
 
6
6
  /** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
@@ -343,9 +343,20 @@ describe("usePlayerStore", () => {
343
343
  const video = doc.createElement("video");
344
344
  Object.defineProperty(video, "readyState", { value: 0, configurable: true });
345
345
  doc.body.appendChild(video);
346
+ usePlayerStore.getState().markPreviewLoadStep(doc);
346
347
  return { doc, video };
347
348
  }
348
349
 
350
+ it("waits for the document's load step before marking it ready", async () => {
351
+ const doc = document.implementation.createHTMLDocument("composition");
352
+ usePlayerStore.getState().requestTimelineReady(doc);
353
+ await new Promise((resolve) => setTimeout(resolve, 50));
354
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
355
+
356
+ usePlayerStore.getState().markPreviewLoadStep(doc);
357
+ await vi.waitFor(() => expect(usePlayerStore.getState().timelineReady).toBe(true));
358
+ });
359
+
349
360
  it("resolves timelineReady once the doc's media settles", async () => {
350
361
  const { doc, video } = stalledVideoDoc();
351
362
  usePlayerStore.getState().requestTimelineReady(doc);
@@ -748,3 +759,26 @@ describe("liveTime", () => {
748
759
  expect(result).toBe(false);
749
760
  });
750
761
  });
762
+
763
+ describe("whenPreviewBooted", () => {
764
+ it("resolves false for a project the user left before its preview booted", async () => {
765
+ usePlayerStore.setState({ timelineProjectId: "project-a", previewBooted: false });
766
+ const waitA = whenPreviewBooted("project-a");
767
+
768
+ usePlayerStore.getState().beginTimelineSession("project-b");
769
+ usePlayerStore.getState().markPreviewBooted();
770
+
771
+ await expect(waitA).resolves.toBe(false);
772
+ await expect(whenPreviewBooted("project-b")).resolves.toBe(true);
773
+ });
774
+
775
+ it("waits through the open of its own project, then resolves on that project's boot", async () => {
776
+ usePlayerStore.setState({ timelineProjectId: "project-a", previewBooted: true });
777
+ const waitB = whenPreviewBooted("project-b");
778
+
779
+ usePlayerStore.getState().beginTimelineSession("project-b");
780
+ usePlayerStore.getState().markPreviewBooted();
781
+
782
+ await expect(waitB).resolves.toBe(true);
783
+ });
784
+ });
@@ -551,3 +551,32 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
551
551
  }));
552
552
 
553
553
  attachPlayerStoreDevHandle(usePlayerStore);
554
+
555
+ export function isPreviewBooted(projectId: string): boolean {
556
+ const { previewBooted, timelineProjectId } = usePlayerStore.getState();
557
+ return previewBooted && timelineProjectId === projectId;
558
+ }
559
+
560
+ /** True once projectId's live preview has booted, false once another project replaces it.
561
+ * Open-time work the first frame does not need (server parses, lint) waits on it. */
562
+ export function whenPreviewBooted(projectId: string): Promise<boolean> {
563
+ const openedFrom = usePlayerStore.getState().timelineProjectId;
564
+ let seen = false;
565
+ const settle = (state: PlayerState): boolean | null => {
566
+ if (state.timelineProjectId === projectId) {
567
+ seen = true;
568
+ return state.previewBooted ? true : null;
569
+ }
570
+ return seen || state.timelineProjectId !== openedFrom ? false : null;
571
+ };
572
+ return new Promise((resolve) => {
573
+ const now = settle(usePlayerStore.getState());
574
+ if (now !== null) return resolve(now);
575
+ const stop = usePlayerStore.subscribe((state) => {
576
+ const result = settle(state);
577
+ if (result === null) return;
578
+ stop();
579
+ resolve(result);
580
+ });
581
+ });
582
+ }
@@ -8,26 +8,45 @@ import { settleCompositionReadiness } from "@hyperframes/core/composition-readin
8
8
 
9
9
  export interface PlaybackReadinessSlice {
10
10
  timelineReady: boolean;
11
+ /** Latched when the project's first preview shows its first frame (or fails), kept through edit
12
+ * reloads, so work that must not compete with the boot waits for it once. */
13
+ previewBooted: boolean;
11
14
  setTimelineReady: (ready: boolean) => void;
15
+ markPreviewBooted: () => void;
12
16
  /** Sets timelineReady once doc's readiness inputs settle, or immediately
13
- * if doc is null. A wait a later call supersedes never wins the race. */
17
+ * if doc is null. A wait a later call supersedes never wins the race.
18
+ * Waits for doc's load step first: it pauses and rewinds the preview, so a
19
+ * Play enabled before it would be undone. */
14
20
  requestTimelineReady: (doc: Document | null) => void;
21
+ /** Called by the preview's load step for the document it ran on. */
22
+ markPreviewLoadStep: (doc: Document) => void;
15
23
  }
16
24
 
17
25
  let timelineReadyGeneration = 0;
26
+ const loadStepDocs = new WeakSet<Document>();
27
+ let awaitingLoadStep: { doc: Document; generation: number } | null = null;
18
28
 
19
29
  /** For a full timeline reset: bumps the generation so any requestTimelineReady
20
30
  * wait in flight can never resolve into what replaced it. */
21
- export function resetPlaybackReadinessState(): Pick<PlaybackReadinessSlice, "timelineReady"> {
31
+ export function resetPlaybackReadinessState(): Pick<
32
+ PlaybackReadinessSlice,
33
+ "timelineReady" | "previewBooted"
34
+ > {
22
35
  timelineReadyGeneration++;
23
- return { timelineReady: false };
36
+ return { timelineReady: false, previewBooted: false };
24
37
  }
25
38
 
26
39
  export function createPlaybackReadinessSlice(
27
40
  set: StoreApi<PlaybackReadinessSlice>["setState"],
28
41
  ): PlaybackReadinessSlice {
42
+ const settle = (doc: Document, generation: number) =>
43
+ settleCompositionReadiness(doc, () => {
44
+ if (generation === timelineReadyGeneration) set({ timelineReady: true });
45
+ });
29
46
  return {
30
47
  timelineReady: false,
48
+ previewBooted: false,
49
+ markPreviewBooted: () => set({ previewBooted: true }),
31
50
  setTimelineReady: (ready) => {
32
51
  timelineReadyGeneration++;
33
52
  set({ timelineReady: ready });
@@ -35,9 +54,18 @@ export function createPlaybackReadinessSlice(
35
54
  requestTimelineReady: (doc) => {
36
55
  const generation = ++timelineReadyGeneration;
37
56
  if (!doc) return set({ timelineReady: true });
38
- settleCompositionReadiness(doc, () => {
39
- if (generation === timelineReadyGeneration) set({ timelineReady: true });
40
- });
57
+ if (!loadStepDocs.has(doc)) {
58
+ awaitingLoadStep = { doc, generation };
59
+ return;
60
+ }
61
+ settle(doc, generation);
62
+ },
63
+ markPreviewLoadStep: (doc) => {
64
+ loadStepDocs.add(doc);
65
+ if (awaitingLoadStep?.doc !== doc) return;
66
+ const { generation } = awaitingLoadStep;
67
+ awaitingLoadStep = null;
68
+ settle(doc, generation);
41
69
  },
42
70
  };
43
71
  }
@@ -9,7 +9,6 @@ import { collectHtmlIds } from "./studioHelpers";
9
9
  import { generateId } from "./generateId";
10
10
  import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
11
11
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
- import type { EditHistoryKind } from "./editHistory";
13
12
  import { extendRootDurationInSource } from "./rootDuration";
14
13
  import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
15
14
 
@@ -41,7 +40,6 @@ interface AddBlockOptions {
41
40
  writeProjectFile: (path: string, content: string) => Promise<void>;
42
41
  recordEdit: (entry: {
43
42
  label: string;
44
- kind: EditHistoryKind;
45
43
  coalesceKey?: string;
46
44
  files: Record<string, { before: string; after: string }>;
47
45
  }) => Promise<void>;
@@ -234,7 +232,6 @@ export async function addBlockToProject(
234
232
  await saveProjectFilesWithHistory({
235
233
  projectId,
236
234
  label: `Add ${isBlock ? "block" : "component"}: ${block.title}`,
237
- kind: "timeline",
238
235
  files: { [targetPath]: patchedContent },
239
236
  readFile: async () => originalContent,
240
237
  writeFile: writeProjectFile,
@@ -0,0 +1,22 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { prefetchPreviewForHash } from "./previewPrefetch";
4
+
5
+ describe("prefetchPreviewForHash", () => {
6
+ afterEach(() => vi.unstubAllGlobals());
7
+
8
+ it("requests the opening project's preview once, not on every hash update", () => {
9
+ const fetchStub = vi.fn(() => Promise.resolve(new Response("")));
10
+ vi.stubGlobal("fetch", fetchStub);
11
+
12
+ prefetchPreviewForHash("#project/demo");
13
+ prefetchPreviewForHash("#project/demo?t=2&tab=design");
14
+ prefetchPreviewForHash("#project/other");
15
+ prefetchPreviewForHash("#settings");
16
+
17
+ expect(fetchStub.mock.calls.map(([url]) => new URL(String(url)).pathname)).toEqual([
18
+ "/api/projects/demo/preview",
19
+ "/api/projects/other/preview",
20
+ ]);
21
+ });
22
+ });
@@ -0,0 +1,14 @@
1
+ import { applyPreviewVariablesToUrl } from "../hooks/previewVariablesStore";
2
+ import { buildProjectApiPath, parseProjectIdFromHash } from "./projectRouting";
3
+
4
+ let prefetched: string | null = null;
5
+
6
+ /** Requests the URL's project preview while Studio boots, so the server builds it in parallel. */
7
+ export function prefetchPreviewForHash(hash: string): void {
8
+ const projectId = parseProjectIdFromHash(hash);
9
+ if (!projectId || projectId === prefetched) return;
10
+ prefetched = projectId;
11
+ const url = new URL(buildProjectApiPath(projectId, "/preview"), window.location.origin);
12
+ applyPreviewVariablesToUrl(url);
13
+ fetch(url).catch(() => undefined);
14
+ }
@@ -133,7 +133,6 @@ describe("runAtomicCutTransaction", () => {
133
133
  expect(writeProjectFile).not.toHaveBeenCalled();
134
134
  expect(recordEdit).toHaveBeenCalledWith({
135
135
  label: "Split timeline clip",
136
- kind: "timeline",
137
136
  files: { "index.html": { before: "before", after: "after" } },
138
137
  });
139
138
  expect(observe).toHaveBeenCalledWith("index.html", '"v1"');
@@ -181,7 +181,7 @@ export function runAtomicCutTransaction(input: RunAtomicCutInput): Promise<Atomi
181
181
  result.files.map((file) => [file.path, { before: file.before, after: file.after }]),
182
182
  );
183
183
  try {
184
- await input.recordEdit({ label: input.label, kind: "timeline", files: snapshots });
184
+ await input.recordEdit({ label: input.label, files: snapshots });
185
185
  } catch (error) {
186
186
  try {
187
187
  await rollbackUnrecordedCut(result.files, input.writeProjectFile);
@@ -1,5 +1,4 @@
1
1
  import { openComposition, type Composition } from "@hyperframes/sdk";
2
- import type { EditHistoryKind } from "./editHistory";
3
2
  import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
4
3
  import { trackStudioEvent } from "./studioTelemetry";
5
4
  import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
@@ -32,7 +31,6 @@ export interface CutoverDeps {
32
31
  editHistory: {
33
32
  recordEdit: (entry: {
34
33
  label: string;
35
- kind: EditHistoryKind;
36
34
  coalesceKey?: string;
37
35
  coalesceMs?: number;
38
36
  files: Record<string, { before: string; after: string }>;
@@ -248,7 +246,6 @@ async function writeAndRecord(
248
246
  try {
249
247
  await deps.editHistory.recordEdit({
250
248
  label: options?.label ?? "Edit layer",
251
- kind: "manual",
252
249
  ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}),
253
250
  ...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}),
254
251
  files: { [targetPath]: { before: originalContent, after } },