@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.
Files changed (83) hide show
  1. package/dist/assets/hyperframes-player-CtNVImMi.js +473 -0
  2. package/dist/assets/{index-Cz3E_ZSj.js → index-B_MF86RE.js} +1 -1
  3. package/dist/assets/{index-8Fgryj7q.js → index-DJ-Ol7YS.js} +1 -1
  4. package/dist/assets/{index-d5DKK15Q.js → index-DZKcDAtf.js} +203 -203
  5. package/dist/assets/{index-mzZCha_N.css → index-Dx79V1Se.css} +1 -1
  6. package/dist/{chunk-3IOCVWB6.js → chunk-PQFEGZWM.js} +3 -1
  7. package/dist/chunk-PQFEGZWM.js.map +1 -0
  8. package/dist/{domEditingLayers-ZOOMN7CQ.js → domEditingLayers-3BAP6NNO.js} +2 -2
  9. package/dist/index.d.ts +203 -160
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +1958 -1611
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/editor/DomEditOverlay.tsx +1 -2
  16. package/src/components/editor/DomEditOverlayHover.test.tsx +5 -0
  17. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -3
  18. package/src/components/editor/DomEditSelectionChrome.tsx +3 -13
  19. package/src/components/editor/LayersPanel.test.ts +14 -0
  20. package/src/components/editor/LayersPanel.tsx +2 -2
  21. package/src/components/editor/TopologyLens.tsx +2 -2
  22. package/src/components/editor/commitPreflightHandles.test.tsx +466 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +0 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +10 -1
  25. package/src/components/editor/domEditing.test.ts +29 -0
  26. package/src/components/editor/domEditingDom.ts +1 -0
  27. package/src/components/editor/domEditingTypes.ts +1 -0
  28. package/src/components/editor/marqueeCommit.test.tsx +84 -0
  29. package/src/components/editor/marqueeCommit.ts +13 -1
  30. package/src/components/editor/useColorGradingController.test.ts +24 -0
  31. package/src/components/editor/useColorGradingController.ts +3 -2
  32. package/src/components/editor/useDomEditOverlayGestures.ts +20 -5
  33. package/src/components/nle/useCompositionStack.test.tsx +53 -0
  34. package/src/components/nle/useCompositionStack.ts +4 -8
  35. package/src/hooks/domStyleCommit.ts +129 -0
  36. package/src/hooks/gsapEditOutcome.ts +2 -2
  37. package/src/hooks/gsapElementMatch.ts +38 -0
  38. package/src/hooks/gsapResizeIntercept.ts +10 -31
  39. package/src/hooks/gsapResizePreflight.ts +56 -0
  40. package/src/hooks/gsapRuntimeBridge.ts +66 -26
  41. package/src/hooks/keyframeCacheAstLoad.ts +4 -1
  42. package/src/hooks/useCommitPreflightCapabilities.ts +147 -0
  43. package/src/hooks/useDomEditCommits.ts +17 -182
  44. package/src/hooks/useDomEditPersist.ts +225 -0
  45. package/src/hooks/useDomEditPreviewSync.test.ts +58 -0
  46. package/src/hooks/useDomEditPreviewSync.ts +2 -4
  47. package/src/hooks/useDomEditSession.test.tsx +51 -1
  48. package/src/hooks/useDomEditSession.ts +11 -2
  49. package/src/hooks/useDomEditTextCommits.ts +15 -99
  50. package/src/hooks/useDomStyleCommit.test.tsx +155 -0
  51. package/src/hooks/useDomStyleCommit.ts +74 -0
  52. package/src/hooks/useGsapAnimationFetchFallback.test.ts +12 -0
  53. package/src/hooks/useGsapAnimationFetchFallback.ts +8 -2
  54. package/src/hooks/useGsapAwareEditing.test.tsx +4 -0
  55. package/src/hooks/useGsapAwareEditing.ts +1 -1
  56. package/src/hooks/useGsapTweenCache.ts +2 -48
  57. package/src/index.ts +10 -1
  58. package/src/player/components/Timeline.audioEffects.test.tsx +80 -0
  59. package/src/player/components/Timeline.bandEscape.test.tsx +75 -0
  60. package/src/player/components/Timeline.playheadRemount.test.tsx +3 -27
  61. package/src/player/components/TimelineGroupHeader.tsx +21 -10
  62. package/src/player/components/TimelineGroupLaneLabels.tsx +7 -2
  63. package/src/player/components/TimelineGroupRow.test.tsx +24 -33
  64. package/src/player/components/TimelineGroupRow.tsx +4 -1
  65. package/src/player/components/TimelineLanes.tsx +5 -5
  66. package/src/player/components/TimelineTrackHeader.test.tsx +27 -0
  67. package/src/player/components/TimelineTrackHeader.tsx +6 -3
  68. package/src/player/components/TimelineTypes.ts +1 -0
  69. package/src/player/components/timelineLaneProps.ts +1 -0
  70. package/src/player/components/timelineMountTestEnv.ts +29 -0
  71. package/src/player/components/useTimelineLabelColumn.ts +18 -0
  72. package/src/player/components/useTimelineLaneRowIndexes.ts +6 -1
  73. package/src/player/components/useTimelineProviderState.tsx +6 -12
  74. package/src/player/components/useTimelineRangeSelection.ts +13 -12
  75. package/src/player/hooks/useShadowPreviewReload.ts +2 -0
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +191 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +29 -7
  78. package/src/player/lib/runtimeProtocol.test.ts +1 -0
  79. package/src/player/sceneSwap.test.ts +95 -0
  80. package/src/player/sceneSwap.ts +53 -0
  81. package/dist/assets/hyperframes-player-Dx-2Gpsu.js +0 -473
  82. package/dist/chunk-3IOCVWB6.js.map +0 -1
  83. /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={`Show ${groupLabel} ${parts.name}${parts.param ? ` ${parts.param}` : ""} in the effect rack`}
67
- 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)] hover:text-[var(--timeline-text-solid)] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-[var(--timeline-accent)]"
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 { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
119
- const groupMemberTracks = useMemo(
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
- {clipCount > 1 &&
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
@@ -33,4 +33,5 @@ export interface TimelineProps
33
33
  onDuplicateClip?: () => Promise<boolean>;
34
34
  canPasteClip?: () => boolean;
35
35
  theme?: Partial<TimelineTheme>;
36
+ showAudioEffects?: boolean;
36
37
  }
@@ -29,6 +29,7 @@ export interface TimelineLaneBaseProps {
29
29
  contentGutter: number;
30
30
  trackContentWidth: number;
31
31
  theme: TimelineTheme;
32
+ showAudioEffects?: boolean;
32
33
  displayTrackOrder: number[];
33
34
  rowHeights: readonly number[];
34
35
  rowGeometry: TimelineRowGeometry;
@@ -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
- return { logicalRowsByTrack, groupByAnchor };
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, TRACKS_LEFT_PAD } from "./timelineLayout";
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 = useMemo(
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: cancel an in-flight marquee (restores the pre-drag selection);
550
- // otherwise clear any lingering multi-selection.
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
- const marquee = marqueeRef.current;
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
- window.addEventListener("keydown", onKeyDown);
571
- return () => window.removeEventListener("keydown", onKeyDown);
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
+ });