@hyperframes/studio 0.8.20 → 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 (37) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-BL1zYTW0.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-Cehosfnu.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-CBSKXKAr.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 +1170 -227
  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/useStudioContextValue.ts +9 -0
  13. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  14. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  15. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  16. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  17. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  18. package/src/player/store/playerStore.ts +17 -2
  19. package/src/player/store/timelineElement.ts +21 -0
  20. package/src/webmcp/StudioAgentTools.tsx +105 -10
  21. package/src/webmcp/toolResult.ts +1 -1
  22. package/src/webmcp/tools/animationTools.test.ts +244 -0
  23. package/src/webmcp/tools/animationTools.ts +276 -0
  24. package/src/webmcp/tools/contentTools.test.ts +275 -0
  25. package/src/webmcp/tools/contentTools.ts +217 -0
  26. package/src/webmcp/tools/frameTools.test.ts +136 -0
  27. package/src/webmcp/tools/frameTools.ts +133 -0
  28. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  29. package/src/webmcp/tools/inspectTools.ts +208 -0
  30. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  31. package/src/webmcp/tools/selectionTools.ts +154 -0
  32. package/src/webmcp/tools/transformTools.test.ts +179 -0
  33. package/src/webmcp/tools/transformTools.ts +205 -0
  34. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  35. package/src/webmcp/useStudioAgentTools.ts +195 -1
  36. package/src/webmcp/webmcpTestUtils.ts +91 -0
  37. package/dist/assets/hyperframes-player-BEKxuimO.js +0 -459
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.20",
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.20",
52
- "@hyperframes/sdk": "0.8.20",
53
- "@hyperframes/player": "0.8.20",
54
- "@hyperframes/studio-server": "0.8.20",
55
- "@hyperframes/parsers": "0.8.20"
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.20"
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,
@@ -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", () => {
@@ -1,5 +1,10 @@
1
1
  import { useMemo } from "react";
2
- import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
2
+ import {
3
+ usePlayerStore,
4
+ type TimelineElement,
5
+ type DomClipChild,
6
+ type SubCompositionHostState,
7
+ } from "../store/playerStore";
3
8
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
9
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
10
  import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
@@ -131,18 +136,6 @@ interface DisplayBounds {
131
136
  track: number;
132
137
  }
133
138
 
134
- /**
135
- * State that lives on the live host element, not in the clip manifest:
136
- * `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
137
- * built from a manifest clip with no hostEl to read, so
138
- * createTimelineElementFromManifestClip cannot see any of it. The flat store
139
- * element for the same child WAS built with one, so it is inherited from there.
140
- *
141
- * Without this the eye on an expanded child always reported the row visible, so
142
- * clicking it wrote data-hidden again instead of removing it, and a hidden child
143
- * could never be shown again (not even after a reload, since the attribute is in
144
- * the source).
145
- */
146
139
  /**
147
140
  * Audio-group membership for an expanded child, from whichever source has it.
148
141
  *
@@ -169,20 +162,34 @@ function childGroupState(
169
162
  };
170
163
  }
171
164
 
172
- function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
173
- if (!flat) return {};
174
- return {
175
- hidden: flat.hidden,
176
- timelineLocked: flat.timelineLocked,
177
- timelineRole: flat.timelineRole,
178
- // Same reason as the three above: these are read off the host element, which
179
- // an expanded child is built without. Missing them, an audio child inside a
180
- // sub-composition reserved no automation height and drew no lanes, while the
181
- // property panel reading the live DOM selection rather than this row
182
- // still showed the chain and its toggles.
183
- fxChain: flat.fxChain,
184
- automation: flat.automation,
185
- };
165
+ /**
166
+ * State that lives on the live host element, not in the clip manifest:
167
+ * `data-hidden`, `data-timeline-locked`, `data-timeline-role`, `data-fx-chain`,
168
+ * `data-automation`. A child row is built from a manifest clip with no hostEl to
169
+ * read, so createTimelineElementFromManifestClip cannot see any of it.
170
+ *
171
+ * `live` is the reading taken off the element itself and is authoritative. For a
172
+ * child of a REAL sub-composition it is also the only source: such a clip is
173
+ * filtered out of the manifest before the flat store is built, so no twin exists
174
+ * to inherit from. The flat twin still covers the phantom-wrapper case, where
175
+ * the child does keep a store entry of its own.
176
+ *
177
+ * Without a reading of the element, the eye on an expanded child always reported
178
+ * the row visible, so clicking it wrote data-hidden again instead of removing
179
+ * it, and a hidden child could never be shown again (not even after a reload,
180
+ * since the attribute is in the source). Missing fxChain and automation, an
181
+ * audio child inside a sub-composition reserved no automation height and drew no
182
+ * lanes, while the property panel, which reads the live DOM selection rather
183
+ * than this row, still showed the chain and its toggles.
184
+ */
185
+ function hostElementState(
186
+ flat: TimelineElement | undefined,
187
+ live: SubCompositionHostState | undefined,
188
+ ): Partial<TimelineElement> {
189
+ if (!flat) return { ...live };
190
+ const { hidden, timelineLocked, timelineRole, fxChain, automation } = flat;
191
+ // `live` last: it is the reading off the element, so it wins wherever it has one.
192
+ return { hidden, timelineLocked, timelineRole, fxChain, automation, ...live };
186
193
  }
187
194
 
188
195
  // `display` bounds come from the top-level scene clip (where the expanded row is
@@ -196,6 +203,7 @@ function buildChildElements(
196
203
  expandedHostKey: string,
197
204
  elements: readonly TimelineElement[],
198
205
  domChildrenById: ReadonlyMap<string, DomClipChild>,
206
+ hostStateById: ReadonlyMap<string, SubCompositionHostState>,
199
207
  ): TimelineElement[] {
200
208
  const result: TimelineElement[] = [];
201
209
  for (const child of siblings) {
@@ -223,7 +231,10 @@ function buildChildElements(
223
231
  });
224
232
  result.push({
225
233
  ...base,
226
- ...hostElementState(elements.find((element) => element.key === key)),
234
+ ...hostElementState(
235
+ elements.find((element) => element.key === key),
236
+ domId ? hostStateById.get(domId) : undefined,
237
+ ),
227
238
  ...childGroupState(
228
239
  elements.find((element) => element.key === key),
229
240
  domId ? domChildrenById.get(domId) : undefined,
@@ -309,6 +320,7 @@ export function buildExpandedElements(
309
320
  topLevelId: string,
310
321
  siblingParentId: string,
311
322
  domClipChildren: DomClipChild[] = [],
323
+ subCompositionHostState: ReadonlyMap<string, SubCompositionHostState> = new Map(),
312
324
  ): TimelineElement[] {
313
325
  const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
314
326
  if (!topLevelElement) return filterToTopLevel(elements, parentMap);
@@ -348,6 +360,7 @@ export function buildExpandedElements(
348
360
  parentKey,
349
361
  elements,
350
362
  domChildrenById,
363
+ subCompositionHostState,
351
364
  );
352
365
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
353
366
 
@@ -391,6 +404,7 @@ export function useExpandedTimelineElements(): TimelineElement[] {
391
404
  const clipManifest = usePlayerStore((s) => s.clipManifest);
392
405
  const clipParentMap = usePlayerStore((s) => s.clipParentMap);
393
406
  const domClipChildren = usePlayerStore((s) => s.domClipChildren);
407
+ const subCompositionHostState = usePlayerStore((s) => s.subCompositionHostState);
394
408
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
395
409
  const currentTime = usePlayerStore((s) => s.currentTime);
396
410
 
@@ -432,6 +446,15 @@ export function useExpandedTimelineElements(): TimelineElement[] {
432
446
  topLevel,
433
447
  immediateParent,
434
448
  domClipChildren,
449
+ subCompositionHostState,
435
450
  );
436
- }, [elements, clipManifest, clipParentMap, domClipChildren, rawId, selectedRawId]);
451
+ }, [
452
+ elements,
453
+ clipManifest,
454
+ clipParentMap,
455
+ domClipChildren,
456
+ subCompositionHostState,
457
+ rawId,
458
+ selectedRawId,
459
+ ]);
437
460
  }
@@ -19,6 +19,7 @@ import {
19
19
  buildTimelineElementsFromClips,
20
20
  clipTreeParentMap,
21
21
  collectSubCompositionDomChildren,
22
+ collectSubCompositionHostState,
22
23
  hydrateTimelineFromPreview,
23
24
  isPreviewReadinessMessage,
24
25
  safeContentDocument,
@@ -131,6 +132,9 @@ export function useTimelineSyncCallbacks({
131
132
  const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
132
133
  usePlayerStore.getState().setClipParentMap(parentMap);
133
134
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
135
+ usePlayerStore
136
+ .getState()
137
+ .setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
134
138
  } catch {
135
139
  // cross-origin or __clipTree not available — maps stay empty
136
140
  }
@@ -23,9 +23,13 @@ import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
23
23
  export type { KeyframeCacheEntry } from "./keyframeSlice";
24
24
  export { liveTime } from "./liveTime";
25
25
 
26
- import type { TimelineElement, TimelineElementPatch } from "./timelineElement";
26
+ import type {
27
+ TimelineElement,
28
+ TimelineElementPatch,
29
+ SubCompositionHostState,
30
+ } from "./timelineElement";
27
31
 
28
- export type { TimelineElement };
32
+ export type { TimelineElement, SubCompositionHostState };
29
33
  export type ZoomMode = "fit" | "manual";
30
34
  type TimelineTool = "select" | "razor";
31
35
 
@@ -212,6 +216,14 @@ interface PlayerState
212
216
  */
213
217
  domClipChildren: DomClipChild[];
214
218
  setDomClipChildren: (children: DomClipChild[]) => void;
219
+ /**
220
+ * Host-element state for every id'd element inside a sub-composition, keyed by
221
+ * dom id. Collected from the live preview because it is the only place that
222
+ * sees it: these elements are filtered out of `elements` before the flat store
223
+ * is built, and the clip manifest carries timing, not attributes.
224
+ */
225
+ subCompositionHostState: Map<string, SubCompositionHostState>;
226
+ setSubCompositionHostState: (state: Map<string, SubCompositionHostState>) => void;
215
227
  }
216
228
 
217
229
  /** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
@@ -284,6 +296,7 @@ export function createTimelineResetState() {
284
296
  clipManifest: null,
285
297
  clipParentMap: new Map<string, string>(),
286
298
  domClipChildren: [],
299
+ subCompositionHostState: new Map<string, SubCompositionHostState>(),
287
300
  };
288
301
  }
289
302
 
@@ -430,6 +443,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
430
443
  setClipParentMap: (map) => set({ clipParentMap: map }),
431
444
  domClipChildren: [],
432
445
  setDomClipChildren: (children) => set({ domClipChildren: children }),
446
+ subCompositionHostState: new Map(),
447
+ setSubCompositionHostState: (state) => set({ subCompositionHostState: state }),
433
448
 
434
449
  setIsPlaying: (playing) => {
435
450
  if (get().isPlaying === playing) return;
@@ -118,3 +118,24 @@ export type TimelineElementPatch = Partial<
118
118
  | "audioGroupAutomation"
119
119
  >
120
120
  >;
121
+
122
+ /**
123
+ * The `data-*` state an expanded sub-composition child needs but cannot reach.
124
+ *
125
+ * A child row is synthesized from a manifest clip with no element to read, and
126
+ * for a real sub-composition it has no flat store twin either: such clips are
127
+ * dropped before the flat store is built. Read off the live preview instead
128
+ * (`collectSubCompositionHostState`) and carried on the store by dom id.
129
+ *
130
+ * Without it the eye reported every hidden child visible, so clicking it wrote
131
+ * `data-hidden` a second time instead of removing it, and the element could
132
+ * never be shown again, not even after a reload, since the attribute is in the
133
+ * source.
134
+ */
135
+ export interface SubCompositionHostState {
136
+ hidden?: boolean;
137
+ timelineLocked?: boolean;
138
+ timelineRole?: string;
139
+ fxChain?: string;
140
+ automation?: string;
141
+ }