@hyperframes/studio 0.8.76 → 0.8.78

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 (44) hide show
  1. package/dist/assets/hyperframes-player-BdnQGwG5.js +464 -0
  2. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  3. package/dist/assets/{index-DeHir1up.js → index-CX_4MYbk.js} +148 -148
  4. package/dist/assets/{index-FmKUmYhC.js → index-C_n__Uh3.js} +1 -1
  5. package/dist/assets/{index-COX0scwJ.js → index-DdpuHaGN.js} +1 -1
  6. package/dist/{chunk-IYDE66BY.js → chunk-5VANA2TG.js} +7 -1
  7. package/dist/{chunk-IYDE66BY.js.map → chunk-5VANA2TG.js.map} +1 -1
  8. package/dist/{domEditingLayers-2XBFXXSW.js → domEditingLayers-IGPLTI5E.js} +2 -2
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +4132 -4010
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -7
  15. package/src/components/editor/domEditingElement.ts +13 -0
  16. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  17. package/src/components/editor/gsapLivePreview.ts +2 -14
  18. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  19. package/src/components/nle/NLEPreview.test.ts +110 -7
  20. package/src/components/nle/NLEPreview.tsx +51 -46
  21. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  22. package/src/components/nle/previewZoom.test.ts +42 -0
  23. package/src/components/nle/previewZoom.ts +33 -0
  24. package/src/hooks/useDomEditWiring.ts +1 -3
  25. package/src/hooks/useGestureRecording.test.tsx +1 -1
  26. package/src/hooks/useGestureRecording.ts +6 -10
  27. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  28. package/src/hooks/useGsapTweenCache.ts +5 -13
  29. package/src/index.ts +7 -0
  30. package/src/player/lib/automationStoreSync.test.ts +24 -0
  31. package/src/player/lib/automationStoreSync.ts +3 -9
  32. package/src/player/lib/playbackScrub.ts +1 -1
  33. package/src/player/lib/timelineDOM.test.ts +28 -0
  34. package/src/player/lib/timelineDOM.ts +4 -2
  35. package/src/player/lib/timelineElementHelpers.ts +33 -0
  36. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  37. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  38. package/src/telemetry/client.test.ts +26 -0
  39. package/src/telemetry/policy.test.ts +10 -0
  40. package/src/telemetry/policy.ts +7 -0
  41. package/src/utils/studioUiPreferences.test.ts +9 -3
  42. package/src/utils/studioUiPreferences.ts +0 -20
  43. package/dist/assets/hyperframes-player-BkDxgSKG.js +0 -464
  44. /package/dist/{domEditingLayers-2XBFXXSW.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
@@ -17,6 +17,12 @@ export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = {
17
17
  const ZOOM_SENSITIVITY = 0.007;
18
18
  const MAX_DELTA = 10;
19
19
 
20
+ export const isFitZoom = (zoomPercent: number) => Math.abs(zoomPercent - 100) < 0.5;
21
+
22
+ export function isPreviewAtFit(state: PreviewZoomState): boolean {
23
+ return isFitZoom(state.zoomPercent) && Math.abs(state.panX) < 0.1 && Math.abs(state.panY) < 0.1;
24
+ }
25
+
20
26
  export function toDomPrecision(value: number): number {
21
27
  return Math.round(value * 10000) / 10000;
22
28
  }
@@ -162,3 +168,30 @@ export function resolvePreviewWheelPan(input: {
162
168
  ...pan,
163
169
  };
164
170
  }
171
+
172
+ export interface PreviewVisibleRegion {
173
+ left: number;
174
+ top: number;
175
+ width: number;
176
+ height: number;
177
+ }
178
+
179
+ export function resolvePreviewVisibleRegion(input: {
180
+ state: PreviewZoomState;
181
+ viewportWidth: number;
182
+ viewportHeight: number;
183
+ contentWidth: number;
184
+ contentHeight: number;
185
+ }): PreviewVisibleRegion {
186
+ const scale = clampPreviewZoomPercent(input.state.zoomPercent) / 100;
187
+ const axis = (pan: number, view: number, content: number): [number, number] => {
188
+ const size = content * scale;
189
+ if (!(size > 0)) return [0, 1];
190
+ const at = (edge: number) => Math.min(1, Math.max(0, (edge - pan + size / 2) / size));
191
+ const start = at(-view / 2);
192
+ return [start, at(view / 2) - start];
193
+ };
194
+ const [left, width] = axis(input.state.panX, input.viewportWidth, input.contentWidth);
195
+ const [top, height] = axis(input.state.panY, input.viewportHeight, input.contentHeight);
196
+ return { left, top, width, height };
197
+ }
@@ -207,9 +207,7 @@ export function useDomEditWiring(params: UseDomEditWiringParams) {
207
207
  } = useGsapAnimationsForElement(
208
208
  projectId ?? null,
209
209
  gsapSourceFile,
210
- domEditSelection
211
- ? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
212
- : null,
210
+ domEditSelection,
213
211
  gsapCacheVersion,
214
212
  // Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
215
213
  // the live element and surface in the inspector — not just by #id match.
@@ -109,7 +109,7 @@ describe("useGestureRecording", () => {
109
109
  act(() => root.unmount());
110
110
 
111
111
  expect(cancelAnimationFrame).toHaveBeenCalledWith(17);
112
- expect(set).toHaveBeenCalledWith("#card", {
112
+ expect(set).toHaveBeenCalledWith(element, {
113
113
  clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
114
114
  });
115
115
  expect(element.style.visibility).toBe("hidden");
@@ -30,8 +30,7 @@ interface BasePosition {
30
30
 
31
31
  interface GsapRuntime {
32
32
  timeline: { seek: (t: number) => void };
33
- gsap: { set: (target: string, vars: Record<string, number | string>) => void };
34
- selector: string;
33
+ gsap: { set: (target: Element, vars: Record<string, number | string>) => void };
35
34
  element: HTMLElement;
36
35
  startTime: number;
37
36
  maxSeekTime: number;
@@ -81,12 +80,11 @@ function readBasePosition(element: HTMLElement, iframeEl: HTMLIFrameElement): Ba
81
80
  function connectGsapRuntime(
82
81
  element: HTMLElement,
83
82
  iframeEl: HTMLIFrameElement,
84
- selector: string | null,
85
83
  elementEndTime: number | undefined,
86
84
  ): GsapRuntime | null {
87
85
  try {
88
86
  const win = iframeEl.contentWindow as Window & {
89
- gsap?: { set: (t: string, v: Record<string, number | string>) => void };
87
+ gsap?: { set: (t: Element, v: Record<string, number | string>) => void };
90
88
  __timelines?: Record<string, { seek: (t: number) => void; duration: () => number }>;
91
89
  __player?: { getTime: () => number };
92
90
  };
@@ -98,12 +96,11 @@ function connectGsapRuntime(
98
96
  ([key, value]) => key !== "__proxied" && typeof value?.seek === "function",
99
97
  )?.[1] ?? null)
100
98
  : null;
101
- if (win?.gsap?.set && tl?.seek && selector) {
99
+ if (win?.gsap?.set && tl?.seek && element.id) {
102
100
  const tlDuration = tl.duration();
103
101
  return {
104
102
  timeline: tl,
105
103
  gsap: win.gsap,
106
- selector,
107
104
  element,
108
105
  startTime: win.__player?.getTime() ?? 0,
109
106
  maxSeekTime:
@@ -126,7 +123,7 @@ function applyRuntimePreview(
126
123
  const seekTime = Math.min(runtime.startTime + time, runtime.maxSeekTime);
127
124
  runtime.timeline.seek(seekTime);
128
125
  runtime.element.style.setProperty("translate", "none");
129
- runtime.gsap.set(runtime.selector, { ...properties });
126
+ runtime.gsap.set(runtime.element, { ...properties });
130
127
  runtime.element.style.visibility = "visible";
131
128
  liveTime.notify(seekTime);
132
129
  usePlayerStore.getState().setCurrentTime(seekTime);
@@ -256,7 +253,7 @@ function releaseRuntimePreview(r: RecordingRefs): void {
256
253
  element.style.visibility = savedVisibility;
257
254
  element.style.setProperty("translate", savedTranslate || "");
258
255
  try {
259
- runtime.gsap.set(runtime.selector, {
256
+ runtime.gsap.set(runtime.element, {
260
257
  clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z",
261
258
  });
262
259
  } catch {
@@ -324,8 +321,7 @@ export function useGestureRecording() {
324
321
  r.scale = computeIframeScale(iframeEl);
325
322
 
326
323
  // --- Phase 3: Connect to the iframe GSAP runtime ---
327
- const selector = element.id ? `#${element.id}` : null;
328
- r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime);
324
+ r.runtime = connectGsapRuntime(element, iframeEl, elementEndTime);
329
325
  // Clear the optimistic path offset only while a live runtime owns the
330
326
  // preview. releaseRuntimePreview restores it on every exit path.
331
327
  if (r.runtime && (base.cssOffX || base.cssOffY)) {
@@ -0,0 +1,39 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { expect, it, vi } from "vitest";
4
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
5
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
+ import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
7
+
8
+ installReactActEnvironment();
9
+
10
+ const dotTween = {
11
+ id: "dots",
12
+ targetSelector: ".dot",
13
+ method: "from",
14
+ position: 0,
15
+ properties: { opacity: 0 },
16
+ } as GsapAnimation;
17
+ vi.mock("./keyframeCacheAstLoad", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("./keyframeCacheAstLoad")>()),
19
+ fetchParsedAnimations: async () => ({ animations: [dotTween] }),
20
+ }));
21
+ const { useGsapAnimationsForElement } = await import("./useGsapTweenCache");
22
+
23
+ it("attributes a class tween by the selected element, not an earlier same-id copy in a sub-composition", async () => {
24
+ document.body.innerHTML =
25
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card"></div></div>' +
26
+ '<div id="card" class="dot"></div>';
27
+ const element = document.querySelectorAll<HTMLElement>("#card")[1];
28
+ const selection = { id: "card", sourceFile: "index.html", element } as DomEditSelection;
29
+ const iframeRef = { current: { contentDocument: document } as unknown as HTMLIFrameElement };
30
+ let animations: GsapAnimation[] = [];
31
+ function Harness() {
32
+ animations = useGsapAnimationsForElement("p", "index.html", selection, 0, iframeRef).animations;
33
+ return null;
34
+ }
35
+ const root = mountReactHarness(<Harness />);
36
+ await act(async () => {});
37
+ expect(animations.map((animation) => animation.id)).toEqual(["dots"]);
38
+ act(() => root.unmount());
39
+ });
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
2
  import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import { findPreviewNode } from "../components/editor/domEditingElement";
4
6
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
5
7
  import {
6
8
  clearKeyframeCacheForElement,
@@ -73,7 +75,7 @@ export function getAnimationsForElement(
73
75
  export function useGsapAnimationsForElement(
74
76
  projectId: string | null,
75
77
  sourceFile: string,
76
- target: GsapElementTarget | null,
78
+ target: DomEditSelection | null,
77
79
  version: number,
78
80
  iframeRef?: React.RefObject<HTMLIFrameElement | null>,
79
81
  ): {
@@ -157,24 +159,14 @@ export function useGsapAnimationsForElement(
157
159
  // gsap.from(".dot", {stagger})) attribute to every matching element, not
158
160
  // just the one whose exact selector equals the tween's. `version` re-runs
159
161
  // this after composition reloads.
160
- let element: Element | null = null;
161
- const doc = iframeRef?.current?.contentDocument;
162
- if (doc) {
163
- try {
164
- element =
165
- (targetId ? doc.getElementById(targetId) : null) ??
166
- (targetSelector ? doc.querySelector(targetSelector) : null);
167
- } catch {
168
- element = null;
169
- }
170
- }
162
+ const element = target ? findPreviewNode(iframeRef?.current?.contentDocument, target) : null;
171
163
  return getAnimationsForElement(
172
164
  allAnimations,
173
165
  { id: targetId, selector: targetSelector },
174
166
  element,
175
167
  );
176
168
  // eslint-disable-next-line react-hooks/exhaustive-deps
177
- }, [allAnimations, targetId, targetSelector, version, iframeRef]);
169
+ }, [allAnimations, target, version, iframeRef]);
178
170
 
179
171
  // fallow-ignore-next-line complexity
180
172
  const animations = useMemo(() => {
package/src/index.ts CHANGED
@@ -183,3 +183,10 @@ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
183
183
  export { TimelineEditProvider } from "./contexts/TimelineEditContext";
184
184
  export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
185
185
  export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";
186
+
187
+ export {
188
+ playSeamTransitionLoop,
189
+ type SeamTransitionFrameSource,
190
+ type SeamTransitionLoopHandle,
191
+ type SeamTransitionLoopOptions,
192
+ } from "@hyperframes/shader-transitions";
@@ -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") ??
@@ -458,6 +458,39 @@ function findInClipScope(
458
458
  return lone;
459
459
  }
460
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
+
461
494
  export function findClipElementById(doc: Document, clip: ClipManifestClip): Element | null {
462
495
  if (!clip.id) return null;
463
496
  const first = doc.getElementById(clip.id);
@@ -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");
@@ -30,6 +30,7 @@ describe("studio client shouldTrack", () => {
30
30
  setDev(false);
31
31
  setNoTelemetry(undefined);
32
32
  localStorage.clear();
33
+ delete window.__HF_CLI_TELEMETRY_DISABLED;
33
34
  vi.unstubAllGlobals();
34
35
  });
35
36
 
@@ -89,3 +90,28 @@ describe("studio client shouldTrack", () => {
89
90
  expect(shouldTrack()).toBe(false);
90
91
  });
91
92
  });
93
+
94
+ it("suppresses actual render events in a CLI-opted-out Studio while the enabled control sends", async () => {
95
+ vi.useFakeTimers();
96
+ vi.resetModules();
97
+ setDev(false);
98
+ setNoTelemetry(undefined);
99
+ localStorage.clear();
100
+ const send = vi.fn().mockResolvedValue(new Response());
101
+ vi.stubGlobal("fetch", send);
102
+ try {
103
+ const { trackEvent } = await import("./client");
104
+ window.__HF_CLI_TELEMETRY_DISABLED = true;
105
+ trackEvent("render_complete", { duration: 1 });
106
+ await vi.advanceTimersByTimeAsync(1100);
107
+ expect(send).not.toHaveBeenCalled();
108
+ window.__HF_CLI_TELEMETRY_DISABLED = false;
109
+ trackEvent("render_complete", { duration: 1 });
110
+ await vi.advanceTimersByTimeAsync(1100);
111
+ expect(send).toHaveBeenCalledOnce();
112
+ } finally {
113
+ delete window.__HF_CLI_TELEMETRY_DISABLED;
114
+ vi.useRealTimers();
115
+ vi.unstubAllGlobals();
116
+ }
117
+ });
@@ -15,6 +15,7 @@ describe("browserTelemetryAllowed", () => {
15
15
 
16
16
  beforeEach(async () => {
17
17
  localStorage.clear();
18
+ delete window.__HF_CLI_TELEMETRY_DISABLED;
18
19
  vi.resetModules();
19
20
  // vitest sets import.meta.env.DEV; the policy suppresses under it, so the
20
21
  // baseline has to be an explicitly production-like env.
@@ -28,6 +29,15 @@ describe("browserTelemetryAllowed", () => {
28
29
  vi.unstubAllEnvs();
29
30
  });
30
31
 
32
+ it("refuses a CLI opt-out even when browser storage permits telemetry", () => {
33
+ expect(browserTelemetryAllowed()).toBe(true);
34
+ window.__HF_CLI_TELEMETRY_DISABLED = true;
35
+ expect(browserTelemetryAllowed()).toBe(false);
36
+ expect(localStorage.getItem(DOCUMENTED_OPT_OUT)).toBeNull();
37
+ window.__HF_CLI_TELEMETRY_DISABLED = false;
38
+ expect(browserTelemetryAllowed()).toBe(true);
39
+ });
40
+
31
41
  it("allows telemetry with no control set", () => {
32
42
  expect(browserTelemetryAllowed()).toBe(true);
33
43
  });
@@ -92,10 +92,17 @@ export function browserTelemetryAllowed(): boolean {
92
92
  }
93
93
  }
94
94
 
95
+ declare global {
96
+ interface Window {
97
+ __HF_CLI_TELEMETRY_DISABLED?: boolean;
98
+ }
99
+ }
100
+
95
101
  function allowed(): boolean {
96
102
  return (
97
103
  isApiKeyConfigured() &&
98
104
  !isBuildTimeOptOut() &&
105
+ !(typeof window !== "undefined" && window.__HF_CLI_TELEMETRY_DISABLED === true) &&
99
106
  !isViteDevMode() &&
100
107
  !isOptedOut() &&
101
108
  !isLegacyOptedOut() &&