@hyperframes/studio 0.8.20 → 0.8.22
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.
- package/dist/assets/hyperframes-player-DmtkkaPN.js +460 -0
- package/dist/assets/{index-CBSKXKAr.js → index-BIf0zQNt.js} +164 -164
- package/dist/assets/{index-Cehosfnu.js → index-DXR3nsh2.js} +1 -1
- package/dist/assets/{index-BL1zYTW0.js → index-DivnGS5G.js} +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1170 -227
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/contexts/StudioContext.tsx +10 -0
- package/src/hooks/useStudioContextValue.ts +9 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
- package/src/player/hooks/timelineSyncHydration.ts +47 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
- package/src/player/store/playerStore.ts +17 -2
- package/src/player/store/timelineElement.ts +21 -0
- package/src/webmcp/StudioAgentTools.tsx +105 -10
- package/src/webmcp/toolResult.ts +1 -1
- package/src/webmcp/tools/animationTools.test.ts +244 -0
- package/src/webmcp/tools/animationTools.ts +276 -0
- package/src/webmcp/tools/contentTools.test.ts +275 -0
- package/src/webmcp/tools/contentTools.ts +217 -0
- package/src/webmcp/tools/frameTools.test.ts +136 -0
- package/src/webmcp/tools/frameTools.ts +133 -0
- package/src/webmcp/tools/inspectTools.test.ts +197 -0
- package/src/webmcp/tools/inspectTools.ts +208 -0
- package/src/webmcp/tools/selectionTools.test.ts +164 -0
- package/src/webmcp/tools/selectionTools.ts +154 -0
- package/src/webmcp/tools/transformTools.test.ts +179 -0
- package/src/webmcp/tools/transformTools.ts +205 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
- package/src/webmcp/useStudioAgentTools.ts +195 -1
- package/src/webmcp/webmcpTestUtils.ts +91 -0
- 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.
|
|
3
|
+
"version": "0.8.22",
|
|
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.
|
|
52
|
-
"@hyperframes/sdk": "0.8.
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/studio-server": "0.8.
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/core": "0.8.22",
|
|
52
|
+
"@hyperframes/sdk": "0.8.22",
|
|
53
|
+
"@hyperframes/parsers": "0.8.22",
|
|
54
|
+
"@hyperframes/studio-server": "0.8.22",
|
|
55
|
+
"@hyperframes/player": "0.8.22"
|
|
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.
|
|
72
|
+
"@hyperframes/producer": "0.8.22"
|
|
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 {
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
-
}, [
|
|
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 {
|
|
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
|
+
}
|