@hyperframes/studio 0.8.11 → 0.8.13
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-BP9rShvS.js} +1 -1
- package/dist/assets/{index-1vMlv05L.js → index-CBm9bYHQ.js} +1 -1
- package/dist/assets/{index-BX0piiWc.js → index-CDerRsMw.js} +1 -1
- package/dist/assets/index-CQMHN4nk.js +431 -0
- 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 +7646 -6514
- 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/domSelectionTimelineMirror.ts +12 -3
- 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/useDomSelectionSelectionGuards.test.ts +115 -0
- 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/AutomationEnvelopePaths.tsx +54 -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 +180 -0
- package/src/player/components/TimelineAutomationLane.tsx +129 -36
- 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 +99 -0
- package/src/player/components/TimelineGroupRow.test.tsx +222 -0
- package/src/player/components/TimelineGroupRow.tsx +140 -71
- 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/automationLaneDragMath.ts +28 -0
- package/src/player/components/automationLaneGeometry.ts +52 -13
- 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/useAutomationLaneGestures.ts +66 -18
- package/src/player/components/useAutomationSegmentDrag.ts +110 -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
|
@@ -7,7 +7,6 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
|
7
7
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
8
8
|
import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
|
|
9
9
|
import {
|
|
10
|
-
STRIP_H,
|
|
11
10
|
TRACK_H,
|
|
12
11
|
createTimelineRowGeometry,
|
|
13
12
|
type TimelineRowGeometry,
|
|
@@ -15,6 +14,13 @@ import {
|
|
|
15
14
|
type TimelineTrackHeightClip,
|
|
16
15
|
} from "./timelineLayout";
|
|
17
16
|
import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
|
|
17
|
+
import { groupAutomationElement } from "./groupAutomationElement";
|
|
18
|
+
import { AUTOMATION_LANE_H } from "./automationLaneHeight";
|
|
19
|
+
|
|
20
|
+
/** Automation rows the GROUP itself owns — its `data-automation`, not its members'. */
|
|
21
|
+
function groupOwnLaneCount(group: TimelineTrackGroupInfo): number {
|
|
22
|
+
return groupAutomationLanes([groupAutomationElement(group, 0)]).length;
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
export { getTrackStyle } from "./timelineIcons";
|
|
20
26
|
|
|
@@ -132,8 +138,8 @@ function computeLaneCounts(
|
|
|
132
138
|
/** Group anchor rows have no elements of their own (`groupTimelineTracks`
|
|
133
139
|
* pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever
|
|
134
140
|
* looks at a row's clips — always gives them TRACK_H. Override those
|
|
135
|
-
* specific rows post-hoc: TRACK_H while collapsed,
|
|
136
|
-
*
|
|
141
|
+
* specific rows post-hoc: TRACK_H while collapsed, plus the group's own
|
|
142
|
+
* automation rows once its `∿` is open. */
|
|
137
143
|
function applyGroupStripHeights(
|
|
138
144
|
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
|
|
139
145
|
rowHeights: number[],
|
|
@@ -145,7 +151,9 @@ function applyGroupStripHeights(
|
|
|
145
151
|
return tracks.map(([track], index) => {
|
|
146
152
|
const group = groupByAnchor.get(track);
|
|
147
153
|
if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H;
|
|
148
|
-
|
|
154
|
+
// The group's own automation rows, which its `∿` discloses. A row sized
|
|
155
|
+
// without them clipped every lane it had just promised in the count.
|
|
156
|
+
return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H;
|
|
149
157
|
});
|
|
150
158
|
}
|
|
151
159
|
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Extracted from `useTimelinePlayer`, which had grown past the studio's 600-line
|
|
5
5
|
* file cap and carried a `fallow-ignore-next-line complexity` on this function
|
|
6
|
-
* admitting the same thing. Nothing here is new logic — it is the same
|
|
7
|
-
* branches (accept-gate,
|
|
8
|
-
*
|
|
6
|
+
* admitting the same thing. Nothing here is new logic — it is the same three
|
|
7
|
+
* branches (accept-gate, state, timeline) against the same refs, with the
|
|
8
|
+
* suppression retired rather than moved. The group-levels branch went with the
|
|
9
|
+
* level meter it fed (see the group volume/meter removal).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
12
13
|
import type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
|
13
14
|
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
14
15
|
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
|
15
|
-
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
|
|
16
16
|
import { parseTimelineFromDOM } from "../lib/timelineDOM";
|
|
17
17
|
|
|
18
18
|
/** What `processTimelineMessage` accepts — the clip-manifest postMessage. */
|
|
@@ -60,12 +60,6 @@ function acceptedPreviewMessage(
|
|
|
60
60
|
return acceptStudioRuntimeMessage(data) ? data : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** One meter reading per group with an active member. */
|
|
64
|
-
function handleGroupLevelsMessage(data: PreviewMessage): void {
|
|
65
|
-
const levels = parseGroupLevelsMessage(data);
|
|
66
|
-
if (levels) groupLevels.notify(levels);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
63
|
/**
|
|
70
64
|
* A `state` tick doubles as a recovery hook: if the store still has no
|
|
71
65
|
* elements, read the manifest straight off the iframe, and if no `timeline`
|
|
@@ -118,7 +112,6 @@ export function createPreviewMessageHandler(
|
|
|
118
112
|
const iframe = deps.iframeRef.current;
|
|
119
113
|
const data = acceptedPreviewMessage(e, iframe);
|
|
120
114
|
if (!data) return;
|
|
121
|
-
if (data.type === "group-levels") return handleGroupLevelsMessage(data);
|
|
122
115
|
if (data.type === "state") return handleStateMessage(deps, iframe);
|
|
123
116
|
if (data.type === "timeline" && Array.isArray(data.clips)) {
|
|
124
117
|
handleTimelineMessage(deps, iframe, data as unknown as ClipManifestMessage);
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the timeline's preview sync: reading a runtime clip manifest
|
|
3
|
+
* and a live preview DOM into `TimelineElement`s, and the steps that hydrate a
|
|
4
|
+
* freshly-loaded adapter.
|
|
5
|
+
*
|
|
6
|
+
* Split out of `useTimelineSyncCallbacks.ts`, which held all of this inline
|
|
7
|
+
* inside `processTimelineMessage` and `initializeAdapter` and stood at 642 lines
|
|
8
|
+
* against the studio's 600-line cap. Every function here takes what it needs as
|
|
9
|
+
* an argument, so each is callable — and readable — on its own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
13
|
+
import type { TimelineElement, DomClipChild } from "../store/playerStore";
|
|
14
|
+
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
15
|
+
import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
|
|
16
|
+
import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
|
|
17
|
+
import { groupInfoFor } from "../lib/timelineGroupInfo";
|
|
18
|
+
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
|
|
19
|
+
import {
|
|
20
|
+
buildStandaloneRootTimelineElement,
|
|
21
|
+
createImplicitTimelineLayersFromDOM,
|
|
22
|
+
createTimelineElementFromManifestClip,
|
|
23
|
+
findTimelineDomNodeForClip,
|
|
24
|
+
getTimelineElementSelector,
|
|
25
|
+
parseTimelineFromDOM,
|
|
26
|
+
} from "../lib/timelineDOM";
|
|
27
|
+
import {
|
|
28
|
+
autoHealMissingCompositionIds,
|
|
29
|
+
normalizePreviewViewport,
|
|
30
|
+
} from "../lib/timelineIframeHelpers";
|
|
31
|
+
import { inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
|
32
|
+
|
|
33
|
+
/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */
|
|
34
|
+
export function sanitizeDurationSeconds(value: number): number {
|
|
35
|
+
return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A sub-comp child's audio-group membership, read off its live element.
|
|
40
|
+
*
|
|
41
|
+
* Captured during the DOM walk because that walk holds the only reference to
|
|
42
|
+
* the element. A sub-composition that declares both a group and its members
|
|
43
|
+
* keeps those members out of the flat store entirely, so an expanded child has
|
|
44
|
+
* no flat twin to inherit membership from later — without this, a group defined
|
|
45
|
+
* inside a sub-composition produced no group row at all.
|
|
46
|
+
*/
|
|
47
|
+
function readChildAudioGroupState(child: Element): Partial<DomClipChild> {
|
|
48
|
+
const audioGroup = child.getAttribute(HF_AUDIO_GROUP_ATTR);
|
|
49
|
+
if (!audioGroup) return {};
|
|
50
|
+
const info = groupInfoFor(child.ownerDocument, audioGroup);
|
|
51
|
+
return {
|
|
52
|
+
audioGroup,
|
|
53
|
+
audioGroupLabel: info.label,
|
|
54
|
+
audioGroupVolume: info.volume,
|
|
55
|
+
audioGroupHidden: info.hidden,
|
|
56
|
+
...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}),
|
|
57
|
+
...(info.automation ? { audioGroupAutomation: info.automation } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The runtime's clip tree as a child-id -> parent-id map.
|
|
63
|
+
*
|
|
64
|
+
* Empty when the tree is absent (cross-origin, or the runtime has not published
|
|
65
|
+
* it yet), which the caller treats the same as "no nesting".
|
|
66
|
+
*/
|
|
67
|
+
export function clipTreeParentMap(win: Window | null): Map<string, string> {
|
|
68
|
+
const parentMap = new Map<string, string>();
|
|
69
|
+
const clipTree = (win as (Window & { __clipTree?: ClipTree }) | null)?.__clipTree;
|
|
70
|
+
if (!clipTree) return parentMap;
|
|
71
|
+
const walk = (nodes: ClipTree["roots"]) => {
|
|
72
|
+
for (const node of nodes) {
|
|
73
|
+
if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
|
|
74
|
+
if (node.children.length > 0) walk(node.children);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(clipTree.roots);
|
|
78
|
+
return parentMap;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One sub-composition host's id'd descendants, as timeline-expandable rows.
|
|
83
|
+
*
|
|
84
|
+
* Descends through id-less structural wrappers (the inlined sub-comp body) and
|
|
85
|
+
* one level into groups for drill-in. Also records each child's parent in
|
|
86
|
+
* `parentMap`, which it mutates: the walk is the only place both ends of the
|
|
87
|
+
* link are in hand.
|
|
88
|
+
*/
|
|
89
|
+
function collectHostDomChildren(
|
|
90
|
+
hostId: string,
|
|
91
|
+
parentEl: Element,
|
|
92
|
+
parentId: string,
|
|
93
|
+
parentMap: Map<string, string>,
|
|
94
|
+
out: DomClipChild[],
|
|
95
|
+
): void {
|
|
96
|
+
for (const child of Array.from(parentEl.children)) {
|
|
97
|
+
if (!child.id) {
|
|
98
|
+
collectHostDomChildren(hostId, child, parentId, parentMap, out); // id-less wrapper
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const isGroup = child.hasAttribute("data-hf-group");
|
|
102
|
+
out.push({
|
|
103
|
+
id: child.id,
|
|
104
|
+
parentId,
|
|
105
|
+
hostId,
|
|
106
|
+
label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
|
|
107
|
+
stackingContextId: resolveCssStackingContextId(child),
|
|
108
|
+
...readChildAudioGroupState(child),
|
|
109
|
+
});
|
|
110
|
+
parentMap.set(child.id, parentId);
|
|
111
|
+
if (isGroup) collectHostDomChildren(hostId, child, child.id, parentMap, out);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Every sub-composition's internal elements, across the whole manifest.
|
|
117
|
+
*
|
|
118
|
+
* Those elements (group wrappers + their children) carry no `data-start`, so the
|
|
119
|
+
* clip tree and the manifest never enumerate them. Surfacing them studio-side
|
|
120
|
+
* as DOM children + parent links is what lets the timeline expand a
|
|
121
|
+
* sub-comp/group row; the manifest stays lean (timed clips only).
|
|
122
|
+
*/
|
|
123
|
+
export function collectSubCompositionDomChildren(
|
|
124
|
+
iframeDoc: Document | null,
|
|
125
|
+
clips: readonly ClipManifestClip[],
|
|
126
|
+
parentMap: Map<string, string>,
|
|
127
|
+
): DomClipChild[] {
|
|
128
|
+
const out: DomClipChild[] = [];
|
|
129
|
+
if (!iframeDoc) return out;
|
|
130
|
+
for (const clip of clips) {
|
|
131
|
+
if (clip.kind !== "composition" || !clip.id) continue;
|
|
132
|
+
const hostEl = iframeDoc.getElementById(clip.id);
|
|
133
|
+
if (!hostEl) continue;
|
|
134
|
+
const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
|
|
135
|
+
collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** An iframe's document, or null when reading it throws (cross-origin, or the
|
|
141
|
+
* frame is mid-navigation). */
|
|
142
|
+
export function safeContentDocument(iframe: HTMLIFrameElement | null): Document | null {
|
|
143
|
+
try {
|
|
144
|
+
return iframe?.contentDocument ?? null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The manifest's root clips as TimelineElements, each bound to the live DOM node
|
|
152
|
+
* it was authored as. `usedHostEls` makes the binding one-to-one: two clips with
|
|
153
|
+
* the same shape must not both claim the same element.
|
|
154
|
+
*/
|
|
155
|
+
export function buildTimelineElementsFromClips(
|
|
156
|
+
clips: readonly ClipManifestClip[],
|
|
157
|
+
iframeDoc: Document | null,
|
|
158
|
+
): TimelineElement[] {
|
|
159
|
+
const usedHostEls = new Set<Element>();
|
|
160
|
+
return clips.map((clip, index) => {
|
|
161
|
+
const hostEl = iframeDoc
|
|
162
|
+
? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls)
|
|
163
|
+
: null;
|
|
164
|
+
if (hostEl) usedHostEls.add(hostEl);
|
|
165
|
+
return createTimelineElementFromManifestClip({
|
|
166
|
+
clip,
|
|
167
|
+
fallbackIndex: index,
|
|
168
|
+
doc: iframeDoc,
|
|
169
|
+
hostEl,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The clamped manifest elements plus the layers that exist only in the DOM.
|
|
176
|
+
* Both halves need the same resolved duration, which is why they land together.
|
|
177
|
+
*/
|
|
178
|
+
export function withImplicitDomLayers(
|
|
179
|
+
els: readonly TimelineElement[],
|
|
180
|
+
iframeDoc: Document | null,
|
|
181
|
+
effectiveDuration: number,
|
|
182
|
+
): TimelineElement[] {
|
|
183
|
+
const clamped = clampElementsToDuration(els, effectiveDuration);
|
|
184
|
+
if (!iframeDoc || effectiveDuration <= 0) return clamped;
|
|
185
|
+
return [
|
|
186
|
+
...clamped,
|
|
187
|
+
...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clamped),
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Drop elements that start past the composition's end and trim the ones that
|
|
193
|
+
* straddle it. A non-positive duration means "not known yet" — pass through
|
|
194
|
+
* untouched rather than clamping everything to nothing.
|
|
195
|
+
*/
|
|
196
|
+
function clampElementsToDuration(
|
|
197
|
+
els: readonly TimelineElement[],
|
|
198
|
+
effectiveDuration: number,
|
|
199
|
+
): TimelineElement[] {
|
|
200
|
+
if (effectiveDuration <= 0) return [...els];
|
|
201
|
+
return els
|
|
202
|
+
.filter((element) => element.start < effectiveDuration)
|
|
203
|
+
.map((element) => ({
|
|
204
|
+
...element,
|
|
205
|
+
duration: Math.min(element.duration, effectiveDuration - element.start),
|
|
206
|
+
}))
|
|
207
|
+
.filter((element) => element.duration > 0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Seek a freshly-loaded adapter to the playhead the session should resume at,
|
|
212
|
+
* and return it.
|
|
213
|
+
*
|
|
214
|
+
* Honors a seek requested before the adapter was ready. It may sit in either
|
|
215
|
+
* place: `pendingSeekRef` if the store subscription was mounted when requestSeek
|
|
216
|
+
* fired, or only in the store's `requestedSeekTime` if it fired earlier still
|
|
217
|
+
* (deep-link hydration runs before the player subscription mounts, so the
|
|
218
|
+
* request never reaches pendingSeekRef). Reconciling with the store here is what
|
|
219
|
+
* makes a deep-linked `?t=` land instead of starting at 0.
|
|
220
|
+
*
|
|
221
|
+
* The double seek forces a REAL render, not a no-op. After a post-edit reload the
|
|
222
|
+
* freshly rebuilt GSAP timeline can already report being at `startTime`
|
|
223
|
+
* internally (the reload restores the same playhead), so a single
|
|
224
|
+
* `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time
|
|
225
|
+
* doesn't re-evaluate. That's why a just-dropped clip stayed invisible until the
|
|
226
|
+
* user nudged the playhead: its element's state was never applied at the restore
|
|
227
|
+
* position. Seeking to a DIFFERENT guard value first (a hair off, or 0 when
|
|
228
|
+
* startTime is already ~0) guarantees the follow-up seek crosses a time boundary
|
|
229
|
+
* and re-renders every clip — including the new one.
|
|
230
|
+
*/
|
|
231
|
+
export function resolveReloadSeekTime(input: {
|
|
232
|
+
pendingSeek: number | null;
|
|
233
|
+
requestedSeek: number | null;
|
|
234
|
+
storeCurrentTime: number;
|
|
235
|
+
duration: number;
|
|
236
|
+
}): number {
|
|
237
|
+
const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime;
|
|
238
|
+
if (!Number.isFinite(target) || target <= 0) return 0;
|
|
239
|
+
// Only clamp to duration when it's a usable positive number. A non-finite or
|
|
240
|
+
// non-positive duration (e.g. the adapter reports NaN mid-reload) would turn
|
|
241
|
+
// Math.min(target, NaN) into NaN and seek(NaN); return the guarded target
|
|
242
|
+
// unclamped instead so the playhead lands at the intended position.
|
|
243
|
+
if (!Number.isFinite(input.duration) || input.duration <= 0) return target;
|
|
244
|
+
return Math.min(target, input.duration);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function seekAdapterToRestorePoint(
|
|
248
|
+
adapter: PlaybackAdapter,
|
|
249
|
+
pendingSeekRef: { current: number | null },
|
|
250
|
+
): number {
|
|
251
|
+
const storeSeek = usePlayerStore.getState().requestedSeekTime;
|
|
252
|
+
const startTime = resolveReloadSeekTime({
|
|
253
|
+
pendingSeek: pendingSeekRef.current,
|
|
254
|
+
requestedSeek: storeSeek,
|
|
255
|
+
storeCurrentTime: usePlayerStore.getState().currentTime,
|
|
256
|
+
duration: adapter.getDuration(),
|
|
257
|
+
});
|
|
258
|
+
pendingSeekRef.current = null;
|
|
259
|
+
if (storeSeek != null) usePlayerStore.getState().clearSeekRequest();
|
|
260
|
+
adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001);
|
|
261
|
+
adapter.seek(startTime);
|
|
262
|
+
return startTime;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Push the adapter's own duration into the store, ignoring the values
|
|
266
|
+
* `sanitizeDurationSeconds` rejects and a value already in place. */
|
|
267
|
+
export function syncAdapterDuration(
|
|
268
|
+
adapter: PlaybackAdapter,
|
|
269
|
+
setDuration: (d: number) => void,
|
|
270
|
+
): void {
|
|
271
|
+
const adapterDur = sanitizeDurationSeconds(adapter.getDuration());
|
|
272
|
+
if (adapterDur > 0 && adapterDur !== usePlayerStore.getState().duration) {
|
|
273
|
+
setDuration(adapterDur);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Last-resort timeline for a preview whose manifest produced nothing: parse the
|
|
279
|
+
* DOM, and failing that stand the root composition up as a single element.
|
|
280
|
+
* Without it a composition the runtime never enumerated shows an empty timeline
|
|
281
|
+
* rather than one row spanning its own duration.
|
|
282
|
+
*/
|
|
283
|
+
function syncFallbackTimelineFromDom(
|
|
284
|
+
doc: Document,
|
|
285
|
+
iframe: HTMLIFrameElement | null,
|
|
286
|
+
rootDuration: number,
|
|
287
|
+
syncTimelineElements: (els: TimelineElement[], duration?: number) => void,
|
|
288
|
+
): void {
|
|
289
|
+
const els = parseTimelineFromDOM(doc, rootDuration);
|
|
290
|
+
if (els.length > 0) {
|
|
291
|
+
syncTimelineElements(els);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const rootComp = doc.querySelector("[data-composition-id]");
|
|
295
|
+
if (!rootComp || rootDuration <= 0) return;
|
|
296
|
+
const fallbackElement = buildStandaloneRootTimelineElement({
|
|
297
|
+
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
|
298
|
+
tagName: (rootComp as HTMLElement).tagName || "div",
|
|
299
|
+
rootDuration,
|
|
300
|
+
iframeSrc: iframe?.src || "",
|
|
301
|
+
selector: getTimelineElementSelector(rootComp),
|
|
302
|
+
});
|
|
303
|
+
if (fallbackElement) syncTimelineElements([fallbackElement]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The runtime's timeline message, as the preview posts it. */
|
|
307
|
+
export interface RuntimeTimelineMessage {
|
|
308
|
+
clips: ClipManifestClip[];
|
|
309
|
+
durationInFrames: number;
|
|
310
|
+
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
|
|
311
|
+
protocolVersion?: unknown;
|
|
312
|
+
capabilities?: unknown;
|
|
313
|
+
fps?: unknown;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Whether a window message came from the preview iframe we are watching.
|
|
317
|
+
* A message with no `source` (jsdom, synthetic dispatch) is not rejected. */
|
|
318
|
+
function isFromPreviewFrame(e: MessageEvent, iframe: HTMLIFrameElement | null): boolean {
|
|
319
|
+
if (!e.source || !iframe) return true;
|
|
320
|
+
return e.source === iframe.contentWindow;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Whether a message is a preview readiness signal this listener should act on.
|
|
325
|
+
*
|
|
326
|
+
* The main message handler owns protocol-error diagnostics. This readiness-only
|
|
327
|
+
* listener mirrors its acceptance gate without dispatching a duplicate event: an
|
|
328
|
+
* unsupported runtime must not make the iframe appear successfully settled.
|
|
329
|
+
*/
|
|
330
|
+
export function isPreviewReadinessMessage(
|
|
331
|
+
e: MessageEvent,
|
|
332
|
+
iframe: HTMLIFrameElement | null,
|
|
333
|
+
): boolean {
|
|
334
|
+
if (!isFromPreviewFrame(e, iframe)) return false;
|
|
335
|
+
const data = e.data;
|
|
336
|
+
if (data?.source !== "hf-preview") return false;
|
|
337
|
+
if (data?.type !== "state" && data?.type !== "timeline") return false;
|
|
338
|
+
return inspectStudioRuntimeMessage(data).status !== "unsupported";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export interface HydrateTimelineFromPreviewInput {
|
|
342
|
+
iframe: HTMLIFrameElement | null;
|
|
343
|
+
adapter: PlaybackAdapter;
|
|
344
|
+
processTimelineMessage: (manifest: RuntimeTimelineMessage) => void;
|
|
345
|
+
enrichMissingCompositions: () => void;
|
|
346
|
+
applyPreviewAudioState: () => void;
|
|
347
|
+
attachIframeShortcutListeners: () => void;
|
|
348
|
+
syncTimelineElements: (els: TimelineElement[], duration?: number) => void;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Everything the timeline reads off a newly-loaded preview: viewport
|
|
353
|
+
* normalisation, the runtime's own clip manifest, composition enrichment, audio
|
|
354
|
+
* state, and the DOM fallbacks when none of that produced a row.
|
|
355
|
+
*
|
|
356
|
+
* Wrapped in one try, as it always was: any of these can throw on a
|
|
357
|
+
* cross-origin or mid-navigation frame, and none of them is worth failing the
|
|
358
|
+
* adapter's initialisation over.
|
|
359
|
+
*/
|
|
360
|
+
function normalizePreviewDom(
|
|
361
|
+
doc: Document | null,
|
|
362
|
+
iframeWin: IframeWindow | null,
|
|
363
|
+
attachIframeShortcutListeners: () => void,
|
|
364
|
+
): void {
|
|
365
|
+
if (!doc || !iframeWin) return;
|
|
366
|
+
normalizePreviewViewport(doc, iframeWin);
|
|
367
|
+
autoHealMissingCompositionIds(doc);
|
|
368
|
+
attachIframeShortcutListeners();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Hand the runtime's own clip manifest to the timeline, if it published one. */
|
|
372
|
+
function applyRuntimeClipManifest(
|
|
373
|
+
iframeWin: IframeWindow | null,
|
|
374
|
+
processTimelineMessage: (manifest: RuntimeTimelineMessage) => void,
|
|
375
|
+
): void {
|
|
376
|
+
const manifest = iframeWin?.__clipManifest;
|
|
377
|
+
if (manifest && manifest.clips.length > 0) processTimelineMessage(manifest);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function hydrateTimelineFromPreview(input: HydrateTimelineFromPreviewInput): void {
|
|
381
|
+
const { iframe, adapter, syncTimelineElements } = input;
|
|
382
|
+
try {
|
|
383
|
+
const doc = safeContentDocument(iframe);
|
|
384
|
+
const iframeWin = (iframe?.contentWindow as IframeWindow | null) ?? null;
|
|
385
|
+
normalizePreviewDom(doc, iframeWin, input.attachIframeShortcutListeners);
|
|
386
|
+
applyRuntimeClipManifest(iframeWin, input.processTimelineMessage);
|
|
387
|
+
input.enrichMissingCompositions();
|
|
388
|
+
input.applyPreviewAudioState();
|
|
389
|
+
if (doc && usePlayerStore.getState().elements.length === 0) {
|
|
390
|
+
syncFallbackTimelineFromDom(doc, iframe, adapter.getDuration(), syncTimelineElements);
|
|
391
|
+
}
|
|
392
|
+
} catch {
|
|
393
|
+
// Cross-origin or mid-navigation preview — the adapter is still initialised.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -645,4 +645,87 @@ describe("buildExpandedElements — collision-free synthetic rows (cross-file la
|
|
|
645
645
|
// Distinct ordered rows per child.
|
|
646
646
|
expect(children[0].track).not.toBe(children[1].track);
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* A sub-composition that declares BOTH a group and its members keeps those
|
|
651
|
+
* members out of the flat store entirely — the store holds only the host.
|
|
652
|
+
* So "inherit membership from the flat twin" had nothing to inherit from,
|
|
653
|
+
* and the group produced no timeline row at all, for exactly the case group
|
|
654
|
+
* support was extended to cover. Verified against a real studio session
|
|
655
|
+
* before this test was written: the flat store held three elements (the
|
|
656
|
+
* panel, the sub-comp host and an ungrouped bed) and neither voice.
|
|
657
|
+
*/
|
|
658
|
+
it("takes group membership from the DOM child when there is no flat store twin", () => {
|
|
659
|
+
const elements = [
|
|
660
|
+
el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
|
|
661
|
+
];
|
|
662
|
+
const manifest = [
|
|
663
|
+
clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
|
|
664
|
+
];
|
|
665
|
+
const parentMap = new Map([
|
|
666
|
+
["voice-1", "voices-host"],
|
|
667
|
+
["voice-2", "voices-host"],
|
|
668
|
+
]);
|
|
669
|
+
const domClipChildren = [
|
|
670
|
+
{
|
|
671
|
+
id: "voice-1",
|
|
672
|
+
parentId: "voices-host",
|
|
673
|
+
hostId: "voices-host",
|
|
674
|
+
label: "voice-1",
|
|
675
|
+
stackingContextId: "css:0",
|
|
676
|
+
audioGroup: "voiceover",
|
|
677
|
+
audioGroupLabel: "Voiceover",
|
|
678
|
+
audioGroupVolume: 0.8,
|
|
679
|
+
audioGroupHidden: false,
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
id: "voice-2",
|
|
683
|
+
parentId: "voices-host",
|
|
684
|
+
hostId: "voices-host",
|
|
685
|
+
label: "voice-2",
|
|
686
|
+
stackingContextId: "css:0",
|
|
687
|
+
audioGroup: "voiceover",
|
|
688
|
+
audioGroupLabel: "Voiceover",
|
|
689
|
+
audioGroupVolume: 0.8,
|
|
690
|
+
audioGroupHidden: false,
|
|
691
|
+
},
|
|
692
|
+
];
|
|
693
|
+
|
|
694
|
+
const out = buildExpandedElements(
|
|
695
|
+
elements,
|
|
696
|
+
manifest,
|
|
697
|
+
parentMap,
|
|
698
|
+
"voices-host",
|
|
699
|
+
"voices-host",
|
|
700
|
+
domClipChildren,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
const voices = out.filter((e) => e.domId?.startsWith("voice-"));
|
|
704
|
+
expect(voices).toHaveLength(2);
|
|
705
|
+
for (const voice of voices) {
|
|
706
|
+
expect(voice.audioGroup).toBe("voiceover");
|
|
707
|
+
expect(voice.audioGroupLabel).toBe("Voiceover");
|
|
708
|
+
expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe("sub-comp child rows never collide with a group anchor", () => {
|
|
714
|
+
/**
|
|
715
|
+
* A group row anchors at exactly `firstMemberTrack - 0.5`. The old child
|
|
716
|
+
* scheme `k / (n + 2)` hit 0.5 dead on for a host with TWO children (2/4),
|
|
717
|
+
* producing a duplicate row key and a duplicated group header.
|
|
718
|
+
*/
|
|
719
|
+
it("keeps every child strictly below the host's half-lane", () => {
|
|
720
|
+
for (const childCount of [1, 2, 3, 4, 7]) {
|
|
721
|
+
const fractions = Array.from(
|
|
722
|
+
{ length: childCount },
|
|
723
|
+
(_unused, i) => (0.5 * (i + 1)) / (childCount + 1),
|
|
724
|
+
);
|
|
725
|
+
expect(fractions.every((f) => f > 0 && f < 0.5)).toBe(true);
|
|
726
|
+
// Still distinct and ordered, which is what makes them usable as rows.
|
|
727
|
+
expect(new Set(fractions).size).toBe(childCount);
|
|
728
|
+
expect([...fractions].sort((a, b) => a - b)).toEqual(fractions);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
648
731
|
});
|
|
@@ -143,6 +143,32 @@ interface DisplayBounds {
|
|
|
143
143
|
* could never be shown again (not even after a reload, since the attribute is in
|
|
144
144
|
* the source).
|
|
145
145
|
*/
|
|
146
|
+
/**
|
|
147
|
+
* Audio-group membership for an expanded child, from whichever source has it.
|
|
148
|
+
*
|
|
149
|
+
* The flat store twin when there is one; otherwise the `DomClipChild` record,
|
|
150
|
+
* which carried it off the live element during the DOM walk. That fallback is
|
|
151
|
+
* the ONLY source for a sub-composition that declares both a group and its
|
|
152
|
+
* members: those members never enter the flat store, so "inherit from the flat
|
|
153
|
+
* twin" silently produced no membership and therefore no group row — for
|
|
154
|
+
* exactly the case group support was extended to cover.
|
|
155
|
+
*/
|
|
156
|
+
function childGroupState(
|
|
157
|
+
flat: TimelineElement | undefined,
|
|
158
|
+
domChild: DomClipChild | undefined,
|
|
159
|
+
): Partial<TimelineElement> {
|
|
160
|
+
const source = flat?.audioGroup ? flat : domChild?.audioGroup ? domChild : null;
|
|
161
|
+
if (!source) return {};
|
|
162
|
+
return {
|
|
163
|
+
audioGroup: source.audioGroup,
|
|
164
|
+
audioGroupLabel: source.audioGroupLabel,
|
|
165
|
+
audioGroupVolume: source.audioGroupVolume,
|
|
166
|
+
audioGroupHidden: source.audioGroupHidden,
|
|
167
|
+
audioGroupFxChain: source.audioGroupFxChain,
|
|
168
|
+
audioGroupAutomation: source.audioGroupAutomation,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
|
|
147
173
|
if (!flat) return {};
|
|
148
174
|
return {
|
|
@@ -169,6 +195,7 @@ function buildChildElements(
|
|
|
169
195
|
editBasis: { start: number; sourceFile: string | undefined },
|
|
170
196
|
expandedHostKey: string,
|
|
171
197
|
elements: readonly TimelineElement[],
|
|
198
|
+
domChildrenById: ReadonlyMap<string, DomClipChild>,
|
|
172
199
|
): TimelineElement[] {
|
|
173
200
|
const result: TimelineElement[] = [];
|
|
174
201
|
for (const child of siblings) {
|
|
@@ -197,6 +224,10 @@ function buildChildElements(
|
|
|
197
224
|
result.push({
|
|
198
225
|
...base,
|
|
199
226
|
...hostElementState(elements.find((element) => element.key === key)),
|
|
227
|
+
...childGroupState(
|
|
228
|
+
elements.find((element) => element.key === key),
|
|
229
|
+
domId ? domChildrenById.get(domId) : undefined,
|
|
230
|
+
),
|
|
200
231
|
key,
|
|
201
232
|
start: clamped.start,
|
|
202
233
|
duration: clamped.duration,
|
|
@@ -215,7 +246,13 @@ function buildChildElements(
|
|
|
215
246
|
// clips. Fractions strictly between the host's lane and the next integer
|
|
216
247
|
// can never equal a normalized (integer) lane, while still rendering the
|
|
217
248
|
// children as their own ordered rows directly under the host.
|
|
218
|
-
|
|
249
|
+
//
|
|
250
|
+
// Confined to the LOWER half of that gap, because a GROUP row anchors at
|
|
251
|
+
// exactly `firstMemberTrack - 0.5` (`useTimelineTrackDerivations`) — and
|
|
252
|
+
// the old `k / (n + 2)` hit 0.5 dead on for a host with two children
|
|
253
|
+
// (2/4), producing a duplicate row key and a duplicated group header. This
|
|
254
|
+
// scheme's maximum is `0.5 * n / (n + 1)`, strictly under 0.5 for every n.
|
|
255
|
+
track: display.track + (0.5 * (result.length + 1)) / (siblings.length + 1),
|
|
219
256
|
authoredTrack: base.authoredTrack,
|
|
220
257
|
stackingContextId: base.stackingContextId,
|
|
221
258
|
expandedParentStart: editBasis.start,
|
|
@@ -299,6 +336,7 @@ export function buildExpandedElements(
|
|
|
299
336
|
};
|
|
300
337
|
|
|
301
338
|
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
|
339
|
+
const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child]));
|
|
302
340
|
const expanded = buildChildElements(
|
|
303
341
|
siblings,
|
|
304
342
|
{
|
|
@@ -309,6 +347,7 @@ export function buildExpandedElements(
|
|
|
309
347
|
editBasis,
|
|
310
348
|
parentKey,
|
|
311
349
|
elements,
|
|
350
|
+
domChildrenById,
|
|
312
351
|
);
|
|
313
352
|
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
314
353
|
|
|
@@ -37,11 +37,7 @@ import {
|
|
|
37
37
|
mergeTimelineElementsPreservingDowngrades,
|
|
38
38
|
} from "../lib/timelineDOM";
|
|
39
39
|
import { normalizeToZones } from "../components/timelineZones";
|
|
40
|
-
import {
|
|
41
|
-
setPreviewMediaMuted,
|
|
42
|
-
setPreviewMediaVolume,
|
|
43
|
-
setPreviewPlaybackRate,
|
|
44
|
-
} from "../lib/timelineIframeHelpers";
|
|
40
|
+
import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
|
45
41
|
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
|
46
42
|
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
47
43
|
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
|
@@ -235,8 +231,7 @@ export function useTimelinePlayer() {
|
|
|
235
231
|
}, []);
|
|
236
232
|
const applyPreviewAudioState = useCallback(() => {
|
|
237
233
|
const { audioMuted, audioVolume } = usePlayerStore.getState();
|
|
238
|
-
|
|
239
|
-
setPreviewMediaVolume(iframeRef.current, audioVolume);
|
|
234
|
+
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
|
240
235
|
}, []);
|
|
241
236
|
const play = useCallback(() => {
|
|
242
237
|
stopRAFLoop();
|