@hyperframes/studio 0.8.19 → 0.8.21

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 (45) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-Dd0fsIIL.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-DxccOrkZ.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-Bjd2hioj.js → index-CgGY8dAv.js} +164 -164
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1206 -234
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +2 -0
  11. package/src/contexts/StudioContext.tsx +10 -0
  12. package/src/hooks/useEditorSave.test.tsx +81 -3
  13. package/src/hooks/useEditorSave.ts +31 -6
  14. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
  15. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
  16. package/src/hooks/useStudioContextValue.ts +9 -0
  17. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  18. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  19. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  20. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  21. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  22. package/src/player/store/playerStore.ts +17 -2
  23. package/src/player/store/timelineElement.ts +21 -0
  24. package/src/utils/studioFileVersion.test.ts +37 -1
  25. package/src/utils/studioFileVersion.ts +12 -2
  26. package/src/utils/studioSaveDiagnostics.test.ts +29 -0
  27. package/src/utils/studioSaveDiagnostics.ts +4 -0
  28. package/src/webmcp/StudioAgentTools.tsx +105 -10
  29. package/src/webmcp/toolResult.ts +1 -1
  30. package/src/webmcp/tools/animationTools.test.ts +244 -0
  31. package/src/webmcp/tools/animationTools.ts +276 -0
  32. package/src/webmcp/tools/contentTools.test.ts +275 -0
  33. package/src/webmcp/tools/contentTools.ts +217 -0
  34. package/src/webmcp/tools/frameTools.test.ts +136 -0
  35. package/src/webmcp/tools/frameTools.ts +133 -0
  36. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  37. package/src/webmcp/tools/inspectTools.ts +208 -0
  38. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  39. package/src/webmcp/tools/selectionTools.ts +154 -0
  40. package/src/webmcp/tools/transformTools.test.ts +179 -0
  41. package/src/webmcp/tools/transformTools.ts +205 -0
  42. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  43. package/src/webmcp/useStudioAgentTools.ts +195 -1
  44. package/src/webmcp/webmcpTestUtils.ts +91 -0
  45. package/dist/assets/hyperframes-player-BA-QO9CQ.js +0 -459
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.19",
3
+ "version": "0.8.21",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.19",
52
- "@hyperframes/parsers": "0.8.19",
53
- "@hyperframes/player": "0.8.19",
54
- "@hyperframes/sdk": "0.8.19",
55
- "@hyperframes/studio-server": "0.8.19"
51
+ "@hyperframes/core": "0.8.21",
52
+ "@hyperframes/parsers": "0.8.21",
53
+ "@hyperframes/player": "0.8.21",
54
+ "@hyperframes/sdk": "0.8.21",
55
+ "@hyperframes/studio-server": "0.8.21"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.19"
72
+ "@hyperframes/producer": "0.8.21"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -433,6 +433,8 @@ export function StudioApp() {
433
433
  handleRedo: appHotkeys.handleRedo,
434
434
  renderQueue,
435
435
  compositionDimensions,
436
+ domEditSaveQueuePaused: previewPersistence.domEditSaveQueuePaused,
437
+ externalFileConflict: externalFileChanges.blocked !== null,
436
438
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
437
439
  handlePreviewIframeRef,
438
440
  refreshPreviewDocumentVersion,
@@ -16,6 +16,13 @@ export interface StudioShellValue {
16
16
  undoLabel: string | undefined;
17
17
  redoLabel: string | undefined;
18
18
  };
19
+ /**
20
+ * Why a composition write would be refused right now, or null when writes
21
+ * are possible. Derived from the paused save queue and the external-file
22
+ * conflict state, both of which are otherwise banners with no lock behind
23
+ * them. One field rather than two, so there is one owner of the question.
24
+ */
25
+ writeBlockedReason: string | null;
19
26
  handleUndo: () => Promise<void>;
20
27
  handleRedo: () => Promise<void>;
21
28
  renderQueue: {
@@ -106,6 +113,7 @@ export function StudioShellProvider({
106
113
  showToast,
107
114
  previewIframeRef,
108
115
  editHistory,
116
+ writeBlockedReason,
109
117
  handleUndo,
110
118
  handleRedo,
111
119
  renderQueue,
@@ -122,6 +130,7 @@ export function StudioShellProvider({
122
130
  showToast,
123
131
  previewIframeRef,
124
132
  editHistory,
133
+ writeBlockedReason,
125
134
  handleUndo,
126
135
  handleRedo,
127
136
  renderQueue,
@@ -138,6 +147,7 @@ export function StudioShellProvider({
138
147
  setActiveCompPath,
139
148
  showToast,
140
149
  previewIframeRef,
150
+ writeBlockedReason,
141
151
  handleUndo,
142
152
  handleRedo,
143
153
  waitForPendingDomEditSaves,
@@ -2,8 +2,15 @@
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
5
+
6
+ const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
7
+ vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
9
+ trackStudioSaveFailure,
10
+ }));
11
+
6
12
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
13
+ import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
7
14
 
8
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
16
 
@@ -11,6 +18,7 @@ type WriteProjectFile = (path: string, content: string, expectedContent?: string
11
18
 
12
19
  async function mountEditorSave(writeProjectFile: WriteProjectFile) {
13
20
  const captured: { handle: EditorSaveHandle | null } = { handle: null };
21
+ const showToast = vi.fn();
14
22
 
15
23
  function Probe() {
16
24
  captured.handle = useEditorSave({
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
21
29
  recordEdit: vi.fn(async () => undefined),
22
30
  domEditSaveTimestampRef: { current: 0 },
23
31
  setRefreshKey: vi.fn(),
24
- showToast: vi.fn(),
32
+ showToast,
25
33
  });
26
34
  return null;
27
35
  }
@@ -32,12 +40,14 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
32
40
 
33
41
  return {
34
42
  handle: captured.handle,
43
+ showToast,
35
44
  unmount: () => act(async () => root.unmount()),
36
45
  };
37
46
  }
38
47
 
39
48
  describe("useEditorSave pending work", () => {
40
49
  beforeEach(() => {
50
+ trackStudioSaveFailure.mockClear();
41
51
  vi.stubGlobal(
42
52
  "requestAnimationFrame",
43
53
  vi.fn(() => 41),
@@ -45,7 +55,10 @@ describe("useEditorSave pending work", () => {
45
55
  vi.stubGlobal("cancelAnimationFrame", vi.fn());
46
56
  });
47
57
 
48
- afterEach(() => vi.unstubAllGlobals());
58
+ afterEach(() => {
59
+ vi.restoreAllMocks();
60
+ vi.unstubAllGlobals();
61
+ });
49
62
 
50
63
  it("exposes and flushes the latest rAF-buffered source candidate", async () => {
51
64
  const writeProjectFile = vi.fn(async () => undefined);
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
111
124
  status: "conflict",
112
125
  error: conflict,
113
126
  });
127
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith({
128
+ source: "code_editor",
129
+ error: conflict,
130
+ filePath: "index.html",
131
+ });
132
+
133
+ await mounted.unmount();
134
+ });
135
+
136
+ it("emits one identical failure per five-second burst", async () => {
137
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
138
+ const error = new Error("Load failed");
139
+ const mounted = await mountEditorSave(async () => {
140
+ throw error;
141
+ });
142
+
143
+ act(() => mounted.handle.handleContentChange("first candidate"));
144
+ await mounted.handle.flushPendingSave();
145
+ vi.spyOn(Date, "now").mockReturnValue(2_000);
146
+ act(() => mounted.handle.handleContentChange("second candidate"));
147
+ await mounted.handle.flushPendingSave();
148
+
149
+ expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
150
+ expect(mounted.showToast).toHaveBeenCalledOnce();
151
+ await mounted.unmount();
152
+ });
153
+
154
+ it("emits a changed failure immediately and repeats after the burst window", async () => {
155
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
156
+ const writeProjectFile = vi
157
+ .fn<WriteProjectFile>()
158
+ .mockRejectedValueOnce(new Error("Load failed"))
159
+ .mockRejectedValueOnce(new Error("Failed to fetch"))
160
+ .mockRejectedValueOnce(new Error("Failed to fetch"));
161
+ const mounted = await mountEditorSave(writeProjectFile);
162
+
163
+ act(() => mounted.handle.handleContentChange("first candidate"));
164
+ await mounted.handle.flushPendingSave();
165
+ now.mockReturnValue(2_000);
166
+ act(() => mounted.handle.handleContentChange("second candidate"));
167
+ await mounted.handle.flushPendingSave();
168
+ now.mockReturnValue(8_000);
169
+ act(() => mounted.handle.handleContentChange("third candidate"));
170
+ await mounted.handle.flushPendingSave();
171
+
172
+ expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
173
+ await mounted.unmount();
174
+ });
175
+
176
+ it("emits the same failure again after a successful save", async () => {
177
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
178
+ const writeProjectFile = vi
179
+ .fn<WriteProjectFile>()
180
+ .mockRejectedValueOnce(new Error("Load failed"))
181
+ .mockResolvedValueOnce(undefined)
182
+ .mockRejectedValueOnce(new Error("Load failed"));
183
+ const mounted = await mountEditorSave(writeProjectFile);
184
+
185
+ act(() => mounted.handle.handleContentChange("first candidate"));
186
+ await mounted.handle.flushPendingSave();
187
+ act(() => mounted.handle.handleContentChange("successful candidate"));
188
+ await mounted.handle.flushPendingSave();
189
+ act(() => mounted.handle.handleContentChange("third candidate"));
190
+ await mounted.handle.flushPendingSave();
114
191
 
192
+ expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
115
193
  await mounted.unmount();
116
194
  });
117
195
 
@@ -1,12 +1,15 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
3
  import type { EditHistoryKind } from "../utils/editHistory";
4
- import { trackStudioEvent } from "../utils/studioTelemetry";
5
4
  import {
6
5
  StudioFileConflictError,
6
+ buildStudioSaveFailureProperties,
7
+ trackStudioSaveFailure,
7
8
  type StudioSaveDrainResult,
8
9
  } from "../utils/studioSaveDiagnostics";
9
10
 
11
+ const FAILURE_BURST_MS = 5_000;
12
+
10
13
  interface RecordEditInput {
11
14
  label: string;
12
15
  kind: EditHistoryKind;
@@ -58,19 +61,40 @@ export function useEditorSave({
58
61
  const refreshRafRef = useRef<number | null>(null);
59
62
  // One error toast per burst of failures — every keystroke retries the save,
60
63
  // and error toasts persist until dismissed, so don't stack duplicates.
61
- const lastFailureToastAtRef = useRef(0);
64
+ const lastFailureToastAtRef = useRef<number | null>(null);
65
+ const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
62
66
  const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
63
67
  const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
64
68
  const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
65
69
 
66
70
  const reportFailure = useCallback(
67
71
  (path: string, error: unknown) => {
68
- trackStudioEvent("save_failure", {
72
+ const now = Date.now();
73
+ const properties = buildStudioSaveFailureProperties({
69
74
  source: "code_editor",
70
- error_message: error instanceof Error ? error.message : "unknown",
75
+ error,
76
+ filePath: path,
71
77
  });
72
- const now = Date.now();
73
- if (now - lastFailureToastAtRef.current > 5000) {
78
+ const errorName = error instanceof Error ? error.name : typeof error;
79
+ const fingerprint = JSON.stringify([
80
+ path,
81
+ errorName,
82
+ properties.error_message,
83
+ properties.status_code,
84
+ ]);
85
+ const previous = lastFailureReportRef.current;
86
+ if (
87
+ previous === null ||
88
+ previous.fingerprint !== fingerprint ||
89
+ now - previous.emittedAt >= FAILURE_BURST_MS
90
+ ) {
91
+ trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
92
+ lastFailureReportRef.current = { fingerprint, emittedAt: now };
93
+ }
94
+ if (
95
+ lastFailureToastAtRef.current === null ||
96
+ now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
97
+ ) {
74
98
  lastFailureToastAtRef.current = now;
75
99
  showToast(
76
100
  `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
@@ -95,6 +119,7 @@ export function useEditorSave({
95
119
  })
96
120
  .then<EditorSaveDrainResult>(() => {
97
121
  if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
122
+ lastFailureReportRef.current = null;
98
123
  if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
99
124
  refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
100
125
  return { status: "clean" };
@@ -1,32 +1,49 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { GsapEditBlockedError } from "./gsapEditOutcome";
8
8
 
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
11
- const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
12
- vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
11
+ const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
12
+ trackStudioEditBlocked: vi.fn(),
13
+ trackStudioSaveFailure: vi.fn(),
14
+ }));
15
+ vi.mock("../utils/studioSaveDiagnostics", () => ({
16
+ trackStudioEditBlocked,
17
+ trackStudioSaveFailure,
18
+ }));
13
19
 
14
20
  import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
15
21
 
22
+ const selection = {
23
+ id: "clip",
24
+ selector: "#clip",
25
+ element: document.createElement("div"),
26
+ } as unknown as DomEditSelection;
27
+
28
+ function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
29
+ let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
30
+ function Harness() {
31
+ report = useGsapInteractionFailureTelemetry("index.html", showToast);
32
+ return null;
33
+ }
34
+ const root = mountReactHarness(<Harness />);
35
+ return { report, root };
36
+ }
37
+
16
38
  describe("useGsapInteractionFailureTelemetry", () => {
17
- it("surfaces the blocked reason instead of a generic save failure", () => {
39
+ beforeEach(() => {
40
+ trackStudioEditBlocked.mockClear();
41
+ trackStudioSaveFailure.mockClear();
42
+ });
43
+
44
+ it("tracks an expected edit block separately from save failures", () => {
18
45
  const showToast = vi.fn();
19
- const selection = {
20
- id: "clip",
21
- selector: "#clip",
22
- element: document.createElement("div"),
23
- } as unknown as DomEditSelection;
24
- let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
25
- function Harness() {
26
- report = useGsapInteractionFailureTelemetry("index.html", showToast);
27
- return null;
28
- }
29
- const root = mountReactHarness(<Harness />);
46
+ const { report, root } = mountFailureTelemetry(showToast);
30
47
 
31
48
  act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
32
49
 
@@ -34,9 +51,24 @@ describe("useGsapInteractionFailureTelemetry", () => {
34
51
  "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
35
52
  "error",
36
53
  );
37
- expect(trackStudioSaveFailure).toHaveBeenCalledWith(
54
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(
38
55
  expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
39
56
  );
57
+ expect(trackStudioSaveFailure).not.toHaveBeenCalled();
58
+ act(() => root.unmount());
59
+ });
60
+
61
+ it("keeps unexpected GSAP persistence errors in save_failure", () => {
62
+ const showToast = vi.fn();
63
+ const { report, root } = mountFailureTelemetry(showToast);
64
+ const error = new Error("network dropped");
65
+
66
+ act(() => report(error, selection, "drag", "Move"));
67
+
68
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith(
69
+ expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
70
+ );
71
+ expect(trackStudioEditBlocked).not.toHaveBeenCalled();
40
72
  act(() => root.unmount());
41
73
  });
42
74
  });
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
- import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
3
+ import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
4
  import { isGsapEditBlockedError } from "./gsapEditOutcome";
5
5
 
6
6
  export function useGsapInteractionFailureTelemetry(
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
9
9
  ) {
10
10
  return useCallback(
11
11
  (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
12
- trackStudioSaveFailure({
12
+ const report = isGsapEditBlockedError(error)
13
+ ? trackStudioEditBlocked
14
+ : trackStudioSaveFailure;
15
+ report({
13
16
  source: "gsap_commit",
14
17
  error,
15
18
  filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
@@ -25,6 +25,10 @@ interface StudioContextInput {
25
25
  // fields around it: the context type owns it.
26
26
  renderQueue: StudioContextValue["renderQueue"];
27
27
  compositionDimensions: { width: number; height: number } | null;
28
+ /** Message from `usePreviewPersistence` when auto-save is paused. */
29
+ domEditSaveQueuePaused: string | null;
30
+ /** True when an external edit to the open file is awaiting the user's decision. */
31
+ externalFileConflict: boolean;
28
32
  waitForPendingDomEditSaves: () => Promise<void>;
29
33
  handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
30
34
  refreshPreviewDocumentVersion: () => void;
@@ -46,6 +50,11 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex
46
50
  timelineElements: input.timelineElements,
47
51
  isPlaying: input.isPlaying,
48
52
  editHistory: input.editHistory,
53
+ // Conflict first: when both are true the conflict is the one the user has
54
+ // been asked to decide, and resolving it is what unblocks the queue.
55
+ writeBlockedReason: input.externalFileConflict
56
+ ? "an external change to this file is waiting to be resolved"
57
+ : input.domEditSaveQueuePaused,
49
58
  handleUndo: input.handleUndo,
50
59
  handleRedo: input.handleRedo,
51
60
  renderQueue: input.renderQueue,
@@ -0,0 +1,96 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ collectSubCompositionDomChildren,
6
+ collectSubCompositionHostState,
7
+ } from "./timelineSyncHydration";
8
+ import type { ClipManifestClip } from "../lib/playbackTypes";
9
+
10
+ const clip = (over: Partial<ClipManifestClip>): ClipManifestClip => ({
11
+ id: "x",
12
+ label: "x",
13
+ start: 0,
14
+ duration: 1,
15
+ track: 0,
16
+ kind: "element",
17
+ tagName: "div",
18
+ compositionId: null,
19
+ parentCompositionId: null,
20
+ compositionSrc: null,
21
+ assetUrl: null,
22
+ ...over,
23
+ });
24
+
25
+ /**
26
+ * The shape a storyboard scene actually publishes: the clip that carries
27
+ * `data-hidden` is a `<video>` one level BELOW an id'd region wrapper.
28
+ */
29
+ function mountScene(): Document {
30
+ document.body.innerHTML = `
31
+ <div id="scene-2-slot" data-composition-id="scene-2" data-composition-src="scene-2.html">
32
+ <div data-hf-inner-root>
33
+ <div id="scene-2-video-region" class="hf-region">
34
+ <video id="scene-2-video" class="clip" data-start="0" data-hidden></video>
35
+ </div>
36
+ <div id="scene-2-title" class="clip" data-start="0" data-hidden></div>
37
+ <div id="scene-2-caption" class="clip" data-start="0" data-timeline-locked
38
+ data-timeline-role="caption" data-fx-chain="blur" data-automation="opacity"></div>
39
+ </div>
40
+ </div>`;
41
+ return document;
42
+ }
43
+
44
+ // A composition clip is keyed by its ELEMENT id, not its `data-composition-id`
45
+ // (the runtime's clip tree publishes `scene-2-slot`), so the collector resolves
46
+ // the host with getElementById(clip.id) exactly as the sibling walk does.
47
+ const sceneClips = [clip({ id: "scene-2-slot", kind: "composition", compositionId: "scene-2" })];
48
+
49
+ describe("collectSubCompositionHostState", () => {
50
+ it("reaches a clip nested below an id'd wrapper, which the sibling walk cannot", () => {
51
+ const doc = mountScene();
52
+
53
+ // The walk that defines rows stops at the first id'd descendant, so the
54
+ // video inside the region wrapper is never recorded there. That is why the
55
+ // eye on a hidden scene video had no state to read.
56
+ const siblings = collectSubCompositionDomChildren(doc, sceneClips, new Map());
57
+ expect(siblings.map((child) => child.id)).toEqual([
58
+ "scene-2-video-region",
59
+ "scene-2-title",
60
+ "scene-2-caption",
61
+ ]);
62
+
63
+ const state = collectSubCompositionHostState(doc, sceneClips);
64
+ expect(state.get("scene-2-video")?.hidden).toBe(true);
65
+ });
66
+
67
+ it("records every data-* attribute an expanded child row needs", () => {
68
+ const state = collectSubCompositionHostState(mountScene(), sceneClips);
69
+
70
+ expect(state.get("scene-2-title")).toEqual({ hidden: true });
71
+ expect(state.get("scene-2-caption")).toEqual({
72
+ timelineLocked: true,
73
+ timelineRole: "caption",
74
+ fxChain: "blur",
75
+ automation: "opacity",
76
+ });
77
+ });
78
+
79
+ it("omits elements carrying no state, so a visible child reads as visible", () => {
80
+ const state = collectSubCompositionHostState(mountScene(), sceneClips);
81
+
82
+ expect(state.has("scene-2-video-region")).toBe(false);
83
+ expect(state.get("scene-2-video")?.hidden).toBe(true);
84
+ });
85
+
86
+ it("returns empty without a document, rather than throwing", () => {
87
+ expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0);
88
+ });
89
+
90
+ it("ignores clips that are not compositions", () => {
91
+ const doc = mountScene();
92
+ const state = collectSubCompositionHostState(doc, [clip({ id: "scene-2-slot" })]);
93
+
94
+ expect(state.size).toBe(0);
95
+ });
96
+ });
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { usePlayerStore } from "../store/playerStore";
13
- import type { TimelineElement, DomClipChild } from "../store/playerStore";
13
+ import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
14
14
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
15
15
  import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
16
16
  import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
@@ -137,6 +137,52 @@ export function collectSubCompositionDomChildren(
137
137
  return out;
138
138
  }
139
139
 
140
+ /** The host-element `data-*` state one element carries, or null when it has none. */
141
+ function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
142
+ const state: SubCompositionHostState = {};
143
+ if (el.hasAttribute("data-hidden")) state.hidden = true;
144
+ if (el.hasAttribute("data-timeline-locked")) state.timelineLocked = true;
145
+ const timelineRole = el.getAttribute("data-timeline-role");
146
+ if (timelineRole) state.timelineRole = timelineRole;
147
+ const fxChain = el.getAttribute("data-fx-chain");
148
+ if (fxChain) state.fxChain = fxChain;
149
+ const automation = el.getAttribute("data-automation");
150
+ if (automation) state.automation = automation;
151
+ return Object.keys(state).length > 0 ? state : null;
152
+ }
153
+
154
+ /**
155
+ * Host-element state for every id'd element inside every sub-composition.
156
+ *
157
+ * This walk exists because nothing else in the pipeline can see these
158
+ * attributes. A clip whose parent composition is itself in the manifest is
159
+ * filtered out before the flat store is built, so an expanded child has no twin
160
+ * to inherit from, and the manifest carries timing rather than attributes.
161
+ *
162
+ * Deliberately separate from {@link collectSubCompositionDomChildren}: that walk
163
+ * defines which rows exist and writes `parentMap`, and it stops at the first
164
+ * id'd descendant. Scene footage commonly sits one level below an id'd region
165
+ * wrapper, so it is never reached there. This one descends the whole subtree and
166
+ * touches neither rows nor parentage.
167
+ */
168
+ export function collectSubCompositionHostState(
169
+ iframeDoc: Document | null,
170
+ clips: readonly ClipManifestClip[],
171
+ ): Map<string, SubCompositionHostState> {
172
+ const out = new Map<string, SubCompositionHostState>();
173
+ if (!iframeDoc) return out;
174
+ for (const clip of clips) {
175
+ if (clip.kind !== "composition" || !clip.id) continue;
176
+ const hostEl = iframeDoc.getElementById(clip.id);
177
+ if (!hostEl) continue;
178
+ for (const el of Array.from(hostEl.querySelectorAll("[id]"))) {
179
+ const state = readSubCompositionHostState(el);
180
+ if (state) out.set(el.id, state);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+
140
186
  /** An iframe's document, or null when reading it throws (cross-origin, or the
141
187
  * frame is mid-navigation). */
142
188
  export function safeContentDocument(iframe: HTMLIFrameElement | null): Document | null {
@@ -270,6 +270,49 @@ describe("buildExpandedElements", () => {
270
270
  expect(child.timelineLocked).toBe(true);
271
271
  });
272
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
+
273
316
  // Sub-comp internals (group + pills) have no data-start, so they're not in the
274
317
  // manifest. They arrive as DOM children and must still expand under their host.
275
318
  it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {