@dailies-studio/shot-editor 0.1.0

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 (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,394 @@
1
+ // The shell.
2
+ //
3
+ // ── THE ONE STRUCTURAL RULE ──────────────────────────────────────────────
4
+ //
5
+ // `<StagePanel>` is the UNCONDITIONAL, UNKEYED FIRST CHILD of the shell root,
6
+ // and every panel floats over it. Nothing this component renders may reorder,
7
+ // reparent, key or conditionally wrap it, because a remount costs a full iframe
8
+ // document load, a fresh WebGPU adapter request, every layer raster redrawn and
9
+ // a dead stage for at least one connect poll. `test/stageIdentity.test.tsx`
10
+ // asserts `===` node identity across every shell state.
11
+ //
12
+ // The corollary, inherited from the canvas lane and just as load-bearing: NO
13
+ // ANCESTOR OF THE STAGE IFRAME MAY CARRY `transform`, `filter`,
14
+ // `backdrop-filter`, `contain` OR `will-change`. Each of those makes the
15
+ // element a containing block for fixed-position descendants, and the iframe is
16
+ // `position: fixed; inset: 0`; the failure is the stage silently sizing itself
17
+ // to a panel instead of the viewport.
18
+ import { useCallback, useEffect, useRef, useState } from "react";
19
+ import { FilePanel } from "../panels/files/FilePanel.js";
20
+ import { HelpSection } from "../panels/help/HelpSection.js";
21
+ import { ResizeHandle } from "../panels/ResizeHandle.js";
22
+ import { RAIL_WIDTH } from "../state/panelSize.js";
23
+ import { Header } from "../panels/header/Header.js";
24
+ import { InspectorPanel } from "../panels/inspector/InspectorPanel.js";
25
+ import { StagePanel } from "../panels/stage/StagePanel.js";
26
+ import { TimelineDock } from "../panels/timeline/TimelineDock.js";
27
+ import { ToolDockBar } from "../panels/toolbar/ToolDockBar.js";
28
+ import { ViewModeBar } from "../panels/toolbar/ViewModeBar.js";
29
+ import { ShadingBar } from "../panels/toolbar/ShadingBar.js";
30
+ import { CameraViewBar } from "../panels/toolbar/CameraViewBar.js";
31
+ import { ExportDialog } from "../panels/export/ExportDialog.js";
32
+ import { StageMenu } from "../panels/stage/StageMenu.js";
33
+ import { MaterialPicker } from "../panels/material/MaterialPicker.js";
34
+ import { AgentPanel } from "../panels/agent/AgentPanel.js";
35
+ import { AudioPreviewBridge } from "../audio/index.js";
36
+ import { subscribeStudioEvents } from "../state/renderEvents.js";
37
+ import { useShotEditor, useStore } from "../state/context.js";
38
+ import type { StageInset } from "../bridgeTypes.js";
39
+
40
+ export function App() {
41
+ const store = useStore();
42
+ const [stageError, setStageError] = useState<string | null>(null);
43
+ const onConnectError = useCallback((message: string | null) => setStageError(message), []);
44
+
45
+ const name = useShotEditor((s) => s.name);
46
+ const error = useShotEditor((s) => s.error);
47
+ const reportedStageError = useShotEditor((s) => s.stageError);
48
+ const exportOpen = useShotEditor((s) => s.exportOpen);
49
+ const agentOpen = useShotEditor((s) => s.agentOpen);
50
+
51
+ useOpenFromUrl();
52
+ useKeymap();
53
+ useExternalChangeRelay();
54
+
55
+ // Subscribed for the WHOLE session, not while the dialog is open: closing the
56
+ // dialog must not stop the render or lose its progress, and the header badge
57
+ // reads the same store bit.
58
+ useEffect(() => subscribeStudioEvents(store), [store]);
59
+
60
+ const railCollapsed = useShotEditor((s) => s.railCollapsed);
61
+ const inspectorCollapsed = useShotEditor((s) => s.inspectorCollapsed);
62
+ // The MODE alone, not the whole layout: the divider's own drag writes the
63
+ // ratio dozens of times a second, and subscribing to that here would
64
+ // re-render the entire shell on every frame of it. `--split-x` carries the
65
+ // position, written imperatively beside `--rail-w`.
66
+ const stageLayout = { mode: useShotEditor((s) => s.stageLayout.mode) };
67
+
68
+ // The dragged sizes reach CSS as variables on the shell, which is what lets
69
+ // the control strip and the timeline dock position themselves off the rail
70
+ // without any of the three knowing about the others.
71
+ //
72
+ // WRITTEN IMPERATIVELY, OUTSIDE REACT. The values exist only to become two
73
+ // custom properties, and subscribing them here re-rendered the entire shell
74
+ // — every panel, every dock row — on every pointermove of a resize drag,
75
+ // to do a style write. A raw store subscription writes the same two
76
+ // variables and renders nothing.
77
+ //
78
+ // `--rail-w` is omitted while the rail is collapsed ON PURPOSE: an inline
79
+ // style beats a class rule, so writing it unconditionally would override
80
+ // `.is-rail-collapsed { --rail-w: 0px }` and the strip would keep a gap
81
+ // where the hidden rail used to be.
82
+ const shellRef = useRef<HTMLDivElement | null>(null);
83
+ useEffect(() => {
84
+ const el = shellRef.current;
85
+ if (el === null) return;
86
+ let raf = 0;
87
+ // ── WHAT CAN MOVE A PANEL EDGE ────────────────────────────────────
88
+ //
89
+ // The variables are cheap style writes and are done on every store change.
90
+ // `measureInset` is not: it is seven `getBoundingClientRect` reads, which
91
+ // is a FORCED LAYOUT, and this is a raw store subscription — so it ran once
92
+ // per playback frame, for four numbers that a seek cannot possibly change.
93
+ //
94
+ // These seven fields are every piece of state that moves an edge. `name` is
95
+ // on the list because the header's title is content-sized and a long
96
+ // document name wraps the strip; the window's own resize comes through the
97
+ // ResizeObserver instead, which forces the measure outright.
98
+ let lastEdges = "";
99
+ const edgesOf = (s: ReturnType<typeof store.getState>): string =>
100
+ [
101
+ s.railCollapsed,
102
+ s.inspectorCollapsed,
103
+ s.dockCollapsed,
104
+ s.railWidth,
105
+ s.dockHeight,
106
+ s.agentOpen,
107
+ s.name,
108
+ ].join("|");
109
+ const write = (force: boolean): void => {
110
+ raf = 0;
111
+ const s = store.getState();
112
+ if (s.railCollapsed) el.style.removeProperty("--rail-w");
113
+ else el.style.setProperty("--rail-w", `${s.railWidth}px`);
114
+ el.style.setProperty("--dock-h", `${s.dockHeight}px`);
115
+ // Where the stage's own divider is, so the handle can sit on it without
116
+ // the shell re-rendering per frame of the drag.
117
+ el.style.setProperty("--split-x", `${Math.round(s.stageLayout.splitRatio * window.innerWidth)}px`);
118
+ const edges = edgesOf(s);
119
+ if (!force && edges === lastEdges) return;
120
+ lastEdges = edges;
121
+ // ── AND THE STAGE IS TOLD WHERE IT IS COVERED ────────────────────
122
+ //
123
+ // The stage iframe is pinned `inset: 0` behind these panels, so its
124
+ // panes are the whole window and it cannot see any of this. It used to
125
+ // carry a hardcoded 116 for the top edge and nothing at all for the
126
+ // other three, which put two mode bars behind the opaque header and the
127
+ // G / R / S pill behind the 232px left rail — where it has never once
128
+ // been visible in the default layout.
129
+ //
130
+ // MEASURED, not derived from the CSS constants. `.control-strip` is
131
+ // `flex-wrap: wrap` and grows past any constant on a narrow window, and
132
+ // `.timeline-dock` is content-driven under a max-height. Reading the
133
+ // boxes is both shorter than reproducing the layout and correct when
134
+ // someone changes it.
135
+ store.stage()?.setStageInset(measureInset(el));
136
+ };
137
+ // COALESCED TO THE FRAME. This is a raw store subscription so it fires on
138
+ // every pointermove of a panel resize, and `getBoundingClientRect` is a
139
+ // forced layout read against a layout that same move just invalidated.
140
+ let forced = false;
141
+ const schedule = (force: boolean): void => {
142
+ forced = forced || force;
143
+ if (raf !== 0) return;
144
+ raf = requestAnimationFrame(() => {
145
+ const f = forced;
146
+ forced = false;
147
+ write(f);
148
+ });
149
+ };
150
+ write(true);
151
+ const unsubscribe = store.subscribe(() => schedule(false));
152
+ // The store cannot see a WINDOW resize, and that is exactly when the
153
+ // control strip wraps and the top edge moves — so its tick FORCES the
154
+ // measure rather than asking the store whether anything changed.
155
+ const observer = new ResizeObserver(() => schedule(true));
156
+ observer.observe(el);
157
+ return () => {
158
+ if (raf !== 0) cancelAnimationFrame(raf);
159
+ unsubscribe();
160
+ observer.disconnect();
161
+ };
162
+ }, [store]);
163
+
164
+ return (
165
+ <div
166
+ ref={shellRef}
167
+ className={`shell${railCollapsed ? " is-rail-collapsed" : ""}${
168
+ inspectorCollapsed ? " is-inspector-collapsed" : ""
169
+ }`}
170
+ >
171
+ {/* FIRST, unconditional, unkeyed. See the header. */}
172
+ <StagePanel onConnectError={onConnectError} />
173
+
174
+ <Header />
175
+ <aside className="left-rail">
176
+ <FilePanel />
177
+ {/* Pinned to the rail's bottom (margin-top: auto) — the manual lives in
178
+ the corner that is otherwise empty, and stays put when the layer
179
+ tree grows because the rail scrolls as one column. */}
180
+ <HelpSection />
181
+ </aside>
182
+ {/* A SIBLING of the rail, not a child: the rail scrolls, and a handle
183
+ inside a scrolling box scrolls away from the edge it is supposed to
184
+ be on. Positioned off the same --rail-w the rail's width comes from,
185
+ so the two cannot disagree. */}
186
+ {railCollapsed ? null : (
187
+ <ResizeHandle
188
+ axis="x"
189
+ getValue={() => store.getState().railWidth}
190
+ sign={1}
191
+ onChange={(w) => store.setRailWidth(w)}
192
+ onReset={() => store.setRailWidth(RAIL_WIDTH.default)}
193
+ className="rail-resize"
194
+ label="Resize the left panel"
195
+ />
196
+ )}
197
+ {/* ── THE SPLIT DIVIDER DRAGS ────────────────────────────────────────
198
+ `splitRatio` has been in the schema, clamped twice and read by the
199
+ scissor since the split landed, and NOTHING ever moved it: the stage
200
+ drew a 1px seam and `panes.ts`'s header claimed the chrome laid out a
201
+ handle from the same rects. It did not.
202
+
203
+ CHROME-SIDE, because a stage-side handle needs a new stage-to-chrome
204
+ bridge message AND `pointerEvents: auto` inside an overlay whose
205
+ `pointerEvents: none` is a stated invariant — without it every drag
206
+ would end the moment the cursor crossed the divider.
207
+
208
+ Against `window.innerWidth`, which IS the pane width: the stage iframe
209
+ is pinned `inset: 0`, so its viewport is the whole window (panes.ts).
210
+ `clampSplitRatio` and `setStageLayout` already bound the value twice,
211
+ so there is no third validation here. */}
212
+ {stageLayout.mode !== "split" ? null : (
213
+ <ResizeHandle
214
+ axis="x"
215
+ getValue={() => store.getState().stageLayout.splitRatio * window.innerWidth}
216
+ sign={1}
217
+ onChange={(px) =>
218
+ store.setStageLayout({
219
+ ...store.getState().stageLayout,
220
+ splitRatio: px / Math.max(1, window.innerWidth),
221
+ })
222
+ }
223
+ onReset={() => store.setStageLayout({ ...store.getState().stageLayout, splitRatio: 0.5 })}
224
+ className="split-resize"
225
+ label="Resize the split"
226
+ />
227
+ )}
228
+ {/* ONE control strip along the top of the stage. These three were three
229
+ separately-positioned floats — tools centred on the bottom edge, the
230
+ view modes and camera verbs stacked at the top left — which cost the
231
+ bottom of the frame to a bar and split "what am I doing" across two
232
+ corners. As one flex row they read left to right in the order a
233
+ gesture is decided: which pane, what the camera does, which tool. */}
234
+ <div className="control-strip">
235
+ <ViewModeBar />
236
+ <ShadingBar />
237
+ <CameraViewBar />
238
+ <ToolDockBar />
239
+ </div>
240
+ {agentOpen ? <AgentPanel /> : <InspectorPanel />}
241
+ <TimelineDock />
242
+ {/* Renders nothing. Mounted HERE and not in the stage: audio is not part
243
+ of the document, and the stage is a GL iframe that must never
244
+ remount. Unconditional, so it survives every shell state. */}
245
+ <AudioPreviewBridge />
246
+
247
+ {exportOpen && name !== null && <ExportDialog onClose={() => store.setExportOpen(false)} />}
248
+
249
+ {/* Both portal out of the layout, so they hang here rather than inside a
250
+ panel: the stage menu opens over whichever half was right-clicked, and
251
+ the picker is a mode over the whole shell. Unconditional, because each
252
+ renders nothing until its own state field is set. */}
253
+ <StageMenu />
254
+ <MaterialPicker />
255
+
256
+ {name === null ? (
257
+ <div className="empty-state">
258
+ <h1>Dailies shot</h1>
259
+ <p>Open a scene, or create one, to start.</p>
260
+ </div>
261
+ ) : null}
262
+
263
+ <div className="overlay-strip" role="status" aria-live="polite">
264
+ {[stageError, reportedStageError, error].filter((m): m is string => m !== null).map((message) => (
265
+ <div key={message} className="overlay-message">
266
+ <span>{message}</span>
267
+ <button type="button" onClick={() => store.dismissError()} title="Dismiss">
268
+ ×
269
+ </button>
270
+ </div>
271
+ ))}
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ /** `?shot=<name>` on load. The URL is a MIRROR the store writes, and reading it
278
+ * once at boot is what makes a reload land back where the author was — the
279
+ * same reason the canvas lane mirrors its focused board.
280
+ *
281
+ * With NO name in the URL, open the first document there is. A bare `/shot`
282
+ * is how a hosted workspace is entered — nobody types a query string at a
283
+ * front door — and greeting a new author with an empty stage hides the fact
284
+ * that their project already has content. Guarded on `name === null` so it
285
+ * can only ever fill a vacuum, never replace something already open. */
286
+ function useOpenFromUrl(): void {
287
+ const store = useStore();
288
+ useEffect(() => {
289
+ const wanted = new URLSearchParams(window.location.search).get("shot");
290
+ if (wanted !== null && wanted.length > 0) {
291
+ void store.openShot(wanted);
292
+ return;
293
+ }
294
+ void (async () => {
295
+ await store.refreshShots();
296
+ const first = store.getState().shots[0];
297
+ if (first !== undefined && store.getState().name === null) {
298
+ await store.openShot(first.name);
299
+ }
300
+ })();
301
+ }, [store]);
302
+ }
303
+
304
+ /**
305
+ * The shell's keydown lane, which is now four lines: every binding lives in
306
+ * `state/keymap.ts` and every action lives in the store.
307
+ *
308
+ * ONE BINDING SITE was the point. A keymap spread across a shell handler, a
309
+ * panel and a stage is a keymap where "what does ⇧A do" has three answers and
310
+ * a reader has to find all three to be sure.
311
+ */
312
+ /** The panels that float over the stage, per edge they eat into. Hidden ones
313
+ * measure 0x0 and are skipped, which is what makes a collapsed rail give the
314
+ * stage its corner back. */
315
+ const INSET_PANELS = {
316
+ top: [".shell-header", ".control-strip"],
317
+ left: [".left-rail"],
318
+ right: [".inspector", ".agent-panel"],
319
+ bottom: [".timeline-dock"],
320
+ } as const;
321
+
322
+ /**
323
+ * How far the chrome's furniture reaches into the stage, per side, in CSS px.
324
+ *
325
+ * ZERO-SIZED ELEMENTS ARE SKIPPED and that is not an optimisation. A
326
+ * `display: none` panel reports an all-zero rect, and for the RIGHT edge
327
+ * `shell.right - 0` is the whole window — so a collapsed inspector would tell
328
+ * the stage its entire width was covered and every badge would render off
329
+ * screen.
330
+ */
331
+ function measureInset(shell: HTMLElement): StageInset {
332
+ const box = shell.getBoundingClientRect();
333
+ const edge = (selectors: readonly string[], of: (r: DOMRect) => number): number => {
334
+ let most = 0;
335
+ for (const selector of selectors) {
336
+ const el = shell.querySelector(selector);
337
+ if (el === null) continue;
338
+ const rect = el.getBoundingClientRect();
339
+ if (rect.width === 0 || rect.height === 0) continue;
340
+ most = Math.max(most, of(rect));
341
+ }
342
+ return most;
343
+ };
344
+ return {
345
+ top: edge(INSET_PANELS.top, (r) => r.bottom - box.top),
346
+ left: edge(INSET_PANELS.left, (r) => r.right - box.left),
347
+ right: edge(INSET_PANELS.right, (r) => box.right - r.left),
348
+ bottom: edge(INSET_PANELS.bottom, (r) => box.bottom - r.top),
349
+ };
350
+ }
351
+
352
+ function useKeymap(): void {
353
+ const store = useStore();
354
+ useEffect(() => {
355
+ const onKeyDown = (e: KeyboardEvent): void => {
356
+ if (store.handleKey({ ...keyOf(e), target: e.target })) e.preventDefault();
357
+ };
358
+ window.addEventListener("keydown", onKeyDown);
359
+ return () => window.removeEventListener("keydown", onKeyDown);
360
+ }, [store]);
361
+ }
362
+
363
+ /** The fields the keymap reads, so it can be called with a plain object
364
+ * from a test as easily as with a real event. */
365
+ function keyOf(e: KeyboardEvent) {
366
+ return {
367
+ key: e.key,
368
+ shiftKey: e.shiftKey,
369
+ altKey: e.altKey,
370
+ ctrlKey: e.ctrlKey,
371
+ metaKey: e.metaKey,
372
+ code: e.code,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * The stage relays the server's "this shot changed under you" push (only the
378
+ * iframe has a live HMR client — see stage/main.tsx). Turn it back into a store
379
+ * call here.
380
+ */
381
+ function useExternalChangeRelay(): void {
382
+ const store = useStore();
383
+ useEffect(() => {
384
+ const onMessage = (e: MessageEvent): void => {
385
+ if (e.origin !== window.location.origin) return;
386
+ const data = e.data as { type?: unknown; data?: unknown } | null;
387
+ if (data?.type !== "studio:shot-doc-changed") return;
388
+ store.adoptExternalChange((data.data ?? {}) as { name?: string; origin?: string; clientId?: string });
389
+ };
390
+ window.addEventListener("message", onMessage);
391
+ return () => window.removeEventListener("message", onMessage);
392
+ }, [store]);
393
+ }
394
+
@@ -0,0 +1,74 @@
1
+ // The chrome-side bridge that feeds the scheduler from the store with real Web
2
+ // Audio. Renders nothing.
3
+ //
4
+ // MOUNTED FROM THE CHROME, never from the stage: the stage is a GL document in
5
+ // an iframe that must never remount, and audio is not part of the document.
6
+ //
7
+ // Pushes are per-notification but O(1) — the scheduler only acts on play/pause
8
+ // edges, discontinuities and document identity changes (see preview.ts), so the
9
+ // playback rAF path stays free of work. Monitoring level and mute are STORE
10
+ // state (session, never document), so the dock head's controls and this bridge
11
+ // read one bit and cannot disagree.
12
+ import { useEffect } from "react";
13
+ import { useStore } from "../state/context.js";
14
+ import { createAudioPreview } from "./preview.js";
15
+ import type { AudioContextLike, AudioPreview, AudioPreviewDeps } from "./preview.js";
16
+
17
+ export { SCRUB_JUMP_FRAMES, createAudioPreview } from "./preview.js";
18
+ export type {
19
+ AudioBufferLike,
20
+ AudioBufferSourceNodeLike,
21
+ AudioContextLike,
22
+ AudioParamLike,
23
+ AudioPreview,
24
+ AudioPreviewDeps,
25
+ GainNodeLike,
26
+ TransportSnapshot,
27
+ } from "./preview.js";
28
+
29
+ async function fetchBytes(url: string): Promise<ArrayBuffer> {
30
+ const res = await fetch(url);
31
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
32
+ return res.arrayBuffer();
33
+ }
34
+
35
+ const browserDeps: AudioPreviewDeps = {
36
+ // Lazy: the first play edge is the user gesture that may create the context.
37
+ createContext: () => new AudioContext() as unknown as AudioContextLike,
38
+ fetchBytes,
39
+ decode: (ctx, bytes) => (ctx as unknown as AudioContext).decodeAudioData(bytes),
40
+ };
41
+
42
+ /** `create` is the test seam; production gets Web Audio. */
43
+ export function AudioPreviewBridge(props: { create?: () => AudioPreview } = {}): null {
44
+ const store = useStore();
45
+ const create = props.create;
46
+ useEffect(() => {
47
+ const preview = create ? create() : createAudioPreview(browserDeps);
48
+ let muted: boolean | null = null;
49
+ let volume: number | null = null;
50
+ const push = (): void => {
51
+ const s = store.getState();
52
+ if (s.previewMuted !== muted) {
53
+ muted = s.previewMuted;
54
+ preview.setMasterMute(muted);
55
+ }
56
+ if (s.previewVolume !== volume) {
57
+ volume = s.previewVolume;
58
+ preview.setMasterVolume(volume);
59
+ }
60
+ // AFTER the monitor bits: an unmute that reschedules must do so knowing
61
+ // the transport it is rescheduling against, and `update` is what records
62
+ // it. On the very first push both orders agree, because the scheduler
63
+ // starts paused.
64
+ preview.update({ playing: s.playing, frame: s.frame, doc: s.doc });
65
+ };
66
+ push();
67
+ const unsub = store.subscribe(push);
68
+ return () => {
69
+ unsub();
70
+ preview.dispose();
71
+ };
72
+ }, [store, create]);
73
+ return null;
74
+ }