@hyperframes/studio 0.8.86 → 0.8.87
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-CtNVImMi.js +473 -0
- package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
- package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
- package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
- package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
- package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
- package/dist/chunk-PQFEGZWM.js.map +1 -0
- package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
- package/dist/index.d.ts +203 -160
- package/dist/index.html +2 -2
- package/dist/index.js +1958 -1611
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +1 -2
- package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
- package/src/components/editor/LayersPanel.test.ts +14 -0
- package/src/components/editor/LayersPanel.tsx +2 -2
- package/src/components/editor/TopologyLens.tsx +2 -2
- package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
- package/src/components/editor/domEditOverlayGestures.ts +0 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
- package/src/components/editor/domEditing.test.ts +29 -0
- package/src/components/editor/domEditingDom.ts +1 -0
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/marqueeCommit.test.tsx +84 -0
- package/src/components/editor/marqueeCommit.ts +13 -1
- package/src/components/editor/useColorGradingController.test.ts +24 -0
- package/src/components/editor/useColorGradingController.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
- package/src/components/nle/useCompositionStack.test.tsx +53 -0
- package/src/components/nle/useCompositionStack.ts +4 -8
- package/src/hooks/domStyleCommit.ts +129 -0
- package/src/hooks/gsapEditOutcome.ts +2 -2
- package/src/hooks/gsapElementMatch.ts +38 -0
- package/src/hooks/gsapResizeIntercept.ts +10 -31
- package/src/hooks/gsapResizePreflight.ts +56 -0
- package/src/hooks/gsapRuntimeBridge.ts +66 -26
- package/src/hooks/keyframeCacheAstLoad.ts +4 -1
- package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
- package/src/hooks/useDomEditCommits.ts +17 -182
- package/src/hooks/useDomEditPersist.ts +225 -0
- package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
- package/src/hooks/useDomEditPreviewSync.ts +2 -4
- package/src/hooks/useDomEditSession.test.tsx +51 -1
- package/src/hooks/useDomEditSession.ts +11 -2
- package/src/hooks/useDomEditTextCommits.ts +15 -99
- package/src/hooks/useDomStyleCommit.test.tsx +155 -0
- package/src/hooks/useDomStyleCommit.ts +74 -0
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
- package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
- package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
- package/src/hooks/useGsapAwareEditing.ts +1 -1
- package/src/hooks/useGsapTweenCache.ts +2 -48
- package/src/index.ts +10 -1
- package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
- package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
- package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
- package/src/player/components/TimelineGroupHeader.tsx +21 -10
- package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
- package/src/player/components/TimelineGroupRow.test.tsx +24 -33
- package/src/player/components/TimelineGroupRow.tsx +4 -1
- package/src/player/components/TimelineLanes.tsx +5 -5
- package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
- package/src/player/components/TimelineTrackHeader.tsx +6 -3
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/timelineLaneProps.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +29 -0
- package/src/player/components/useTimelineLabelColumn.ts +18 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
- package/src/player/components/useTimelineProviderState.tsx +6 -12
- package/src/player/components/useTimelineRangeSelection.ts +13 -12
- package/src/player/hooks/useShadowPreviewReload.ts +2 -0
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
- package/src/player/hooks/useTimelinePlayer.ts +29 -7
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/player/sceneSwap.test.ts +95 -0
- package/src/player/sceneSwap.ts +53 -0
- package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
- package/dist/chunk-3IOCVWB6.js.map +0 -1
- /package/dist/{domEditingLayers-ZOOMN7CQ.js.map → domEditingLayers-3BAP6NNO.js.map} +0 -0
|
@@ -63,8 +63,13 @@ export function TimelineGroupLaneLabels({
|
|
|
63
63
|
tabIndex={-1}
|
|
64
64
|
key={lane.target}
|
|
65
65
|
data-group-lane-label={lane.target}
|
|
66
|
-
aria-label={
|
|
67
|
-
|
|
66
|
+
aria-label={
|
|
67
|
+
onReveal
|
|
68
|
+
? `Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`
|
|
69
|
+
: undefined
|
|
70
|
+
}
|
|
71
|
+
disabled={!onReveal}
|
|
72
|
+
className="absolute left-0 flex items-center gap-1.5 overflow-hidden border-0 px-1.5 text-left text-[10px] text-[var(--timeline-text-secondary)] enabled:hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
|
|
68
73
|
style={{
|
|
69
74
|
top: top + index * AUTOMATION_LANE_H,
|
|
70
75
|
width: columnWidth,
|
|
@@ -45,9 +45,21 @@ const GROUP: TimelineTrackGroupInfo = {
|
|
|
45
45
|
hidden: false,
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const GAIN_AUTOMATED: Partial<TimelineTrackGroupInfo> = {
|
|
49
|
+
fxChain: JSON.stringify({
|
|
50
|
+
version: 1,
|
|
51
|
+
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
52
|
+
}),
|
|
53
|
+
automation: JSON.stringify({
|
|
54
|
+
version: 1,
|
|
55
|
+
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
48
59
|
function renderRow(
|
|
49
60
|
overrides: Partial<TimelineTrackGroupInfo> = {},
|
|
50
61
|
expandedLaneOwnerIds = new Set<string>(),
|
|
62
|
+
showAudioEffects?: boolean,
|
|
51
63
|
) {
|
|
52
64
|
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
53
65
|
const onSetElementAttributeQuiet = vi.fn();
|
|
@@ -66,6 +78,7 @@ function renderRow(
|
|
|
66
78
|
virtualized={false}
|
|
67
79
|
contentOrigin={232}
|
|
68
80
|
theme={defaultTimelineTheme}
|
|
81
|
+
showAudioEffects={showAudioEffects}
|
|
69
82
|
collapsedGroupIds={new Set()}
|
|
70
83
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
71
84
|
toggleGroupExpanded={vi.fn()}
|
|
@@ -118,19 +131,7 @@ describe("TimelineGroupRow", () => {
|
|
|
118
131
|
});
|
|
119
132
|
|
|
120
133
|
it("opens a group automation lane on its exact rack parameter", async () => {
|
|
121
|
-
const { host } = renderRow(
|
|
122
|
-
{
|
|
123
|
-
fxChain: JSON.stringify({
|
|
124
|
-
version: 1,
|
|
125
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
126
|
-
}),
|
|
127
|
-
automation: JSON.stringify({
|
|
128
|
-
version: 1,
|
|
129
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
130
|
-
}),
|
|
131
|
-
},
|
|
132
|
-
new Set(["voiceover"]),
|
|
133
|
-
);
|
|
134
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]));
|
|
134
135
|
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
135
136
|
|
|
136
137
|
await act(async () => {
|
|
@@ -147,6 +148,14 @@ describe("TimelineGroupRow", () => {
|
|
|
147
148
|
});
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
it("keeps a group lane's name off the rack while audio effects are hidden", () => {
|
|
152
|
+
const { host } = renderRow(GAIN_AUTOMATED, new Set(["voiceover"]), false);
|
|
153
|
+
const laneTitle = host.querySelector<HTMLButtonElement>('[data-group-lane-label="fx.p1.gain"]');
|
|
154
|
+
|
|
155
|
+
expect(laneTitle?.disabled).toBe(true);
|
|
156
|
+
expect(laneTitle?.getAttribute("aria-label")).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
150
159
|
// C1 names this as the step's own definition of done: "opening the popover on
|
|
151
160
|
// a GROUP and applying a preset results in exactly ONE `data-fx-chain` write,
|
|
152
161
|
// on the group element, and zero writes on members". A group IS a bus — a
|
|
@@ -180,16 +189,7 @@ describe("TimelineGroupRow", () => {
|
|
|
180
189
|
|
|
181
190
|
expect(laneToggle(renderRow().host)).toBeUndefined();
|
|
182
191
|
|
|
183
|
-
const automated = renderRow(
|
|
184
|
-
fxChain: JSON.stringify({
|
|
185
|
-
version: 1,
|
|
186
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
187
|
-
}),
|
|
188
|
-
automation: JSON.stringify({
|
|
189
|
-
version: 1,
|
|
190
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
191
|
-
}),
|
|
192
|
-
});
|
|
192
|
+
const automated = renderRow(GAIN_AUTOMATED);
|
|
193
193
|
expect(laneToggle(automated.host)).toBeDefined();
|
|
194
194
|
});
|
|
195
195
|
|
|
@@ -197,16 +197,7 @@ describe("TimelineGroupRow", () => {
|
|
|
197
197
|
// one right-anchored group. It was two lines — name, then controls — which is
|
|
198
198
|
// what let a stray child overflow the 48px box on the track side.
|
|
199
199
|
it("keeps the caret, the name and every control on one line", () => {
|
|
200
|
-
const { host } = renderRow(
|
|
201
|
-
fxChain: JSON.stringify({
|
|
202
|
-
version: 1,
|
|
203
|
-
nodes: [{ type: "peaking", id: "p1", params: { frequency: 1000, gain: -3, q: 1 } }],
|
|
204
|
-
}),
|
|
205
|
-
automation: JSON.stringify({
|
|
206
|
-
version: 1,
|
|
207
|
-
lanes: [{ target: "fx.p1.gain", points: [{ t: 0, v: 0 }] }],
|
|
208
|
-
}),
|
|
209
|
-
});
|
|
200
|
+
const { host } = renderRow(GAIN_AUTOMATED);
|
|
210
201
|
const header = host.querySelector<HTMLElement>('[role="rowheader"]');
|
|
211
202
|
// Caret, name, control group — no second line.
|
|
212
203
|
expect(header?.children).toHaveLength(3);
|
|
@@ -33,6 +33,7 @@ interface TimelineGroupRowProps {
|
|
|
33
33
|
virtualized: boolean;
|
|
34
34
|
contentOrigin: number;
|
|
35
35
|
theme: TimelineTheme;
|
|
36
|
+
showAudioEffects?: boolean;
|
|
36
37
|
rovingTargetId?: string | null;
|
|
37
38
|
collapsedGroupIds: ReadonlySet<string>;
|
|
38
39
|
expandedLaneOwnerIds: ReadonlySet<string>;
|
|
@@ -59,6 +60,7 @@ export function TimelineGroupRow({
|
|
|
59
60
|
virtualized,
|
|
60
61
|
contentOrigin,
|
|
61
62
|
theme,
|
|
63
|
+
showAudioEffects = true,
|
|
62
64
|
rovingTargetId = null,
|
|
63
65
|
collapsedGroupIds,
|
|
64
66
|
expandedLaneOwnerIds,
|
|
@@ -173,6 +175,7 @@ export function TimelineGroupRow({
|
|
|
173
175
|
// stays pinned there through horizontal scroll.
|
|
174
176
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
175
177
|
theme={theme}
|
|
178
|
+
showAudioEffects={showAudioEffects}
|
|
176
179
|
/>
|
|
177
180
|
{/* The group's OWN curves, under the strip. Selected-gated exactly like a
|
|
178
181
|
clip's: a lane is editable once the group is selected, which clicking
|
|
@@ -187,7 +190,7 @@ export function TimelineGroupRow({
|
|
|
187
190
|
columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin}
|
|
188
191
|
gutterBackground={theme.gutterBackground}
|
|
189
192
|
accentColor={GROUP_LANE_ACCENT}
|
|
190
|
-
onReveal={openGroupFxRack}
|
|
193
|
+
onReveal={showAudioEffects ? openGroupFxRack : undefined}
|
|
191
194
|
/>
|
|
192
195
|
)}
|
|
193
196
|
</div>
|
|
@@ -41,6 +41,7 @@ export function TimelineLanes({
|
|
|
41
41
|
contentGutter,
|
|
42
42
|
trackContentWidth,
|
|
43
43
|
theme,
|
|
44
|
+
showAudioEffects,
|
|
44
45
|
displayTrackOrder,
|
|
45
46
|
rowGeometry,
|
|
46
47
|
virtualRows,
|
|
@@ -115,11 +116,8 @@ export function TimelineLanes({
|
|
|
115
116
|
// synthetic lane element spans the whole composition rather than a clip.
|
|
116
117
|
const compositionDuration = usePlayerStore((s) => s.duration);
|
|
117
118
|
useAutomationSelectionKeyboard({ lanes: automationLanes });
|
|
118
|
-
const
|
|
119
|
-
const groupMemberTracks =
|
|
120
|
-
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
121
|
-
[groups],
|
|
122
|
-
);
|
|
119
|
+
const rowIndexes = useTimelineLaneRowIndexes(logicalRows, groups);
|
|
120
|
+
const { logicalRowsByTrack, groupByAnchor, groupMemberTracks } = rowIndexes;
|
|
123
121
|
const {
|
|
124
122
|
toggleRowExpanded: toggleRowExpandedTracked,
|
|
125
123
|
toggleClipExpanded: toggleClipExpandedTracked,
|
|
@@ -168,6 +166,7 @@ export function TimelineLanes({
|
|
|
168
166
|
virtualized={rowsVirtualized}
|
|
169
167
|
contentOrigin={contentOrigin}
|
|
170
168
|
theme={theme}
|
|
169
|
+
showAudioEffects={showAudioEffects}
|
|
171
170
|
rovingTargetId={keyboard.rovingTargetId}
|
|
172
171
|
collapsedGroupIds={collapsedGroupIds}
|
|
173
172
|
expandedLaneOwnerIds={expandedLaneOwnerIds}
|
|
@@ -292,6 +291,7 @@ export function TimelineLanes({
|
|
|
292
291
|
isAudioTrack={isAudioTrack}
|
|
293
292
|
isGroupMember={groupMemberTracks.has(trackNum)}
|
|
294
293
|
theme={theme}
|
|
294
|
+
showAudioEffects={showAudioEffects}
|
|
295
295
|
onToggleClipExpanded={() => {
|
|
296
296
|
const keys = els.map(getTimelineElementIdentity);
|
|
297
297
|
if (keys.length > 0) toggleRowExpandedTracked(keys);
|
|
@@ -75,6 +75,7 @@ interface RenderHeaderOptions {
|
|
|
75
75
|
isAudioTrack?: boolean;
|
|
76
76
|
isGroupMember?: boolean;
|
|
77
77
|
isTrackHidden?: boolean;
|
|
78
|
+
showAudioEffects?: boolean;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function renderHeader(options: RenderHeaderOptions = {}): {
|
|
@@ -119,6 +120,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
|
|
|
119
120
|
isAudioTrack={next.isAudioTrack}
|
|
120
121
|
isGroupMember={next.isGroupMember}
|
|
121
122
|
theme={defaultTimelineTheme}
|
|
123
|
+
showAudioEffects={next.showAudioEffects}
|
|
122
124
|
onToggleClipExpanded={vi.fn()}
|
|
123
125
|
onToggleTrackHidden={next.onToggleTrackHidden}
|
|
124
126
|
onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
|
|
@@ -583,6 +585,31 @@ describe("TimelineTrackHeader", () => {
|
|
|
583
585
|
}),
|
|
584
586
|
} as TimelineElement;
|
|
585
587
|
|
|
588
|
+
it("offers the effect rack from an effect lane only while audio effects are shown", () => {
|
|
589
|
+
const clip = { ...BED, domId: "bed" } as TimelineElement;
|
|
590
|
+
const gainLabel = (showAudioEffects?: boolean) => {
|
|
591
|
+
const { host, root } = renderHeader({
|
|
592
|
+
keyframeClip: clip,
|
|
593
|
+
animations: [],
|
|
594
|
+
showAudioEffects,
|
|
595
|
+
});
|
|
596
|
+
const button = host
|
|
597
|
+
.querySelector('[data-automation-lane-label="Peaking EQ 1.6 kHz · Gain"]')
|
|
598
|
+
?.querySelector("button");
|
|
599
|
+
const state = {
|
|
600
|
+
label: button?.getAttribute("aria-label") ?? null,
|
|
601
|
+
disabled: button?.disabled,
|
|
602
|
+
};
|
|
603
|
+
act(() => root.unmount());
|
|
604
|
+
return state;
|
|
605
|
+
};
|
|
606
|
+
expect(gainLabel()).toEqual({
|
|
607
|
+
label: "Show Peaking EQ 1.6 kHz · Gain in the effect rack",
|
|
608
|
+
disabled: false,
|
|
609
|
+
});
|
|
610
|
+
expect(gainLabel(false)).toEqual({ label: null, disabled: true });
|
|
611
|
+
});
|
|
612
|
+
|
|
586
613
|
it("names every envelope in the label column", () => {
|
|
587
614
|
const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
|
|
588
615
|
const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
|
|
@@ -72,6 +72,7 @@ interface TimelineTrackHeaderProps {
|
|
|
72
72
|
isGroupMember?: boolean;
|
|
73
73
|
rovingTargetId?: string | null;
|
|
74
74
|
theme: TimelineTheme;
|
|
75
|
+
showAudioEffects?: boolean;
|
|
75
76
|
onToggleClipExpanded: () => void;
|
|
76
77
|
onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
|
|
77
78
|
onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
|
|
@@ -98,6 +99,7 @@ export function TimelineTrackHeader({
|
|
|
98
99
|
isAudioTrack,
|
|
99
100
|
isGroupMember = false,
|
|
100
101
|
theme,
|
|
102
|
+
showAudioEffects = true,
|
|
101
103
|
onToggleClipExpanded,
|
|
102
104
|
onToggleTrackHidden,
|
|
103
105
|
onTogglePropertyGroupKeyframe,
|
|
@@ -316,7 +318,7 @@ export function TimelineTrackHeader({
|
|
|
316
318
|
// On the control line rather than a third row of its own.
|
|
317
319
|
trailing={
|
|
318
320
|
<>
|
|
319
|
-
{singleAudioClip && (
|
|
321
|
+
{showAudioEffects && singleAudioClip && (
|
|
320
322
|
<TimelineFxButton
|
|
321
323
|
variant="chain"
|
|
322
324
|
fxChainRaw={singleAudioClip.fxChain}
|
|
@@ -340,7 +342,8 @@ export function TimelineTrackHeader({
|
|
|
340
342
|
onOpenRack={() => openClipFxRack(singleAudioClip)}
|
|
341
343
|
/>
|
|
342
344
|
)}
|
|
343
|
-
{
|
|
345
|
+
{showAudioEffects &&
|
|
346
|
+
clipCount > 1 &&
|
|
344
347
|
!isTrackGrouped &&
|
|
345
348
|
(isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && (
|
|
346
349
|
<TimelineFxButton
|
|
@@ -455,7 +458,7 @@ export function TimelineTrackHeader({
|
|
|
455
458
|
// element, so a shared row's other envelopes belong to clips it is
|
|
456
459
|
// not showing and there would be nothing to reveal.
|
|
457
460
|
onReveal={
|
|
458
|
-
revealTarget && revealElementId && keyframeClip
|
|
461
|
+
showAudioEffects && revealTarget && revealElementId && keyframeClip
|
|
459
462
|
? () => {
|
|
460
463
|
// Select FIRST: the rack is the property panel's view of
|
|
461
464
|
// the selected element, so a reveal aimed at an unselected
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterAll, beforeAll } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mounting a whole <Timeline> in happy-dom needs a width to lay out against and a size report.
|
|
4
|
+
class MockResizeObserver {
|
|
5
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
6
|
+
observe(target: Element) {
|
|
7
|
+
this.callback([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
|
8
|
+
}
|
|
9
|
+
unobserve() {}
|
|
10
|
+
disconnect() {}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function installTimelineMountEnv(): void {
|
|
14
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
15
|
+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
18
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
|
19
|
+
configurable: true,
|
|
20
|
+
get: () => 900,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
25
|
+
if (originalClientWidth)
|
|
26
|
+
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
|
|
27
|
+
document.body.innerHTML = "";
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
4
|
+
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
|
5
|
+
import { timelineNeedsLabelColumn } from "./timelineViewModel";
|
|
6
|
+
|
|
7
|
+
export function useTimelineLabelColumn(
|
|
8
|
+
animationsByElement: ReadonlyMap<string, readonly GsapAnimation[]>,
|
|
9
|
+
elements: readonly TimelineElement[],
|
|
10
|
+
) {
|
|
11
|
+
const labelMode = useMemo(
|
|
12
|
+
() => timelineNeedsLabelColumn(animationsByElement, elements),
|
|
13
|
+
[animationsByElement, elements],
|
|
14
|
+
);
|
|
15
|
+
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
16
|
+
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
17
|
+
return { labelMode, contentOrigin };
|
|
18
|
+
}
|
|
@@ -34,5 +34,10 @@ export function useTimelineLaneRowIndexes(
|
|
|
34
34
|
[groups],
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const groupMemberTracks = useMemo(
|
|
38
|
+
() => new Set(groups.flatMap((group) => group.memberTracks)),
|
|
39
|
+
[groups],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return { logicalRowsByTrack, groupByAnchor, groupMemberTracks };
|
|
38
43
|
}
|
|
@@ -19,7 +19,8 @@ import { useTimelineEditPinning } from "./useTimelineEditPinning";
|
|
|
19
19
|
import { useTimelineStackingSync } from "./useTimelineStackingSync";
|
|
20
20
|
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
21
21
|
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
|
22
|
-
import { GUTTER, LABEL_COL_W
|
|
22
|
+
import { GUTTER, LABEL_COL_W } from "./timelineLayout";
|
|
23
|
+
import { useTimelineLabelColumn } from "./useTimelineLabelColumn";
|
|
23
24
|
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
|
24
25
|
import { ClipContentOnceShown } from "./timelineClipChildren";
|
|
25
26
|
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
|
@@ -33,11 +34,7 @@ import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers";
|
|
|
33
34
|
import { useTimelineGapHighlights } from "./useTimelineGapHighlights";
|
|
34
35
|
import { TimelineRazorGuideOverlay, useTimelineRazorInteraction } from "./TimelineRazorInteraction";
|
|
35
36
|
import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry";
|
|
36
|
-
import {
|
|
37
|
-
getEffectiveTimelineDuration,
|
|
38
|
-
getTimelinePreviewElement,
|
|
39
|
-
timelineNeedsLabelColumn,
|
|
40
|
-
} from "./timelineViewModel";
|
|
37
|
+
import { getEffectiveTimelineDuration, getTimelinePreviewElement } from "./timelineViewModel";
|
|
41
38
|
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
|
|
42
39
|
import { useTimelineTicks } from "./useTimelineTicks";
|
|
43
40
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -70,6 +67,7 @@ export function useTimelineProviderState({
|
|
|
70
67
|
onDuplicateClip,
|
|
71
68
|
canPasteClip,
|
|
72
69
|
theme: themeOverrides,
|
|
70
|
+
showAudioEffects = true,
|
|
73
71
|
sessionEpoch = 0,
|
|
74
72
|
previewIframeRef,
|
|
75
73
|
onZIndexReorder,
|
|
@@ -107,12 +105,7 @@ export function useTimelineProviderState({
|
|
|
107
105
|
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
|
|
108
106
|
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
|
|
109
107
|
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
|
|
110
|
-
const labelMode =
|
|
111
|
-
() => timelineNeedsLabelColumn(gsapAnimations, timelineElements),
|
|
112
|
-
[gsapAnimations, timelineElements],
|
|
113
|
-
);
|
|
114
|
-
// The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter.
|
|
115
|
-
const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD;
|
|
108
|
+
const { labelMode, contentOrigin } = useTimelineLabelColumn(gsapAnimations, timelineElements);
|
|
116
109
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
117
110
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
118
111
|
const beatDragging = usePlayerStore((s) => s.beatDragging);
|
|
@@ -448,6 +441,7 @@ export function useTimelineProviderState({
|
|
|
448
441
|
laneGapStrips,
|
|
449
442
|
dropPreview: assetDrop.dropPreview,
|
|
450
443
|
theme,
|
|
444
|
+
showAudioEffects,
|
|
451
445
|
displayTrackOrder: displayLayout.displayTrackOrder,
|
|
452
446
|
rowHeights: displayLayout.displayRowHeights,
|
|
453
447
|
rowGeometry: displayLayout.rowGeometry,
|
|
@@ -546,29 +546,30 @@ export function useTimelineRangeSelection({
|
|
|
546
546
|
[cancelActiveGesture, isGestureSessionCurrent],
|
|
547
547
|
);
|
|
548
548
|
|
|
549
|
-
// Escape
|
|
550
|
-
// otherwise
|
|
549
|
+
// Escape cancels an in-flight band, captured so a host's own Escape never also fires;
|
|
550
|
+
// otherwise it clears any lingering multi-selection.
|
|
551
551
|
useEffect(() => {
|
|
552
|
+
const onBandEscape = (e: KeyboardEvent) => {
|
|
553
|
+
if (e.key !== "Escape" || !(marqueeRef.current || isRangeSelecting.current)) return;
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
e.stopPropagation();
|
|
556
|
+
cancelActiveGesture(true, true);
|
|
557
|
+
};
|
|
552
558
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
553
559
|
if (e.key !== "Escape") return;
|
|
554
560
|
const store = usePlayerStore.getState();
|
|
555
|
-
|
|
556
|
-
if (marquee) {
|
|
557
|
-
cancelActiveGesture(true, true);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
if (isRangeSelecting.current || isDragging.current) {
|
|
561
|
+
if (isDragging.current) {
|
|
561
562
|
cancelActiveGesture(true, true);
|
|
562
563
|
return;
|
|
563
564
|
}
|
|
564
|
-
// Escape with no marquee clears the whole selection — primary AND set.
|
|
565
|
-
// setSelectedElementId(null) also collapses the multi-select set.
|
|
566
565
|
if (store.selectedElementId || store.selectedElementIds.size > 0) {
|
|
567
566
|
store.setSelectedElementId(null);
|
|
568
567
|
}
|
|
569
568
|
};
|
|
570
|
-
|
|
571
|
-
|
|
569
|
+
const listeners = new AbortController();
|
|
570
|
+
window.addEventListener("keydown", onBandEscape, { capture: true, signal: listeners.signal });
|
|
571
|
+
window.addEventListener("keydown", onKeyDown, { signal: listeners.signal });
|
|
572
|
+
return () => listeners.abort();
|
|
572
573
|
}, [cancelActiveGesture, isDragging]);
|
|
573
574
|
|
|
574
575
|
const previousSessionEpochRef = useRef(sessionEpoch);
|
|
@@ -140,6 +140,7 @@ export function useShadowPreviewReload({
|
|
|
140
140
|
|
|
141
141
|
const getShadowAdapter = useCallback(() => getAdapter(shadowIframeRef.current), [getAdapter]);
|
|
142
142
|
const isCurrentShadow = useCallback((gen?: number) => gen === shadowGenRef.current, []);
|
|
143
|
+
const previewGeneration = useCallback(() => shadowGenRef.current, []);
|
|
143
144
|
const markAdapterReady = useCallback(
|
|
144
145
|
(iframe: HTMLIFrameElement | null, gen: number | undefined, commit: () => void) => {
|
|
145
146
|
if (gen == null || gen !== shadowGenRef.current) return;
|
|
@@ -261,5 +262,6 @@ export function useShadowPreviewReload({
|
|
|
261
262
|
setShadowIframeNode,
|
|
262
263
|
beginShadowReload,
|
|
263
264
|
resetPreviewSlots,
|
|
265
|
+
previewGeneration,
|
|
264
266
|
};
|
|
265
267
|
}
|
|
@@ -649,3 +649,194 @@ function stubVisibility(initial: DocumentVisibilityState) {
|
|
|
649
649
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
650
650
|
};
|
|
651
651
|
}
|
|
652
|
+
|
|
653
|
+
describe("useTimelinePlayer scene swap", () => {
|
|
654
|
+
function liveFilm(swap?: (html: string) => Promise<void>) {
|
|
655
|
+
const { adapter, win } = makeAdapterWindow();
|
|
656
|
+
const iframe = makeFakeIframe(swap ? { ...win, __hfSwapScenes: swap } : win);
|
|
657
|
+
iframe.src = "http://localhost/api/projects/demo/preview";
|
|
658
|
+
const harness = renderTimelinePlayerHarness();
|
|
659
|
+
act(() => {
|
|
660
|
+
harness.getApi().iframeRef.current = iframe;
|
|
661
|
+
harness.getApi().onIframeLoad();
|
|
662
|
+
});
|
|
663
|
+
return { ...harness, adapter };
|
|
664
|
+
}
|
|
665
|
+
function playingFilm(swap: (html: string) => Promise<void>) {
|
|
666
|
+
const film = liveFilm(swap);
|
|
667
|
+
usePlayerStore.setState({ timelineReady: true });
|
|
668
|
+
act(() => film.getApi().play());
|
|
669
|
+
film.adapter.pause.mockClear();
|
|
670
|
+
return film;
|
|
671
|
+
}
|
|
672
|
+
const roles = (api: ReturnType<ReturnType<typeof liveFilm>["getApi"]>) =>
|
|
673
|
+
api.previewSlots.map((slot) => slot.role);
|
|
674
|
+
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
|
|
675
|
+
|
|
676
|
+
it("swaps the rebuilt preview's scenes into the live preview with no shadow reload", async () => {
|
|
677
|
+
const swap = vi.fn(async () => {});
|
|
678
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
679
|
+
const { getApi } = liveFilm(swap);
|
|
680
|
+
act(() => getApi().refreshPlayer());
|
|
681
|
+
await settle();
|
|
682
|
+
expect(swap).toHaveBeenCalledWith("<html>v2</html>", expect.anything());
|
|
683
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("falls back to the full reload when the preview refuses the swap", async () => {
|
|
687
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
688
|
+
const { getApi } = liveFilm(async () => {
|
|
689
|
+
throw new Error("the film changed outside its scenes");
|
|
690
|
+
});
|
|
691
|
+
act(() => getApi().refreshPlayer());
|
|
692
|
+
await settle();
|
|
693
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("reloads the whole film at once when the preview cannot swap", () => {
|
|
697
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
698
|
+
const { getApi } = liveFilm();
|
|
699
|
+
act(() => getApi().refreshPlayer());
|
|
700
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
701
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("never swaps in an older document that arrives after a newer reload started", async () => {
|
|
705
|
+
const swap = vi.fn(async () => {});
|
|
706
|
+
const replies: Array<(r: Response) => void> = [];
|
|
707
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
708
|
+
() => new Promise<Response>((resolve) => replies.push(resolve)),
|
|
709
|
+
);
|
|
710
|
+
const { getApi } = liveFilm(swap);
|
|
711
|
+
act(() => getApi().refreshPlayer());
|
|
712
|
+
act(() => getApi().refreshPlayer());
|
|
713
|
+
replies[1]!(new Response("<html>v3</html>"));
|
|
714
|
+
await settle();
|
|
715
|
+
replies[0]!(new Response("<html>v2</html>"));
|
|
716
|
+
await settle();
|
|
717
|
+
expect(swap.mock.calls).toEqual([["<html>v3</html>", expect.anything()]]);
|
|
718
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("cancels an older swap still waiting when a newer edit starts, so it cannot land after that edit's reload", async () => {
|
|
722
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response("<html>v</html>"));
|
|
723
|
+
let captionsArrive = () => {};
|
|
724
|
+
const captions = new Promise<void>((resolve) => (captionsArrive = resolve));
|
|
725
|
+
const landed: string[] = [];
|
|
726
|
+
let calls = 0;
|
|
727
|
+
// As the runtime does: the first waits for caption overrides, then refuses if cancelled; the second is refused.
|
|
728
|
+
const swap = async (_html: string, signal?: AbortSignal) => {
|
|
729
|
+
if (++calls > 1) throw new Error("the film changed outside its scenes");
|
|
730
|
+
await captions;
|
|
731
|
+
if (signal?.aborted) throw new Error("the swap was cancelled");
|
|
732
|
+
landed.push("edit 1");
|
|
733
|
+
};
|
|
734
|
+
const { getApi } = liveFilm(swap);
|
|
735
|
+
act(() => getApi().refreshPlayer());
|
|
736
|
+
await settle();
|
|
737
|
+
act(() => getApi().refreshPlayer());
|
|
738
|
+
await settle();
|
|
739
|
+
expect(roles(getApi())).toContain("shadow");
|
|
740
|
+
captionsArrive();
|
|
741
|
+
await settle();
|
|
742
|
+
expect(landed).toEqual([]);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("drops a pending swap's fallback once the preview was replaced, e.g. by a composition switch", async () => {
|
|
746
|
+
let refuse!: (e: Error) => void;
|
|
747
|
+
const swap = vi.fn(() => new Promise<void>((_, reject) => (refuse = reject)));
|
|
748
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
749
|
+
const { getApi } = liveFilm(swap);
|
|
750
|
+
act(() => getApi().refreshPlayer());
|
|
751
|
+
await settle();
|
|
752
|
+
expect(swap).toHaveBeenCalledTimes(1);
|
|
753
|
+
act(() => getApi().resetPreviewSlots());
|
|
754
|
+
const next = makeFakeIframe(makeAdapterWindow().win);
|
|
755
|
+
next.src = "http://localhost/api/projects/demo/preview/comp/compositions/intro.html";
|
|
756
|
+
act(() => {
|
|
757
|
+
getApi().iframeRef.current = next;
|
|
758
|
+
getApi().onIframeLoad();
|
|
759
|
+
});
|
|
760
|
+
refuse(new Error("the preview was torn down during the swap"));
|
|
761
|
+
await settle();
|
|
762
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("skips the swap for an edit made while a full reload is in flight", async () => {
|
|
766
|
+
const fetchSpy = vi
|
|
767
|
+
.spyOn(globalThis, "fetch")
|
|
768
|
+
.mockResolvedValue(new Response("<html>v2</html>"));
|
|
769
|
+
const { getApi } = liveFilm(async () => {
|
|
770
|
+
throw new Error("the film changed outside its scenes");
|
|
771
|
+
});
|
|
772
|
+
act(() => getApi().refreshPlayer());
|
|
773
|
+
await settle();
|
|
774
|
+
expect(roles(getApi())).toEqual(["live", "shadow"]);
|
|
775
|
+
const firstShadow = getApi().previewSlots[1]!.gen;
|
|
776
|
+
fetchSpy.mockClear();
|
|
777
|
+
act(() => getApi().refreshPlayer());
|
|
778
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
779
|
+
expect(getApi().previewSlots[1]!.gen).toBeGreaterThan(firstShadow);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("keeps a playing film playing through a swap, ready to swap the next edit", async () => {
|
|
783
|
+
const fetchSpy = vi
|
|
784
|
+
.spyOn(globalThis, "fetch")
|
|
785
|
+
.mockImplementation(async () => new Response("<html>v2</html>"));
|
|
786
|
+
const swap = vi.fn(async () => {});
|
|
787
|
+
const { getApi, adapter } = playingFilm(swap);
|
|
788
|
+
act(() => getApi().refreshPlayer());
|
|
789
|
+
await settle();
|
|
790
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
791
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
792
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
793
|
+
act(() => getApi().refreshPlayer());
|
|
794
|
+
await settle();
|
|
795
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
796
|
+
expect(swap).toHaveBeenCalledTimes(2);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("shows a playing film stopped at its new end when a swap cuts it short of the playhead", async () => {
|
|
800
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
801
|
+
let length = 6;
|
|
802
|
+
// As the runtime's swap does when the edit ends the film at or before the playhead.
|
|
803
|
+
const cutShort = async () => {
|
|
804
|
+
length = 5;
|
|
805
|
+
adapter.pause();
|
|
806
|
+
adapter.seek(5);
|
|
807
|
+
};
|
|
808
|
+
const { getApi, adapter } = playingFilm(cutShort);
|
|
809
|
+
adapter.getDuration = () => length;
|
|
810
|
+
adapter.seek(5.5);
|
|
811
|
+
act(() => getApi().refreshPlayer());
|
|
812
|
+
await settle();
|
|
813
|
+
await act(async () => void (await new Promise((r) => setTimeout(r, 50))));
|
|
814
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
815
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
816
|
+
expect(adapter.play).toHaveBeenCalledTimes(1);
|
|
817
|
+
expect(roles(getApi())).toEqual(["live"]);
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("keeps a playing film playing through a refused swap, from where the live frame had reached", async () => {
|
|
821
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("<html>v2</html>"));
|
|
822
|
+
const { getApi, adapter } = playingFilm(async () => {
|
|
823
|
+
throw new Error("the film changed outside its scenes");
|
|
824
|
+
});
|
|
825
|
+
adapter.seek(3);
|
|
826
|
+
act(() => getApi().refreshPlayer());
|
|
827
|
+
// The live frame plays on while the swap is tried; the fallback reload starts from here.
|
|
828
|
+
adapter.seek(4.5);
|
|
829
|
+
await settle();
|
|
830
|
+
expect(adapter.pause).not.toHaveBeenCalled();
|
|
831
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
832
|
+
const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen;
|
|
833
|
+
const shadow = makeShadowWithSpies();
|
|
834
|
+
act(() => {
|
|
835
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
836
|
+
getApi().onShadowIframeLoad(gen);
|
|
837
|
+
getApi().onShadowReadyChange(gen, true);
|
|
838
|
+
});
|
|
839
|
+
expect(shadow.adapter.getTime()).toBe(4.5);
|
|
840
|
+
expect(shadow.adapter.isPlaying()).toBe(true);
|
|
841
|
+
});
|
|
842
|
+
});
|