@hyperframes/studio 0.8.49 → 0.8.50
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-CqON2Rzo.js → hyperframes-player-CxxDxHTW.js} +1 -1
- package/dist/assets/{index-mIgSsKqs.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-BGovDksE.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-Bgmx2h7W.js → index-n2ml4tUn.js} +218 -217
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5899 -5754
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.ts +2 -2
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +2 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
2
|
-
import {
|
|
3
|
-
usePlayerStore,
|
|
4
|
-
type TimelineElement,
|
|
5
|
-
type DomClipChild,
|
|
6
|
-
type SubCompositionHostState,
|
|
7
|
-
} from "../store/playerStore";
|
|
8
|
-
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
9
|
-
import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
|
|
10
|
-
import { buildTimelineElementKey, splitTimelineElementKey } from "../lib/timelineElementHelpers";
|
|
11
|
-
|
|
12
|
-
function findTopLevelAncestor(id: string, parentMap: Map<string, string>): string | null {
|
|
13
|
-
let current = parentMap.get(id);
|
|
14
|
-
if (!current) return null;
|
|
15
|
-
const visited = new Set<string>();
|
|
16
|
-
visited.add(id);
|
|
17
|
-
while (parentMap.has(current)) {
|
|
18
|
-
if (visited.has(current)) return current;
|
|
19
|
-
visited.add(current);
|
|
20
|
-
const parent = parentMap.get(current);
|
|
21
|
-
if (!parent) return current;
|
|
22
|
-
current = parent;
|
|
23
|
-
}
|
|
24
|
-
return current;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function resolveRawId(
|
|
28
|
-
selectedId: string | null,
|
|
29
|
-
manifest: ClipManifestClip[],
|
|
30
|
-
parentMap: Map<string, string>,
|
|
31
|
-
): string | null {
|
|
32
|
-
if (!selectedId) return null;
|
|
33
|
-
const rawId = splitTimelineElementKey(selectedId).domId;
|
|
34
|
-
if (parentMap.has(rawId)) return rawId;
|
|
35
|
-
if (parentMap.has(selectedId)) return selectedId;
|
|
36
|
-
const clip = manifest.find((c) => c.label === selectedId || c.label === rawId);
|
|
37
|
-
if (clip?.id && parentMap.has(clip.id)) return clip.id;
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface TimelineExpansionRawIdInput {
|
|
42
|
-
selectedElementId: string | null;
|
|
43
|
-
currentTime: number;
|
|
44
|
-
manifest: ClipManifestClip[];
|
|
45
|
-
parentMap: Map<string, string>;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function clipContainsTime(clip: ClipManifestClip, time: number): boolean {
|
|
49
|
-
return Number.isFinite(time) && time >= clip.start && time < clip.start + clip.duration;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Half-open containment plus the closing boundary — see the fallback below. */
|
|
53
|
-
function clipTouchesTime(clip: ClipManifestClip, time: number): boolean {
|
|
54
|
-
return Number.isFinite(time) && time >= clip.start && time <= clip.start + clip.duration;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function getActiveParentDepth(id: string, parentMap: Map<string, string>, activeIds: Set<string>) {
|
|
58
|
-
let depth = 0;
|
|
59
|
-
let parent = parentMap.get(id);
|
|
60
|
-
const visited = new Set<string>();
|
|
61
|
-
visited.add(id);
|
|
62
|
-
while (parent) {
|
|
63
|
-
if (visited.has(parent)) return depth;
|
|
64
|
-
visited.add(parent);
|
|
65
|
-
if (activeIds.has(parent)) depth += 1;
|
|
66
|
-
parent = parentMap.get(parent);
|
|
67
|
-
}
|
|
68
|
-
return depth;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function findActiveExpandableCompositionId(
|
|
72
|
-
currentTime: number,
|
|
73
|
-
manifest: ClipManifestClip[],
|
|
74
|
-
parentMap: Map<string, string>,
|
|
75
|
-
): string | null {
|
|
76
|
-
const parentIds = new Set(parentMap.values());
|
|
77
|
-
const collect = (matches: (clip: ClipManifestClip, time: number) => boolean) => {
|
|
78
|
-
const ids = new Set<string>();
|
|
79
|
-
for (const clip of manifest) {
|
|
80
|
-
if (clip.id && parentIds.has(clip.id) && matches(clip, currentTime)) ids.add(clip.id);
|
|
81
|
-
}
|
|
82
|
-
return ids;
|
|
83
|
-
};
|
|
84
|
-
// Clip windows are half-open, so a playhead parked exactly on a clip's end is
|
|
85
|
-
// inside nothing — at the end of the timeline that collapsed every expanded
|
|
86
|
-
// sub-composition row and its keyframe lanes. Only when the strict pass finds
|
|
87
|
-
// nothing do we accept the closing boundary, so a playhead landing on the seam
|
|
88
|
-
// between two adjacent clips still expands the one that is starting.
|
|
89
|
-
const strict = collect(clipContainsTime);
|
|
90
|
-
const activeIds = strict.size > 0 ? strict : collect(clipTouchesTime);
|
|
91
|
-
let bestId: string | null = null;
|
|
92
|
-
let bestDepth = -1;
|
|
93
|
-
for (const id of activeIds) {
|
|
94
|
-
const depth = getActiveParentDepth(id, parentMap, activeIds);
|
|
95
|
-
if (depth <= bestDepth) continue;
|
|
96
|
-
bestId = id;
|
|
97
|
-
bestDepth = depth;
|
|
98
|
-
}
|
|
99
|
-
return bestId;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function resolveTimelineExpansionRawId({
|
|
103
|
-
selectedElementId,
|
|
104
|
-
currentTime,
|
|
105
|
-
manifest,
|
|
106
|
-
parentMap,
|
|
107
|
-
}: TimelineExpansionRawIdInput): string | null {
|
|
108
|
-
const selectedRawId = resolveRawId(selectedElementId, manifest, parentMap);
|
|
109
|
-
if (selectedRawId) return selectedRawId;
|
|
110
|
-
return findActiveExpandableCompositionId(currentTime, manifest, parentMap);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function filterToTopLevel(
|
|
114
|
-
elements: TimelineElement[],
|
|
115
|
-
parentMap: Map<string, string>,
|
|
116
|
-
): TimelineElement[] {
|
|
117
|
-
if (parentMap.size === 0) return elements;
|
|
118
|
-
return elements.filter((el) => !parentMap.has(el.domId ?? el.id));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function clampChildToParent(
|
|
122
|
-
child: ClipManifestClip,
|
|
123
|
-
parentStart: number,
|
|
124
|
-
parentEnd: number,
|
|
125
|
-
): { start: number; duration: number } | null {
|
|
126
|
-
const childEnd = child.start + child.duration;
|
|
127
|
-
if (child.start >= parentEnd || childEnd <= parentStart) return null;
|
|
128
|
-
const clampedStart = Math.max(child.start, parentStart);
|
|
129
|
-
const clampedDuration = Math.min(childEnd, parentEnd) - clampedStart;
|
|
130
|
-
return clampedDuration > 0 ? { start: clampedStart, duration: clampedDuration } : null;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
interface DisplayBounds {
|
|
134
|
-
start: number;
|
|
135
|
-
end: number;
|
|
136
|
-
track: number;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Audio-group membership for an expanded child, from whichever source has it.
|
|
141
|
-
*
|
|
142
|
-
* The flat store twin when there is one; otherwise the `DomClipChild` record,
|
|
143
|
-
* which carried it off the live element during the DOM walk. That fallback is
|
|
144
|
-
* the ONLY source for a sub-composition that declares both a group and its
|
|
145
|
-
* members: those members never enter the flat store, so "inherit from the flat
|
|
146
|
-
* twin" silently produced no membership and therefore no group row — for
|
|
147
|
-
* exactly the case group support was extended to cover.
|
|
148
|
-
*/
|
|
149
|
-
function childGroupState(
|
|
150
|
-
flat: TimelineElement | undefined,
|
|
151
|
-
domChild: DomClipChild | undefined,
|
|
152
|
-
): Partial<TimelineElement> {
|
|
153
|
-
const source = flat?.audioGroup ? flat : domChild?.audioGroup ? domChild : null;
|
|
154
|
-
if (!source) return {};
|
|
155
|
-
return {
|
|
156
|
-
audioGroup: source.audioGroup,
|
|
157
|
-
audioGroupLabel: source.audioGroupLabel,
|
|
158
|
-
audioGroupVolume: source.audioGroupVolume,
|
|
159
|
-
audioGroupHidden: source.audioGroupHidden,
|
|
160
|
-
audioGroupFxChain: source.audioGroupFxChain,
|
|
161
|
-
audioGroupAutomation: source.audioGroupAutomation,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
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 };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// `display` bounds come from the top-level scene clip (where the expanded row is
|
|
196
|
-
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
|
|
197
|
-
// start anchors local-time edits and its compositionSrc is the file edits write to.
|
|
198
|
-
// They differ only for sub-comp-inside-sub-comp nesting.
|
|
199
|
-
function buildChildElements(
|
|
200
|
-
siblings: ClipManifestClip[],
|
|
201
|
-
display: DisplayBounds,
|
|
202
|
-
editBasis: { start: number; sourceFile: string | undefined },
|
|
203
|
-
expandedHostKey: string,
|
|
204
|
-
elements: readonly TimelineElement[],
|
|
205
|
-
domChildrenById: ReadonlyMap<string, DomClipChild>,
|
|
206
|
-
hostStateById: ReadonlyMap<string, SubCompositionHostState>,
|
|
207
|
-
): TimelineElement[] {
|
|
208
|
-
const result: TimelineElement[] = [];
|
|
209
|
-
for (const child of siblings) {
|
|
210
|
-
const clamped = clampChildToParent(child, display.start, display.end);
|
|
211
|
-
if (!clamped) continue;
|
|
212
|
-
const base = createTimelineElementFromManifestClip({
|
|
213
|
-
clip: child,
|
|
214
|
-
fallbackIndex: result.length,
|
|
215
|
-
});
|
|
216
|
-
const domId = child.id ?? undefined;
|
|
217
|
-
const selector = child.id ? `#${child.id}` : undefined;
|
|
218
|
-
// `base.key` was built without a hostEl, so it fell back to the colon form
|
|
219
|
-
// (`index.html:<id>:<idx>`) even though we set domId below. Recompute it from
|
|
220
|
-
// the same inputs the store uses (`<sourceFile>#<domId>`) so an expanded
|
|
221
|
-
// child shares one identity with its flat store element — otherwise selecting
|
|
222
|
-
// it sets `selectedElementId` to the store's hash key while the rendered row
|
|
223
|
-
// is keyed by the colon form, and `isSelected` never matches (no highlight).
|
|
224
|
-
const key = buildTimelineElementKey({
|
|
225
|
-
id: base.id,
|
|
226
|
-
fallbackIndex: result.length,
|
|
227
|
-
domId,
|
|
228
|
-
selector,
|
|
229
|
-
selectorIndex: base.selectorIndex,
|
|
230
|
-
sourceFile: editBasis.sourceFile,
|
|
231
|
-
});
|
|
232
|
-
result.push({
|
|
233
|
-
...base,
|
|
234
|
-
...hostElementState(
|
|
235
|
-
elements.find((element) => element.key === key),
|
|
236
|
-
domId ? hostStateById.get(domId) : undefined,
|
|
237
|
-
),
|
|
238
|
-
...childGroupState(
|
|
239
|
-
elements.find((element) => element.key === key),
|
|
240
|
-
domId ? domChildrenById.get(domId) : undefined,
|
|
241
|
-
),
|
|
242
|
-
key,
|
|
243
|
-
start: clamped.start,
|
|
244
|
-
duration: clamped.duration,
|
|
245
|
-
// `track` becomes a synthetic display row under the expanded host, but the
|
|
246
|
-
// factory-set `authoredTrack` (the child's data-track-index in ITS OWN
|
|
247
|
-
// file's coordinate space) and the runtime-computed `stackingContextId`
|
|
248
|
-
// must survive verbatim — lane persists and z-sync read them, they are
|
|
249
|
-
// never reconstructed from display lanes.
|
|
250
|
-
//
|
|
251
|
-
// COLLISION-FREE synthetic rows: the old `display.track + index` scheme
|
|
252
|
-
// could equal a REAL clip's integer lane (host on track 0 with two
|
|
253
|
-
// children puts child #2 on track 1 — where an unrelated top-level clip
|
|
254
|
-
// may live). Lane grouping merges purely by track number, so that
|
|
255
|
-
// collision fused clips from DIFFERENT source files into one display
|
|
256
|
-
// lane, and lane-scoped actions (gap close) then batch-persisted foreign
|
|
257
|
-
// clips. Fractions strictly between the host's lane and the next integer
|
|
258
|
-
// can never equal a normalized (integer) lane, while still rendering the
|
|
259
|
-
// children as their own ordered rows directly under the host.
|
|
260
|
-
//
|
|
261
|
-
// Confined to the LOWER half of that gap, because a GROUP row anchors at
|
|
262
|
-
// exactly `firstMemberTrack - 0.5` (`useTimelineTrackDerivations`) — and
|
|
263
|
-
// the old `k / (n + 2)` hit 0.5 dead on for a host with two children
|
|
264
|
-
// (2/4), producing a duplicate row key and a duplicated group header. This
|
|
265
|
-
// scheme's maximum is `0.5 * n / (n + 1)`, strictly under 0.5 for every n.
|
|
266
|
-
track: display.track + (0.5 * (result.length + 1)) / (siblings.length + 1),
|
|
267
|
-
authoredTrack: base.authoredTrack,
|
|
268
|
-
stackingContextId: base.stackingContextId,
|
|
269
|
-
expandedParentStart: editBasis.start,
|
|
270
|
-
expandedHostKey,
|
|
271
|
-
domId,
|
|
272
|
-
selector,
|
|
273
|
-
sourceFile: editBasis.sourceFile,
|
|
274
|
-
timingSource: "authored",
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
return result;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing
|
|
281
|
-
// of their own — they're "always on" within their sub-comp host, so synthesize
|
|
282
|
-
// clips spanning the host's full bounds. The host element supplies start/duration
|
|
283
|
-
// and the composition file edits write to.
|
|
284
|
-
function domSiblingClips(
|
|
285
|
-
domClipChildren: DomClipChild[],
|
|
286
|
-
siblingParentId: string,
|
|
287
|
-
host: {
|
|
288
|
-
id: string | null;
|
|
289
|
-
start: number;
|
|
290
|
-
duration: number;
|
|
291
|
-
track: number;
|
|
292
|
-
compositionSrc?: string | null;
|
|
293
|
-
},
|
|
294
|
-
): ClipManifestClip[] {
|
|
295
|
-
return domClipChildren
|
|
296
|
-
.filter((c) => c.parentId === siblingParentId)
|
|
297
|
-
.map(
|
|
298
|
-
(c): ClipManifestClip => ({
|
|
299
|
-
id: c.id,
|
|
300
|
-
label: c.label,
|
|
301
|
-
start: host.start,
|
|
302
|
-
duration: host.duration,
|
|
303
|
-
track: host.track,
|
|
304
|
-
kind: "element",
|
|
305
|
-
tagName: null,
|
|
306
|
-
compositionId: null,
|
|
307
|
-
parentCompositionId: host.id ?? null,
|
|
308
|
-
compositionSrc: host.compositionSrc ?? null,
|
|
309
|
-
assetUrl: null,
|
|
310
|
-
stackingContextId: c.stackingContextId,
|
|
311
|
-
}),
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Exported for tests.
|
|
316
|
-
export function buildExpandedElements(
|
|
317
|
-
elements: TimelineElement[],
|
|
318
|
-
manifest: ClipManifestClip[],
|
|
319
|
-
parentMap: Map<string, string>,
|
|
320
|
-
topLevelId: string,
|
|
321
|
-
siblingParentId: string,
|
|
322
|
-
domClipChildren: DomClipChild[] = [],
|
|
323
|
-
subCompositionHostState: ReadonlyMap<string, SubCompositionHostState> = new Map(),
|
|
324
|
-
): TimelineElement[] {
|
|
325
|
-
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
|
326
|
-
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
|
327
|
-
|
|
328
|
-
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
329
|
-
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
|
330
|
-
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
|
331
|
-
|
|
332
|
-
// Prefer real manifest children; fall back to DOM-only sub-comp children
|
|
333
|
-
// (groups/pills) that have no data-start and thus never enter the manifest.
|
|
334
|
-
// Those are synthesized against the host they actually live in, not the
|
|
335
|
-
// top-level element, or every child row reads the whole top-level window.
|
|
336
|
-
const siblings = (() => {
|
|
337
|
-
const fromManifest = manifest.filter(
|
|
338
|
-
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
|
|
339
|
-
);
|
|
340
|
-
if (fromManifest.length > 0) return fromManifest;
|
|
341
|
-
return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement);
|
|
342
|
-
})();
|
|
343
|
-
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
|
344
|
-
|
|
345
|
-
const editBasis = {
|
|
346
|
-
start: parentHost?.start ?? topLevelElement.start,
|
|
347
|
-
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
|
351
|
-
const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child]));
|
|
352
|
-
const expanded = buildChildElements(
|
|
353
|
-
siblings,
|
|
354
|
-
{
|
|
355
|
-
start: topLevelElement.start,
|
|
356
|
-
end: topLevelElement.start + topLevelElement.duration,
|
|
357
|
-
track: topLevelElement.track,
|
|
358
|
-
},
|
|
359
|
-
editBasis,
|
|
360
|
-
parentKey,
|
|
361
|
-
elements,
|
|
362
|
-
domChildrenById,
|
|
363
|
-
subCompositionHostState,
|
|
364
|
-
);
|
|
365
|
-
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
366
|
-
|
|
367
|
-
// Every host between the drilled one and the top level owns a row, so the
|
|
368
|
-
// drill has to spare all of them, not just the top. A middle host is still a
|
|
369
|
-
// host: dropping its row drops its keyframe lane with it.
|
|
370
|
-
const drillPath = new Set<string>();
|
|
371
|
-
for (let cursor: string | undefined = siblingParentId; cursor; ) {
|
|
372
|
-
if (drillPath.has(cursor)) break;
|
|
373
|
-
drillPath.add(cursor);
|
|
374
|
-
if (cursor === topLevelId) break;
|
|
375
|
-
cursor = parentMap.get(cursor);
|
|
376
|
-
}
|
|
377
|
-
// Children hang under the DEEPEST host on that path, so anchor them there
|
|
378
|
-
// when it has a row of its own and fall back to the top-level row when it
|
|
379
|
-
// does not (a host that lives only in the manifest never had one).
|
|
380
|
-
const anchorsChildren = (el: TimelineElement): boolean =>
|
|
381
|
-
drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId)
|
|
382
|
-
? (el.domId ?? el.id) === siblingParentId
|
|
383
|
-
: (el.key ?? el.id) === parentKey;
|
|
384
|
-
|
|
385
|
-
// ADDITIVE drill-in: the host row stays and its children are appended under
|
|
386
|
-
// it. Expansion is also triggered by the playhead alone (active auto-expand),
|
|
387
|
-
// so substituting the host row made it vanish on an ordinary seek, and with
|
|
388
|
-
// it the host's keyframe lane, since diamonds render per row from
|
|
389
|
-
// `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
|
|
390
|
-
// strictly between the host's lane and the next integer, so the children have
|
|
391
|
-
// their own rows without the host having to give up its own.
|
|
392
|
-
return elements
|
|
393
|
-
.filter(
|
|
394
|
-
(el) =>
|
|
395
|
-
(el.key ?? el.id) === parentKey ||
|
|
396
|
-
drillPath.has(el.domId ?? el.id) ||
|
|
397
|
-
!parentMap.has(el.domId ?? el.id),
|
|
398
|
-
)
|
|
399
|
-
.flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el]));
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
export function useExpandedTimelineElements(): TimelineElement[] {
|
|
403
|
-
const elements = usePlayerStore((s) => s.elements);
|
|
404
|
-
const clipManifest = usePlayerStore((s) => s.clipManifest);
|
|
405
|
-
const clipParentMap = usePlayerStore((s) => s.clipParentMap);
|
|
406
|
-
const domClipChildren = usePlayerStore((s) => s.domClipChildren);
|
|
407
|
-
const subCompositionHostState = usePlayerStore((s) => s.subCompositionHostState);
|
|
408
|
-
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
409
|
-
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
410
|
-
|
|
411
|
-
// Resolve which raw clip drives expansion from the store's committed playhead
|
|
412
|
-
// time. The RAF loop keeps live time out of React and Zustand during playback,
|
|
413
|
-
// so this target deliberately stays stable until the loop commits or a seek /
|
|
414
|
-
// pause updates the store. The scan re-runs on scrub ticks, but its RESULT only
|
|
415
|
-
// changes when the playhead crosses a composition boundary. Keying the expensive
|
|
416
|
-
// build below on these ids (not raw currentTime) avoids re-allocating
|
|
417
|
-
// expandedElements — and cascading TimelineClip re-renders — on every tick.
|
|
418
|
-
const { rawId, selectedRawId } = useMemo(() => {
|
|
419
|
-
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
|
420
|
-
return { rawId: null as string | null, selectedRawId: null as string | null };
|
|
421
|
-
}
|
|
422
|
-
return {
|
|
423
|
-
rawId: resolveTimelineExpansionRawId({
|
|
424
|
-
selectedElementId,
|
|
425
|
-
currentTime,
|
|
426
|
-
manifest: clipManifest,
|
|
427
|
-
parentMap: clipParentMap,
|
|
428
|
-
}),
|
|
429
|
-
selectedRawId: resolveRawId(selectedElementId, clipManifest, clipParentMap),
|
|
430
|
-
};
|
|
431
|
-
}, [clipManifest, clipParentMap, selectedElementId, currentTime]);
|
|
432
|
-
|
|
433
|
-
return useMemo(() => {
|
|
434
|
-
if (!clipManifest || clipManifest.length === 0 || clipParentMap.size === 0) {
|
|
435
|
-
return elements;
|
|
436
|
-
}
|
|
437
|
-
if (!rawId) return filterToTopLevel(elements, clipParentMap);
|
|
438
|
-
|
|
439
|
-
const immediateParent = selectedRawId ? clipParentMap.get(rawId) : rawId;
|
|
440
|
-
if (!immediateParent) return filterToTopLevel(elements, clipParentMap);
|
|
441
|
-
const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
|
|
442
|
-
return buildExpandedElements(
|
|
443
|
-
elements,
|
|
444
|
-
clipManifest,
|
|
445
|
-
clipParentMap,
|
|
446
|
-
topLevel,
|
|
447
|
-
immediateParent,
|
|
448
|
-
domClipChildren,
|
|
449
|
-
subCompositionHostState,
|
|
450
|
-
);
|
|
451
|
-
}, [
|
|
452
|
-
elements,
|
|
453
|
-
clipManifest,
|
|
454
|
-
clipParentMap,
|
|
455
|
-
domClipChildren,
|
|
456
|
-
subCompositionHostState,
|
|
457
|
-
rawId,
|
|
458
|
-
selectedRawId,
|
|
459
|
-
]);
|
|
460
|
-
}
|