@hyperframes/studio 0.8.11 → 0.8.12
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-C6J6fsAD.js → hyperframes-player-B243szNa.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-BTP86sD-.js} +1 -1
- package/dist/assets/index-BZfUJ2He.js +431 -0
- package/dist/assets/{index-1vMlv05L.js → index-DQ_lkuqY.js} +1 -1
- package/dist/assets/index-yGhfxxoL.css +1 -0
- package/dist/{chunk-ZALQ3CW7.js → chunk-6BT6DTB4.js} +2 -1
- package/dist/{domEditingLayers-XRVXRTNX.js → domEditingLayers-URA7BLWE.js} +2 -2
- package/dist/index.d.ts +84 -43
- package/dist/index.html +2 -2
- package/dist/index.js +6431 -5515
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/App.tsx +0 -2
- package/src/components/StudioRightPanel.tsx +15 -57
- package/src/components/editor/PropertyPanel.test.tsx +101 -3
- package/src/components/editor/PropertyPanel.tsx +15 -1
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +35 -20
- package/src/components/editor/PropertyPanelFlat.tsx +82 -21
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +21 -0
- package/src/components/editor/TimelineFxPopover.tsx +24 -4
- package/src/components/editor/audioFxRevealTarget.test.ts +59 -0
- package/src/components/editor/audioFxRevealTarget.ts +113 -0
- package/src/components/editor/audioFxSignalPath.test.ts +50 -0
- package/src/components/editor/audioFxSignalPath.ts +71 -0
- package/src/components/editor/audioFxSummary.test.ts +8 -0
- package/src/components/editor/audioFxSummary.ts +28 -16
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +101 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +92 -2
- package/src/components/editor/propertyPanelFlatClosedGroup.tsx +46 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +31 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +203 -125
- package/src/components/editor/propertyPanelFxControls.test.tsx +50 -0
- package/src/components/editor/propertyPanelFxControls.tsx +10 -2
- package/src/components/editor/propertyPanelFxEqModule.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +11 -1
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +12 -2
- package/src/components/editor/propertyPanelFxRackChain.tsx +12 -5
- package/src/components/editor/propertyPanelFxSection.test.tsx +42 -0
- package/src/components/editor/propertyPanelFxSection.tsx +63 -4
- package/src/components/editor/propertyPanelFxSectionTypes.ts +19 -0
- package/src/components/editor/useAudioFxRevealSection.ts +64 -0
- package/src/components/editor/useAuditionTransport.test.ts +36 -0
- package/src/components/editor/useAuditionTransport.ts +78 -0
- package/src/components/editor/useFxAudition.ts +12 -1
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.test.ts +118 -0
- package/src/components/editor/useFxCarveGrouping.ts +139 -4
- package/src/components/editor/useFxCarveNodes.ts +153 -0
- package/src/components/editor/useFxLevelling.ts +5 -34
- package/src/components/nle/NLEContext.tsx +3 -10
- package/src/components/nle/PreviewPane.tsx +0 -1
- package/src/components/nle/useTimelineEditCallbacks.ts +5 -1
- package/src/contexts/DesignPanelInputContext.tsx +6 -5
- package/src/contexts/DomEditContext.tsx +10 -3
- package/src/contexts/FileManagerContext.tsx +3 -2
- package/src/contexts/PanelLayoutContext.tsx +3 -2
- package/src/contexts/StudioContext.tsx +7 -3
- package/src/contexts/TimelineEditContext.tsx +6 -2
- package/src/contexts/VariablePromoteContext.tsx +6 -2
- package/src/contexts/ViewModeContext.tsx +2 -2
- package/src/hooks/domEditDeleteMembers.ts +34 -0
- package/src/hooks/timelineAudioGroupCreate.ts +345 -0
- package/src/hooks/timelineAudioGroupVolume.test.ts +204 -0
- package/src/hooks/timelineAudioGroupVolume.ts +135 -3
- package/src/hooks/timelineEditingHelpers.test.ts +86 -0
- package/src/hooks/timelineEditingHelpers.ts +19 -7
- package/src/hooks/timelineElementFxAttribute.ts +0 -3
- package/src/hooks/timelineTrackVisibility.test.ts +95 -13
- package/src/hooks/timelineTrackVisibility.ts +6 -182
- package/src/hooks/useAudioGroupCarveAssignment.test.tsx +139 -0
- package/src/hooks/useBlockedTimelineEditToast.ts +27 -0
- package/src/hooks/useDomEditAttributeCommits.ts +13 -0
- package/src/hooks/useDomEditSession.test.tsx +57 -2
- package/src/hooks/useDomEditSession.ts +23 -24
- package/src/hooks/useEffectiveTimelineDuration.ts +9 -7
- package/src/hooks/useLivePlayheadTime.ts +3 -1
- package/src/hooks/useRemoveBackground.ts +70 -0
- package/src/hooks/useTimelineEditing.ts +5 -19
- package/src/hooks/useTimelineEditingTypes.ts +7 -0
- package/src/player/components/AutomationSelectionMenu.tsx +4 -0
- package/src/player/components/LayerDisclosureRow.tsx +59 -25
- package/src/player/components/PlayerControls.tsx +0 -38
- package/src/player/components/Timeline.test.ts +40 -1
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineAutomationLane.test.tsx +67 -0
- package/src/player/components/TimelineAutomationLane.tsx +89 -12
- package/src/player/components/TimelineAutomationLaneSlot.tsx +22 -3
- package/src/player/components/TimelineFxButton.test.tsx +167 -21
- package/src/player/components/TimelineFxButton.tsx +156 -31
- package/src/player/components/TimelineGroupHeader.test.tsx +0 -5
- package/src/player/components/TimelineGroupHeader.tsx +100 -102
- package/src/player/components/TimelineGroupLaneLabels.tsx +88 -0
- package/src/player/components/TimelineGroupRow.test.tsx +146 -0
- package/src/player/components/TimelineGroupRow.tsx +123 -64
- package/src/player/components/TimelineLanes.test.tsx +3 -3
- package/src/player/components/TimelineLanes.tsx +25 -23
- package/src/player/components/TimelineTrackHeader.test.tsx +298 -13
- package/src/player/components/TimelineTrackHeader.tsx +301 -381
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +47 -69
- package/src/player/components/TimelineTrackPlainHeader.tsx +64 -54
- package/src/player/components/automationLaneData.test.ts +94 -0
- package/src/player/components/automationLaneData.ts +23 -0
- package/src/player/components/groupAutomationElement.test.ts +51 -0
- package/src/player/components/groupAutomationElement.ts +37 -0
- package/src/player/components/timelineCallbacks.ts +6 -2
- package/src/player/components/timelineKeyboardNavigation.test.ts +2 -2
- package/src/player/components/timelineKeyboardNavigation.ts +25 -8
- package/src/player/components/timelineLayout.ts +0 -1
- package/src/player/components/timelineViewModel.ts +30 -1
- package/src/player/components/trackHeaderLabelRows.tsx +328 -0
- package/src/player/components/useTimelineClipDisclosure.ts +41 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +1 -1
- package/src/player/components/useTimelineLogicalFocus.ts +2 -2
- package/src/player/components/useTimelineLogicalRows.test.tsx +2 -2
- package/src/player/components/useTimelineLogicalRows.ts +3 -3
- package/src/player/components/useTimelineTrackDerivations.ts +47 -8
- package/src/player/components/useTimelineTrackLayout.test.ts +119 -0
- package/src/player/components/useTimelineTrackLayout.ts +12 -4
- package/src/player/hooks/previewMessageRouter.ts +4 -11
- package/src/player/hooks/timelineSyncHydration.ts +395 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +83 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +40 -1
- package/src/player/hooks/useTimelinePlayer.ts +2 -7
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -219
- package/src/player/lib/automationStoreSync.test.ts +26 -0
- package/src/player/lib/automationStoreSync.ts +28 -4
- package/src/player/lib/runtimeAudioId.test.ts +58 -0
- package/src/player/lib/timelineDOM.test.ts +115 -0
- package/src/player/lib/timelineDOM.ts +3 -32
- package/src/player/lib/timelineElementHelpers.ts +27 -0
- package/src/player/lib/timelineGroupInfo.ts +134 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +21 -0
- package/src/player/lib/timelineIframeHelpers.ts +17 -0
- package/src/player/store/keyframeSlice.ts +79 -5
- package/src/player/store/playerStore.ts +37 -47
- package/src/player/store/playerStoreDevHandle.ts +22 -0
- package/src/player/store/playerStoreSelection.ts +41 -0
- package/src/player/store/timelineElement.ts +32 -0
- package/src/utils/hmrStableContext.ts +64 -0
- package/src/utils/timelineInspector.test.ts +35 -1
- package/src/utils/timelineInspector.ts +38 -0
- package/dist/assets/index-Ba9zbpT9.css +0 -1
- package/dist/assets/index-DoW5v5eS.js +0 -428
- package/src/hooks/useAudioSoloBridge.ts +0 -61
- package/src/hooks/useGroupLevel.ts +0 -36
- package/src/player/components/TimelineGroupBusStrip.test.tsx +0 -146
- package/src/player/components/TimelineGroupBusStrip.tsx +0 -103
- package/src/player/components/TimelineSoloButton.tsx +0 -32
- package/src/player/store/audioSoloSlice.test.ts +0 -113
- package/src/player/store/audioSoloSlice.ts +0 -43
- package/src/player/store/groupLevels.ts +0 -33
- /package/dist/{chunk-ZALQ3CW7.js.map → chunk-6BT6DTB4.js.map} +0 -0
- /package/dist/{domEditingLayers-XRVXRTNX.js.map → domEditingLayers-URA7BLWE.js.map} +0 -0
|
@@ -10,24 +10,27 @@
|
|
|
10
10
|
|
|
11
11
|
import { useCallback } from "react";
|
|
12
12
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
13
|
-
import type { TimelineElement
|
|
14
|
-
import {
|
|
15
|
-
import
|
|
13
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
14
|
+
import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
|
|
15
|
+
import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
|
|
16
|
+
import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
|
|
17
|
+
import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
|
|
16
18
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
19
|
+
buildTimelineElementsFromClips,
|
|
20
|
+
clipTreeParentMap,
|
|
21
|
+
collectSubCompositionDomChildren,
|
|
22
|
+
hydrateTimelineFromPreview,
|
|
23
|
+
isPreviewReadinessMessage,
|
|
24
|
+
safeContentDocument,
|
|
25
|
+
sanitizeDurationSeconds,
|
|
26
|
+
seekAdapterToRestorePoint,
|
|
27
|
+
syncAdapterDuration,
|
|
28
|
+
withImplicitDomLayers,
|
|
29
|
+
type RuntimeTimelineMessage,
|
|
30
|
+
} from "./timelineSyncHydration";
|
|
31
|
+
|
|
32
|
+
// Re-exported for the tests and callers that have always imported it from here.
|
|
33
|
+
export { resolveReloadSeekTime } from "./timelineSyncHydration";
|
|
31
34
|
|
|
32
35
|
interface UseTimelineSyncCallbacksParams {
|
|
33
36
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
@@ -70,27 +73,6 @@ export function revealIframe(iframe: HTMLIFrameElement | null): void {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
export function resolveReloadSeekTime(input: {
|
|
74
|
-
pendingSeek: number | null;
|
|
75
|
-
requestedSeek: number | null;
|
|
76
|
-
storeCurrentTime: number;
|
|
77
|
-
duration: number;
|
|
78
|
-
}): number {
|
|
79
|
-
const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
|
|
80
|
-
if (!Number.isFinite(target) || target <= 0) return 0;
|
|
81
|
-
// Only clamp to duration when it's a usable positive number. A non-finite or
|
|
82
|
-
// non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
|
|
83
|
-
// Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
|
|
84
|
-
// unclamped instead so the playhead lands at the intended position.
|
|
85
|
-
if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
|
|
86
|
-
return Math.min(target, input.duration);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
|
|
90
|
-
function sanitizeDurationSeconds(value: number): number {
|
|
91
|
-
return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
76
|
/**
|
|
95
77
|
* The transport TOTAL a clip-manifest message should write to the store.
|
|
96
78
|
*
|
|
@@ -130,14 +112,7 @@ export function useTimelineSyncCallbacks({
|
|
|
130
112
|
}: UseTimelineSyncCallbacksParams) {
|
|
131
113
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
132
114
|
const processTimelineMessage = useCallback(
|
|
133
|
-
(data: {
|
|
134
|
-
clips: ClipManifestClip[];
|
|
135
|
-
durationInFrames: number;
|
|
136
|
-
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
137
|
-
protocolVersion?: unknown;
|
|
138
|
-
capabilities?: unknown;
|
|
139
|
-
fps?: unknown;
|
|
140
|
-
}) => {
|
|
115
|
+
(data: RuntimeTimelineMessage) => {
|
|
141
116
|
if (!data.clips || data.clips.length === 0) {
|
|
142
117
|
return;
|
|
143
118
|
}
|
|
@@ -149,86 +124,18 @@ export function useTimelineSyncCallbacks({
|
|
|
149
124
|
const filtered = data.clips.filter(
|
|
150
125
|
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
|
151
126
|
);
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
iframeDoc = iframeRef.current?.contentDocument ?? null;
|
|
155
|
-
} catch {
|
|
156
|
-
iframeDoc = null;
|
|
157
|
-
}
|
|
127
|
+
const iframeDoc = safeContentDocument(iframeRef.current);
|
|
158
128
|
|
|
159
129
|
try {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
| null;
|
|
163
|
-
const clipTree = iframeWin?.__clipTree;
|
|
164
|
-
const parentMap = new Map<string, string>();
|
|
165
|
-
if (clipTree) {
|
|
166
|
-
const walk = (nodes: typeof clipTree.roots) => {
|
|
167
|
-
for (const node of nodes) {
|
|
168
|
-
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
|
|
169
|
-
if (node.children.length > 0) walk(node.children);
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
walk(clipTree.roots);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Descend into each sub-composition host: its internal elements (group
|
|
176
|
-
// wrappers + their children) carry no `data-start`, so the clip
|
|
177
|
-
// tree/manifest never enumerate them. Surface them studio-side as DOM
|
|
178
|
-
// children + parent links so the timeline can expand a sub-comp/group
|
|
179
|
-
// row to show them. Manifest stays lean (timed clips only).
|
|
180
|
-
const domClipChildren: DomClipChild[] = [];
|
|
181
|
-
if (iframeDoc) {
|
|
182
|
-
for (const clip of data.clips) {
|
|
183
|
-
if (clip.kind !== "composition" || !clip.id) continue;
|
|
184
|
-
const hostEl = iframeDoc.getElementById(clip.id);
|
|
185
|
-
if (!hostEl) continue;
|
|
186
|
-
const hostId = clip.id;
|
|
187
|
-
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
|
|
188
|
-
// Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they
|
|
189
|
-
// expand into timeline rows. Descends through id-less structural wrappers
|
|
190
|
-
// (the inlined sub-comp body), and one level into groups for drill-in.
|
|
191
|
-
const collect = (parentEl: Element, parentId: string) => {
|
|
192
|
-
for (const child of Array.from(parentEl.children)) {
|
|
193
|
-
if (!child.id) {
|
|
194
|
-
collect(child, parentId); // unwrap id-less structural containers
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
const isGroup = child.hasAttribute("data-hf-group");
|
|
198
|
-
domClipChildren.push({
|
|
199
|
-
id: child.id,
|
|
200
|
-
parentId,
|
|
201
|
-
hostId,
|
|
202
|
-
label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
|
|
203
|
-
stackingContextId: resolveCssStackingContextId(child),
|
|
204
|
-
});
|
|
205
|
-
parentMap.set(child.id, parentId);
|
|
206
|
-
if (isGroup) collect(child, child.id);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
collect(innerRoot, hostId);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
130
|
+
const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
|
|
131
|
+
const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
|
|
212
132
|
usePlayerStore.getState().setClipParentMap(parentMap);
|
|
213
133
|
usePlayerStore.getState().setDomClipChildren(domClipChildren);
|
|
214
134
|
} catch {
|
|
215
135
|
// cross-origin or __clipTree not available — maps stay empty
|
|
216
136
|
}
|
|
217
137
|
|
|
218
|
-
const
|
|
219
|
-
const els: TimelineElement[] = filtered.map((clip, index) => {
|
|
220
|
-
const hostEl = iframeDoc
|
|
221
|
-
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
222
|
-
: null;
|
|
223
|
-
if (hostEl) usedHostEls.add(hostEl);
|
|
224
|
-
return createTimelineElementFromManifestClip({
|
|
225
|
-
clip,
|
|
226
|
-
fallbackIndex: index,
|
|
227
|
-
doc: iframeDoc,
|
|
228
|
-
hostEl,
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
|
|
138
|
+
const els = buildTimelineElementsFromClips(filtered, iframeDoc);
|
|
232
139
|
// Clamp non-finite or absurdly large durations — the runtime can emit
|
|
233
140
|
// Infinity when it detects a loop-inflated GSAP timeline without an
|
|
234
141
|
// explicit data-duration on the root composition. Floor the manifest total
|
|
@@ -236,27 +143,14 @@ export function useTimelineSyncCallbacks({
|
|
|
236
143
|
// furthest clip end (shorter than the authored window) can't leave a stale,
|
|
237
144
|
// too-short total in the transport (the "0:44/0:40" bug).
|
|
238
145
|
const newDuration = resolveTimelineTotalDuration({
|
|
239
|
-
manifestDurationSeconds:
|
|
146
|
+
manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
|
|
240
147
|
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
|
|
241
148
|
});
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
.map((element) => ({
|
|
248
|
-
...element,
|
|
249
|
-
duration: Math.min(element.duration, effectiveDuration - element.start),
|
|
250
|
-
}))
|
|
251
|
-
.filter((element) => element.duration > 0)
|
|
252
|
-
: els;
|
|
253
|
-
const timelineEls =
|
|
254
|
-
iframeDoc && effectiveDuration > 0
|
|
255
|
-
? [
|
|
256
|
-
...clampedEls,
|
|
257
|
-
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls),
|
|
258
|
-
]
|
|
259
|
-
: clampedEls;
|
|
149
|
+
const timelineEls = withImplicitDomLayers(
|
|
150
|
+
els,
|
|
151
|
+
iframeDoc,
|
|
152
|
+
newDuration > 0 ? newDuration : usePlayerStore.getState().duration,
|
|
153
|
+
);
|
|
260
154
|
if (timelineEls.length > 0) {
|
|
261
155
|
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
|
|
262
156
|
}
|
|
@@ -294,34 +188,7 @@ export function useTimelineSyncCallbacks({
|
|
|
294
188
|
if (!adapter || adapter.getDuration() <= 0) return false;
|
|
295
189
|
|
|
296
190
|
adapter.pause();
|
|
297
|
-
|
|
298
|
-
// place: `pendingSeekRef` if the store subscription was mounted when requestSeek
|
|
299
|
-
// fired, or only in the store's `requestedSeekTime` if it fired earlier still
|
|
300
|
-
// (deep-link hydration runs before the player subscription mounts, so the request
|
|
301
|
-
// never reaches pendingSeekRef). Reconciling with the store here is what makes a
|
|
302
|
-
// deep-linked `?t=` land instead of starting at 0.
|
|
303
|
-
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
|
304
|
-
const startTime = resolveReloadSeekTime({
|
|
305
|
-
pendingSeek: pendingSeekRef.current,
|
|
306
|
-
requestedSeek: storeSeek,
|
|
307
|
-
storeCurrentTime: usePlayerStore.getState().currentTime,
|
|
308
|
-
duration: adapter.getDuration(),
|
|
309
|
-
});
|
|
310
|
-
pendingSeekRef.current = null;
|
|
311
|
-
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
312
|
-
|
|
313
|
-
// Force a REAL render at startTime, not a no-op. After a post-edit reload the
|
|
314
|
-
// freshly rebuilt GSAP timeline can already report being at `startTime`
|
|
315
|
-
// internally (the reload restores the same playhead), so a single
|
|
316
|
-
// `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
|
|
317
|
-
// doesn't re-evaluate. That's why a just-dropped clip stayed invisible until
|
|
318
|
-
// the user nudged the playhead: its element's state was never applied at the
|
|
319
|
-
// restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0
|
|
320
|
-
// when startTime is already ~0) guarantees the follow-up seek to `startTime`
|
|
321
|
-
// crosses a time boundary and re-renders every clip — including the new one.
|
|
322
|
-
const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001;
|
|
323
|
-
adapter.seek(guardTime);
|
|
324
|
-
adapter.seek(startTime);
|
|
191
|
+
const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
|
|
325
192
|
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid
|
|
326
193
|
// for the reload, so the user sees the restored frame directly (never the raw
|
|
327
194
|
// all-clips DOM). Cleared unconditionally: any later failure path must not leave
|
|
@@ -330,15 +197,7 @@ export function useTimelineSyncCallbacks({
|
|
|
330
197
|
// Keep non-React listeners such as the capture link and time display in sync
|
|
331
198
|
// with the initial adapter seek on iframe load.
|
|
332
199
|
liveTime.notify(startTime);
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
Number.isFinite(adapterDur) &&
|
|
336
|
-
adapterDur > 0 &&
|
|
337
|
-
adapterDur < 7200 &&
|
|
338
|
-
adapterDur !== usePlayerStore.getState().duration
|
|
339
|
-
) {
|
|
340
|
-
setDuration(adapterDur);
|
|
341
|
-
}
|
|
200
|
+
syncAdapterDuration(adapter, setDuration);
|
|
342
201
|
setCurrentTime(startTime);
|
|
343
202
|
if (!isRefreshingRef.current) {
|
|
344
203
|
setTimelineReady(true);
|
|
@@ -346,42 +205,15 @@ export function useTimelineSyncCallbacks({
|
|
|
346
205
|
isRefreshingRef.current = false;
|
|
347
206
|
setIsPlaying(false);
|
|
348
207
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const manifest = iframeWin?.__clipManifest;
|
|
360
|
-
if (manifest && manifest.clips.length > 0) {
|
|
361
|
-
processTimelineMessage(manifest);
|
|
362
|
-
}
|
|
363
|
-
enrichMissingCompositions();
|
|
364
|
-
applyPreviewAudioState();
|
|
365
|
-
|
|
366
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
367
|
-
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
368
|
-
if (els.length > 0) syncTimelineElements(els);
|
|
369
|
-
}
|
|
370
|
-
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
371
|
-
const rootComp = doc.querySelector("[data-composition-id]");
|
|
372
|
-
const rootDuration = adapter.getDuration();
|
|
373
|
-
if (rootComp && rootDuration > 0) {
|
|
374
|
-
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
375
|
-
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
376
|
-
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
377
|
-
rootDuration,
|
|
378
|
-
iframeSrc: iframe?.src || "",
|
|
379
|
-
selector: getTimelineElementSelector(rootComp),
|
|
380
|
-
});
|
|
381
|
-
if (fallbackElement) syncTimelineElements([fallbackElement]);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
} catch {}
|
|
208
|
+
hydrateTimelineFromPreview({
|
|
209
|
+
iframe: iframeRef.current,
|
|
210
|
+
adapter,
|
|
211
|
+
processTimelineMessage,
|
|
212
|
+
enrichMissingCompositions,
|
|
213
|
+
applyPreviewAudioState,
|
|
214
|
+
attachIframeShortcutListeners,
|
|
215
|
+
syncTimelineElements,
|
|
216
|
+
});
|
|
385
217
|
return true;
|
|
386
218
|
}, [
|
|
387
219
|
getAdapter,
|
|
@@ -421,15 +253,7 @@ export function useTimelineSyncCallbacks({
|
|
|
421
253
|
};
|
|
422
254
|
|
|
423
255
|
const onMessage = (e: MessageEvent) => {
|
|
424
|
-
if (e
|
|
425
|
-
const data = e.data;
|
|
426
|
-
if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
|
|
427
|
-
// The main message handler owns protocol-error diagnostics. This readiness-only
|
|
428
|
-
// listener mirrors its acceptance gate without dispatching a duplicate event:
|
|
429
|
-
// an unsupported runtime must not make the iframe appear successfully settled.
|
|
430
|
-
if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
|
|
431
|
-
trySettle();
|
|
432
|
-
}
|
|
256
|
+
if (isPreviewReadinessMessage(e, iframe)) trySettle();
|
|
433
257
|
};
|
|
434
258
|
window.addEventListener("message", onMessage);
|
|
435
259
|
|
|
@@ -80,4 +80,30 @@ describe("syncStoredAutomationFromPreview", () => {
|
|
|
80
80
|
syncStoredAutomationFromPreview(null);
|
|
81
81
|
expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
|
|
82
82
|
});
|
|
83
|
+
// The reported symptom: automate a parameter on a GROUP from the FX rack and
|
|
84
|
+
// the group's row shows no automation. The rack is not group-aware — it
|
|
85
|
+
// writes the attribute on the group node through the ordinary element path —
|
|
86
|
+
// and the timeline reads a group's lanes from the mirrors its MEMBERS carry,
|
|
87
|
+
// which that path used to leave untouched.
|
|
88
|
+
it("re-reads what the group carries onto every member that belongs to it", () => {
|
|
89
|
+
const chain = '{"version":1,"nodes":[{"type":"gain","id":"n1","params":{"gain":0}}]}';
|
|
90
|
+
const groupAutomation =
|
|
91
|
+
'{"version":1,"lanes":[{"target":"fx.n1.gain","points":[{"t":0,"v":0}]}]}';
|
|
92
|
+
const doc = document.implementation.createHTMLDocument("preview");
|
|
93
|
+
const group = doc.createElement("hf-audio-group");
|
|
94
|
+
group.id = "voiceover";
|
|
95
|
+
group.setAttribute("data-fx-chain", chain);
|
|
96
|
+
group.setAttribute("data-automation", groupAutomation);
|
|
97
|
+
const audio = doc.createElement("audio");
|
|
98
|
+
audio.id = "bgm";
|
|
99
|
+
audio.setAttribute("data-audio-group", "voiceover");
|
|
100
|
+
doc.body.append(group, audio);
|
|
101
|
+
|
|
102
|
+
usePlayerStore.setState({ elements: [el({ audioGroup: "voiceover" })] });
|
|
103
|
+
syncStoredAutomationFromPreview(doc);
|
|
104
|
+
|
|
105
|
+
const stored = usePlayerStore.getState().elements[0];
|
|
106
|
+
expect(stored?.audioGroupAutomation).toBe(groupAutomation);
|
|
107
|
+
expect(stored?.audioGroupFxChain).toBe(chain);
|
|
108
|
+
});
|
|
83
109
|
});
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
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
|
+
import { groupInfoFor } from "./timelineGroupInfo";
|
|
19
20
|
|
|
20
21
|
/** The preview node an element stands for, by dom id and then by `data-hf-id`. */
|
|
21
22
|
function previewNodeFor(doc: Document, element: TimelineElement): Element | null {
|
|
@@ -38,6 +39,27 @@ function previewNodeFor(doc: Document, element: TimelineElement): Element | null
|
|
|
38
39
|
* Reads rather than being told: an undo restores whole files, so the attribute it
|
|
39
40
|
* reverted is only known by looking.
|
|
40
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* What an element's four synced fields SHOULD read, given the preview.
|
|
44
|
+
*
|
|
45
|
+
* Its own two come off its node; the other two are its copy of what its group
|
|
46
|
+
* carries. The timeline derives a group's lanes and chain from these mirrors,
|
|
47
|
+
* never from the group element — and the FX rack is not group-aware: selecting
|
|
48
|
+
* a group and automating one of its parameters writes `data-automation` on the
|
|
49
|
+
* group node through the ordinary element path, which used to refresh an
|
|
50
|
+
* element's own two fields and nothing else. So the group's row went on reading
|
|
51
|
+
* the value it was born with, and its `∿` never appeared.
|
|
52
|
+
*/
|
|
53
|
+
function syncedFields(doc: Document, element: TimelineElement, node: Element) {
|
|
54
|
+
const group = element.audioGroup ? groupInfoFor(doc, element.audioGroup) : null;
|
|
55
|
+
return {
|
|
56
|
+
automation: node.getAttribute(HF_AUDIO_AUTOMATION_ATTR) ?? undefined,
|
|
57
|
+
fxChain: node.getAttribute(HF_AUDIO_FX_ATTR) ?? undefined,
|
|
58
|
+
audioGroupAutomation: group?.automation,
|
|
59
|
+
audioGroupFxChain: group?.fxChain,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
export function syncStoredAutomationFromPreview(doc: Document | null | undefined): void {
|
|
42
64
|
if (!doc) return;
|
|
43
65
|
usePlayerStore.setState((state) => {
|
|
@@ -45,11 +67,13 @@ export function syncStoredAutomationFromPreview(doc: Document | null | undefined
|
|
|
45
67
|
const elements = state.elements.map((element) => {
|
|
46
68
|
const node = previewNodeFor(doc, element);
|
|
47
69
|
if (!node) return element;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
70
|
+
const fields = syncedFields(doc, element, node);
|
|
71
|
+
// Same array back when nothing moved: `elements` keys memos all over the
|
|
72
|
+
// timeline, and a fresh object per sync would re-render every one.
|
|
73
|
+
const keys = Object.keys(fields) as (keyof typeof fields)[];
|
|
74
|
+
if (keys.every((key) => fields[key] === element[key])) return element;
|
|
51
75
|
changed = true;
|
|
52
|
-
return { ...element,
|
|
76
|
+
return { ...element, ...fields };
|
|
53
77
|
});
|
|
54
78
|
return changed ? { elements } : {};
|
|
55
79
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The studio → runtime boundary, which nothing else crosses.
|
|
5
|
+
*
|
|
6
|
+
* Studio addresses rows by `buildTimelineElementKey`'s composite
|
|
7
|
+
* `<sourceFile>#<domId>`; every audio predicate in `@hyperframes/core` keys off
|
|
8
|
+
* the live document instead. Both halves have their own passing tests — one
|
|
9
|
+
* with composite keys, one with bare ids — and the mismatch between them lived
|
|
10
|
+
* in the gap. These parse a real document, take the ids the way the UI does,
|
|
11
|
+
* and hand them to the real core predicates.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
|
|
16
|
+
import { parseTimelineFromDOM } from "./timelineDOM";
|
|
17
|
+
import { runtimeAudioId } from "./timelineElementHelpers";
|
|
18
|
+
|
|
19
|
+
function docWith(body: string): Document {
|
|
20
|
+
const doc = document.implementation.createHTMLDocument("comp");
|
|
21
|
+
doc.body.innerHTML = body;
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const COMPOSITION = `
|
|
26
|
+
<div data-composition-id="root" data-duration="30"></div>
|
|
27
|
+
<audio id="voice-1" data-start="0" data-duration="10" data-audio-group="voiceover"></audio>
|
|
28
|
+
<audio id="voice-2" data-start="10" data-duration="10" data-audio-group="voiceover"></audio>
|
|
29
|
+
<audio id="music-bed" data-start="0" data-duration="30"></audio>
|
|
30
|
+
<hf-audio-group id="voiceover"></hf-audio-group>
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
describe("group membership ids cross into the runtime", () => {
|
|
34
|
+
it("the ids the timeline hands to onGroupClips are the ids resolveAudioGroups reads back", () => {
|
|
35
|
+
const doc = docWith(COMPOSITION);
|
|
36
|
+
const trackElements = parseTimelineFromDOM(doc, 30).filter(
|
|
37
|
+
(el) => el.tag.toLowerCase() === "audio",
|
|
38
|
+
);
|
|
39
|
+
const clipIds = trackElements.map(runtimeAudioId).filter((id): id is string => id !== null);
|
|
40
|
+
expect(clipIds).toEqual(["voice-1", "voice-2", "music-bed"]);
|
|
41
|
+
|
|
42
|
+
// Same space membership is read back in — a composite key here produces a
|
|
43
|
+
// group whose members nothing can find.
|
|
44
|
+
const memberIds = resolveAudioGroups(doc).flatMap((g) => g.memberIds);
|
|
45
|
+
expect(memberIds.every((id) => doc.getElementById(id) !== null)).toBe(true);
|
|
46
|
+
for (const id of memberIds) expect(clipIds).toContain(id);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("an element with no DOM id is not groupable", () => {
|
|
50
|
+
const doc = docWith(`
|
|
51
|
+
<div data-composition-id="root" data-duration="10"></div>
|
|
52
|
+
<audio data-start="0" data-duration="5"></audio>
|
|
53
|
+
`);
|
|
54
|
+
const [clip] = parseTimelineFromDOM(doc, 10);
|
|
55
|
+
expect(clip).toBeDefined();
|
|
56
|
+
expect(runtimeAudioId(clip)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
createImplicitTimelineLayersFromDOM,
|
|
7
7
|
mergeTimelineElementsPreservingDowngrades,
|
|
8
8
|
} from "./timelineDOM";
|
|
9
|
+
import { isTimelineIgnoredElement } from "./timelineElementHelpers";
|
|
10
|
+
import { invalidateGroupInfoCache } from "./timelineGroupInfo";
|
|
9
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
10
12
|
|
|
11
13
|
function el(id: string, extra: Partial<TimelineElement> = {}): TimelineElement {
|
|
@@ -110,6 +112,92 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
|
|
|
110
112
|
});
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
describe("group info cache", () => {
|
|
116
|
+
const parseMember = (doc: Document) =>
|
|
117
|
+
createTimelineElementFromManifestClip({
|
|
118
|
+
clip: {
|
|
119
|
+
id: "voice-1",
|
|
120
|
+
label: "voice-1",
|
|
121
|
+
kind: "element",
|
|
122
|
+
tagName: "audio",
|
|
123
|
+
start: 0,
|
|
124
|
+
duration: 5,
|
|
125
|
+
track: 0,
|
|
126
|
+
compositionId: null,
|
|
127
|
+
parentCompositionId: null,
|
|
128
|
+
compositionSrc: null,
|
|
129
|
+
assetUrl: null,
|
|
130
|
+
},
|
|
131
|
+
fallbackIndex: 0,
|
|
132
|
+
doc,
|
|
133
|
+
hostEl: doc.getElementById("voice-1"),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Group edits are applied as LIVE patches so the preview iframe never
|
|
137
|
+
// reloads, which means the document identity this cache is keyed on never
|
|
138
|
+
// changes either. Without an explicit drop, a muted group could never be
|
|
139
|
+
// unmuted: the header kept reading the cached `hidden: false` and re-wrote
|
|
140
|
+
// `data-hidden` forever.
|
|
141
|
+
it("re-reads group state after an invalidation", () => {
|
|
142
|
+
const doc = makeDoc(`
|
|
143
|
+
<div data-composition-id="root">
|
|
144
|
+
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
|
145
|
+
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
|
146
|
+
</div>
|
|
147
|
+
`);
|
|
148
|
+
|
|
149
|
+
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
150
|
+
|
|
151
|
+
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
|
152
|
+
expect(parseMember(doc).audioGroupHidden).toBe(false); // still the cached scan
|
|
153
|
+
|
|
154
|
+
invalidateGroupInfoCache(doc);
|
|
155
|
+
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
|
156
|
+
|
|
157
|
+
doc.getElementById("voiceover")?.removeAttribute("data-hidden");
|
|
158
|
+
invalidateGroupInfoCache(doc);
|
|
159
|
+
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The explicit invalidator is a convenience, not the contract. A cache whose
|
|
163
|
+
// only defence is "every writer must remember to call this" rots the first
|
|
164
|
+
// time a writer does not know it exists — which is precisely what happened
|
|
165
|
+
// with the FX rack, whose group writes go through the DOM editor rather than
|
|
166
|
+
// the timeline's own writers. The scan carries the DOM revision it was taken
|
|
167
|
+
// at, so a forgotten call costs a re-scan rather than a wrong answer.
|
|
168
|
+
it("expires itself on a group edit nobody announced", async () => {
|
|
169
|
+
const doc = makeDoc(`
|
|
170
|
+
<div data-composition-id="root">
|
|
171
|
+
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
|
172
|
+
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
|
173
|
+
</div>
|
|
174
|
+
`);
|
|
175
|
+
|
|
176
|
+
expect(parseMember(doc).audioGroupHidden).toBe(false);
|
|
177
|
+
|
|
178
|
+
// No invalidateGroupInfoCache call anywhere in this test.
|
|
179
|
+
doc.getElementById("voiceover")?.setAttribute("data-hidden", "");
|
|
180
|
+
await new Promise((resolve) => setTimeout(resolve, 0)); // observer microtask
|
|
181
|
+
|
|
182
|
+
expect(parseMember(doc).audioGroupHidden).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("notices a member joining the group, not just an attribute edit", async () => {
|
|
186
|
+
const doc = makeDoc(`
|
|
187
|
+
<div data-composition-id="root">
|
|
188
|
+
<audio id="voice-1" data-start="0" data-duration="5" data-audio-group="voiceover"></audio>
|
|
189
|
+
<hf-audio-group id="voiceover" data-label="Voices"></hf-audio-group>
|
|
190
|
+
</div>
|
|
191
|
+
`);
|
|
192
|
+
expect(parseMember(doc).audioGroupLabel).toBe("Voices");
|
|
193
|
+
|
|
194
|
+
doc.getElementById("voiceover")?.setAttribute("data-label", "Narration");
|
|
195
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
196
|
+
|
|
197
|
+
expect(parseMember(doc).audioGroupLabel).toBe("Narration");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
113
201
|
describe("parseTimelineFromDOM — canonical playback rate", () => {
|
|
114
202
|
it.each([
|
|
115
203
|
["10", 5],
|
|
@@ -207,6 +295,33 @@ describe("createTimelineElementFromManifestClip — source-scoped selector ident
|
|
|
207
295
|
});
|
|
208
296
|
});
|
|
209
297
|
|
|
298
|
+
// Caught by looking at the studio, not by reading: a grouped composition drew
|
|
299
|
+
// "Voiceover • 0.0s – 12.0s" as a full-duration clip row directly above its own
|
|
300
|
+
// group header. `<hf-audio-group>` is a mixer bus — no timing, drawn as a group
|
|
301
|
+
// row by the group derivation — but it is still a body child with an id, so the
|
|
302
|
+
// implicit-layer fallback happily gave it a track. Draggable and trimmable, and
|
|
303
|
+
// writing timing onto a bus means nothing.
|
|
304
|
+
describe("<hf-audio-group> is not a timeline layer", () => {
|
|
305
|
+
it("gets no implicit row of its own", () => {
|
|
306
|
+
const doc = makeDoc(`
|
|
307
|
+
<div data-composition-id="root">
|
|
308
|
+
<audio id="voice-1" data-start="0" data-duration="6" data-audio-group="voiceover"></audio>
|
|
309
|
+
<hf-audio-group id="voiceover" data-label="Voiceover"></hf-audio-group>
|
|
310
|
+
</div>
|
|
311
|
+
`);
|
|
312
|
+
|
|
313
|
+
const implicit = createImplicitTimelineLayersFromDOM(doc, 12, []);
|
|
314
|
+
|
|
315
|
+
expect(implicit.map((el) => el.domId)).not.toContain("voiceover");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("is excluded by the shared ignore predicate", () => {
|
|
319
|
+
const doc = makeDoc(`<hf-audio-group id="vo"></hf-audio-group><div id="panel"></div>`);
|
|
320
|
+
expect(isTimelineIgnoredElement(doc.getElementById("vo") as Element)).toBe(true);
|
|
321
|
+
expect(isTimelineIgnoredElement(doc.getElementById("panel") as Element)).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
210
325
|
describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
|
|
211
326
|
it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
|
|
212
327
|
const doc = makeDoc(`
|