@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- package/src/tools.ts +181 -0
package/src/app/App.tsx
ADDED
|
@@ -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
|
+
}
|