@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,278 @@
1
+ // The stage's half of the bridge: a tiny store the chrome writes INTO through
2
+ // `window.__shotStudio`, and that `StageApp` subscribes to.
3
+ //
4
+ // Splitting it out of the React tree is what lets `boot()` install the handle
5
+ // SYNCHRONOUSLY, before anything renders. The chrome polls
6
+ // `iframe.contentWindow.__shotStudio` on load and must never observe a
7
+ // rendered-but-bridgeless stage; if the handle lived in a component it could
8
+ // only appear after the first commit, and the poll would race it.
9
+ import { clearRasterCache } from "@dailies-studio/shot";
10
+ import { markPerf } from "./PerfHud.js";
11
+ import type { Doc3D, Scene3D } from "@dailies-studio/shot/doc";
12
+ import type {
13
+ ShadingMode,
14
+ ShotBridgeHandlers,
15
+ ShotScreenQuad,
16
+ ShotStageBridge,
17
+ ShotTool,
18
+ ShotView,
19
+ SnapGuide,
20
+ StageInset,
21
+ StageLayout,
22
+ StagePane,
23
+ WorkingView,
24
+ } from "../bridgeTypes.js";
25
+ import { SHOT_BRIDGE_VERSION } from "../bridgeTypes.js";
26
+ import { clampSplitRatio } from "./panes.js";
27
+ import { clampShading } from "./shading.js";
28
+ import { INITIAL_WORKING_VIEW } from "./workingView.js";
29
+
30
+ export interface StageClientState {
31
+ /** A chrome has registered its handlers. Nothing renders before this. */
32
+ connected: boolean;
33
+ doc: Doc3D | null;
34
+ /** MASTER frame. */
35
+ frame: number;
36
+ selection: readonly string[];
37
+ view: ShotView;
38
+ /** Live only while a gesture is snapping. */
39
+ guides: readonly SnapGuide[];
40
+ tool: ShotTool;
41
+ /** How many panes are on screen. Pushed by the chrome; never derived here,
42
+ * so there is one owner of "what is the author looking at". */
43
+ layout: StageLayout;
44
+ workingView: WorkingView;
45
+ /** "Camera follows this view" is armed. The stage brackets its orbit with the
46
+ * framing gesture lane while it is, and draws the badge that says so. */
47
+ cameraFollowsView: boolean;
48
+ /** The isolated subtree's root layer id, or null for the whole scene. The
49
+ * stage derives the members, the camera and the dimming from the document
50
+ * it already has — see `setIsolation`. */
51
+ isolation: string | null;
52
+ /** Inside isolation, has the author taken the working camera? False means the
53
+ * derived orthographic head-on rig draws the pane; true means the ordinary
54
+ * orbit rig does. Meaningless while `isolation` is null. */
55
+ isolationOrbit: boolean;
56
+ /** The working pane's shading. Workspace state, mirrored from the chrome. */
57
+ shading: ShadingMode;
58
+ /** How far the chrome's floating panels reach into the stage, per side. Paint
59
+ * only: the overlay lays its furniture inside it and `paneRects` ignores it
60
+ * entirely. Zero until the chrome says otherwise. */
61
+ inset: StageInset;
62
+ }
63
+
64
+ const INITIAL: StageClientState = {
65
+ connected: false,
66
+ doc: null,
67
+ frame: 0,
68
+ selection: [],
69
+ view: { zoom: 1, panX: 0, panY: 0 },
70
+ guides: [],
71
+ tool: "select",
72
+ layout: { mode: "camera", splitRatio: 0.5 },
73
+ workingView: INITIAL_WORKING_VIEW,
74
+ cameraFollowsView: false,
75
+ isolation: null,
76
+ isolationOrbit: false,
77
+ shading: "solid",
78
+ inset: { top: 0, right: 0, bottom: 0, left: 0 },
79
+ };
80
+
81
+ /** Finite and non-negative on every side. A boundary, like `clampSplitRatio`:
82
+ * a NaN here becomes a NaN `y` on a `<text>` and the badge vanishes with no
83
+ * error anywhere. */
84
+ function clampInset(inset: StageInset): StageInset {
85
+ const one = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0);
86
+ return { top: one(inset.top), right: one(inset.right), bottom: one(inset.bottom), left: one(inset.left) };
87
+ }
88
+
89
+ export interface StageClient {
90
+ readonly bridge: ShotStageBridge;
91
+ getState(): StageClientState;
92
+ subscribe(listener: () => void): () => void;
93
+ /** The chrome's handlers, or null before connect. The stage calls up through
94
+ * these; every one must be treated as possibly-absent. */
95
+ handlers(): ShotBridgeHandlers | null;
96
+ /** How the stage answers `selectionQuads()`. Registered by the overlay layer
97
+ * once it exists; until then the bridge answers null. */
98
+ setQuadSource(source: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null): void;
99
+ /** How the stage answers `fitView()`. Registered by the viewport. */
100
+ setFitter(fit: (() => ShotView) | null): void;
101
+ /** …and `fitWorkingView()`. A separate registration rather than one fitter
102
+ * returning both, because the two are computed from different things: the
103
+ * camera fit needs the PANE SIZE and the working fit needs the DOCUMENT. */
104
+ setWorkingFitter(fit: (() => WorkingView) | null): void;
105
+ setDisposer(dispose: () => void): void;
106
+ }
107
+
108
+ export function createStageClient(): StageClient {
109
+ let state: StageClientState = INITIAL;
110
+ const listeners = new Set<() => void>();
111
+ let handlers: ShotBridgeHandlers | null = null;
112
+ let quadSource: ((pane: StagePane) => readonly ShotScreenQuad[] | null) | null = null;
113
+ let fitter: (() => ShotView) | null = null;
114
+ let workingFitter: (() => WorkingView) | null = null;
115
+ let dispose: (() => void) | null = null;
116
+
117
+ function set(patch: Partial<StageClientState>): void {
118
+ let changed = false;
119
+ for (const key of Object.keys(patch) as (keyof StageClientState)[]) {
120
+ if (!Object.is(state[key], patch[key])) {
121
+ changed = true;
122
+ break;
123
+ }
124
+ }
125
+ if (!changed) return;
126
+ state = { ...state, ...patch };
127
+ for (const l of listeners) l();
128
+ }
129
+
130
+ const bridge: ShotStageBridge = {
131
+ version: SHOT_BRIDGE_VERSION,
132
+ connect(next) {
133
+ handlers = next;
134
+ set({ connected: true });
135
+ },
136
+ loadDoc(doc) {
137
+ // A DIFFERENT document opening, not an edit echo: the span from here to
138
+ // the next presented frame is the author's "how long until I see it".
139
+ // markPerf no-ops without ?perf=1. Set BEFORE the state write, because
140
+ // the commit this write triggers is the frame that settles it.
141
+ if (state.doc?.id !== doc.id) markPerf("open→frame");
142
+ set({ doc });
143
+ },
144
+ seek(frame) {
145
+ // Integer frames over a rational fps is the engine's invariant, and this
146
+ // is the boundary a float could sneak through: a chrome that computed a
147
+ // frame from a pixel position would otherwise hand the studio clock a
148
+ // fractional one and break byte-identity against the export.
149
+ set({ frame: Math.round(frame) });
150
+ },
151
+ setSelection(ids) {
152
+ set({ selection: [...ids] });
153
+ },
154
+ selectionQuads(pane = "camera") {
155
+ return quadSource === null ? null : quadSource(pane);
156
+ },
157
+ setTool(tool) {
158
+ set({ tool });
159
+ },
160
+ setSnapGuides(guides) {
161
+ // Identity-stable when both are empty, so the common case (no gesture
162
+ // running) never notifies and never re-renders the stage.
163
+ if (guides.length === 0 && state.guides.length === 0) return;
164
+ set({ guides: [...guides] });
165
+ },
166
+ setView(view) {
167
+ set({ view });
168
+ },
169
+ fitView() {
170
+ if (fitter === null) return state.view;
171
+ const view = fitter();
172
+ set({ view });
173
+ return view;
174
+ },
175
+ setStageLayout(layout) {
176
+ // Gaining a working pane is the compile-storm moment (SplitRenderer +
177
+ // clay mount); the span to the next presented frame measures it.
178
+ if (layout.mode !== "camera" && state.layout.mode === "camera") markPerf("split→frame");
179
+ // Clamped HERE and not only in the chrome. This is a boundary: the ratio
180
+ // becomes a scissor rect, and a NaN scissor draws nothing with no error
181
+ // anywhere — the stage does not get to assume its caller was careful.
182
+ set({ layout: { mode: layout.mode, splitRatio: clampSplitRatio(layout.splitRatio) } });
183
+ },
184
+ setIsolation(isolation) {
185
+ // Validated against the DOCUMENT here, not trusted: the chrome's id can
186
+ // be one edit behind the stage's doc, and an isolation naming a layer
187
+ // that no longer exists would leave the pane pointed at nothing with no
188
+ // way for the author to get out.
189
+ const scene = state.doc === null ? null : sceneWithLayer(state.doc, isolation);
190
+ // THE ORBIT MODE RESETS WITH THE SUBTREE. Entering a second card while
191
+ // the first one's camera was taken would otherwise start it in a
192
+ // perspective pose derived from somewhere else entirely, and the head-on
193
+ // entry view — the whole point of isolation — would only appear the first
194
+ // time.
195
+ set({ isolation: isolation !== null && scene !== null ? isolation : null, isolationOrbit: false });
196
+ },
197
+ setIsolationOrbit(taken) {
198
+ set({ isolationOrbit: taken === true });
199
+ },
200
+ setStageInset(inset) {
201
+ const next = clampInset(inset);
202
+ // Identity-stable when nothing moved: the chrome writes this from a raw
203
+ // store subscription, so it fires on every pointermove of a panel resize
204
+ // and `set`'s `Object.is` check would see a fresh object every time.
205
+ const at = state.inset;
206
+ if (at.top === next.top && at.right === next.right && at.bottom === next.bottom && at.left === next.left) {
207
+ return;
208
+ }
209
+ set({ inset: next });
210
+ },
211
+ setWorkingView(workingView) {
212
+ set({ workingView });
213
+ },
214
+ fitWorkingView() {
215
+ if (workingFitter === null) return state.workingView;
216
+ const workingView = workingFitter();
217
+ set({ workingView });
218
+ return workingView;
219
+ },
220
+ setShadingMode(mode) {
221
+ // Clamped at the boundary like the split ratio: an unknown mode from a
222
+ // careless caller means the default clay, not a pane that draws nothing.
223
+ set({ shading: clampShading(mode) });
224
+ },
225
+ setCameraFollowsView(on) {
226
+ set({ cameraFollowsView: on === true });
227
+ },
228
+ closeDoc() {
229
+ set({ doc: null, selection: [] });
230
+ },
231
+ clearCaches() {
232
+ // Called right after closeDoc. The chrome's two calls run synchronously
233
+ // before the stage's doc-null commit can land, so the clear disposes
234
+ // textures some planes still bind — safe, because no GL draw happens
235
+ // between synchronous JS and that commit (draws ride commits), and the
236
+ // unmount commit runs cleanups, never a draw of the old doc.
237
+ clearRasterCache();
238
+ },
239
+ dispose() {
240
+ dispose?.();
241
+ dispose = null;
242
+ handlers = null;
243
+ },
244
+ };
245
+
246
+ return {
247
+ bridge,
248
+ getState: () => state,
249
+ subscribe(listener) {
250
+ listeners.add(listener);
251
+ return () => listeners.delete(listener);
252
+ },
253
+ handlers: () => handlers,
254
+ setQuadSource(source) {
255
+ quadSource = source;
256
+ },
257
+ setFitter(fit) {
258
+ fitter = fit;
259
+ },
260
+ setWorkingFitter(fit) {
261
+ workingFitter = fit;
262
+ },
263
+ setDisposer(fn) {
264
+ dispose = fn;
265
+ },
266
+ };
267
+ }
268
+
269
+ /** The scene holding a layer, or null. Exported for the isolation guard and for
270
+ * the stage's own scoping — "is this id still real" is the question both ask,
271
+ * and a second implementation of it is how the two drift. */
272
+ export function sceneWithLayer(doc: Doc3D, layerId: string | null): Scene3D | null {
273
+ if (layerId === null) return null;
274
+ for (const scene of Object.values(doc.scenesById)) {
275
+ if (scene.layersById[layerId] !== undefined) return scene;
276
+ }
277
+ return null;
278
+ }
@@ -0,0 +1,243 @@
1
+ // The mutable handoff between the half of the stage that lives INSIDE the
2
+ // canvas and the half that lives outside it.
3
+ //
4
+ // WHY A MUTABLE OBJECT AND NOT STATE. The pointer lane sits on the container
5
+ // div (pointer events land on the DOM, not on the scene graph) while picking
6
+ // and projection need the live camera, which only exists inside `<Canvas>`.
7
+ // Passing the camera out through React state would make every pointermove a
8
+ // render; passing a mutable box in makes it a property read. The same pattern
9
+ // the 2D lane's `feed.ts` uses, for the same reason.
10
+ //
11
+ // `version` is the ONE thing React is told about, and only when a selected
12
+ // quad actually moved by more than half a pixel — see `quadMoved`. Everything
13
+ // else on here is read imperatively at the moment it is needed.
14
+ //
15
+ // ── EVERY QUERY NAMES A PANE ────────────────────────────────────────────
16
+ //
17
+ // The stage can show two views of one scene at once (panes.ts), and the two
18
+ // have DIFFERENT CAMERAS. So there is no such thing as "the ray at x, y": the
19
+ // same canvas pixel unprojects to two different rays, picks two different
20
+ // layers and, in the working pane, sorts its hits by a different rule
21
+ // (`pickLayersByDistance` vs `pickLayers`). Making the pane the first argument
22
+ // rather than a mode on the feed is deliberate — a mode would be one more piece
23
+ // of state to keep in step with the pointer, and getting it stale means the
24
+ // author's click is answered by the pane they are not pointing at.
25
+ //
26
+ // COORDINATES ARE PANE-LOCAL CSS px, from the pane's top-left. `paneAt` returns
27
+ // exactly that pairing, so the caller passes through what it was handed and
28
+ // there is no second place where a pane origin gets subtracted.
29
+ import type { Camera, Matrix4, Ray } from "three";
30
+ import type { GestureKind, ResizeHandle } from "@dailies-studio/shot/doc";
31
+ import type { StagePane } from "./panes.js";
32
+ import type { GizmoGrab } from "./gizmoMath.js";
33
+ import type { LayerFrame, ScreenQuad, WorldQuad } from "./project.js";
34
+
35
+ /** What a press on the current selection turned out to be. */
36
+ export interface Grab {
37
+ kind: GestureKind;
38
+ /** resize only. */
39
+ handle?: ResizeHandle;
40
+ /** The grabbed layer's group matrix, for the gesture's plane. */
41
+ matrixWorld: Matrix4;
42
+ /**
43
+ * resize only: which way the grabbed handle points ON SCREEN, in radians from
44
+ * the quad's centre, measured in the overlay's own y-DOWN space.
45
+ *
46
+ * COMPUTED HERE, WHERE THE QUAD IS. The cursor has to be the handle's real
47
+ * direction and not its NAME: these are projected trapezoids, so the "nw"
48
+ * corner of a layer rolled 45 degrees points north, and a name-based
49
+ * `nwse-resize` would sit at 45 degrees to the edge the author is about to
50
+ * drag. Under the working pane's free orbit a name means even less.
51
+ *
52
+ * The pointer lane buckets it to the four bidirectional builtins and never
53
+ * derives it a second time, so the drawn handle, the hit test and the cursor
54
+ * all come off one number.
55
+ */
56
+ handleAngle?: number;
57
+ /**
58
+ * resize and roll: the projected CENTRE of the quad the handle belongs to, in
59
+ * the same pane-local px `handleAngle` was measured about.
60
+ *
61
+ * CARRIED, not re-derived. The rotate lane used to look the centre up again
62
+ * from `feed.quadsCamera[feed.ids.indexOf(selection[0])]`, and `selection[0]`
63
+ * is whichever id sorts LOWEST in document order — not the one the author
64
+ * grabbed. With a multi-selection the first pointermove jumped by the angle
65
+ * between the two centres, and when the lowest id had no frame at the
66
+ * playhead `indexOf` answered -1 and the press died silently.
67
+ */
68
+ centre?: readonly [number, number];
69
+ /**
70
+ * scale only: the UNION box's four world corners, snapshotted at the press.
71
+ *
72
+ * The drag needs them to place the pivot at the corner opposite the grabbed
73
+ * handle, and it needs them FROZEN: the box is rebuilt from the members every
74
+ * frame, so a live reference would move the pivot with the very layers the
75
+ * scale is moving and the ratio would chase itself.
76
+ */
77
+ union?: WorldQuad;
78
+ }
79
+
80
+ export interface StageFeed {
81
+ /** Bumped by the in-canvas half when the overlay needs repainting. */
82
+ version: number;
83
+ /**
84
+ * Bumped whenever a layer object MOUNTS OR UNMOUNTS in the three scene graph.
85
+ *
86
+ * `collectFrames` walks the whole graph, and its answer is a pure function of
87
+ * the graph's SHAPE — the matrices inside it are live references, so motion
88
+ * needs no invalidation and neither does a seek that only moves things. What
89
+ * it cannot see is membership, and membership changes for a reason no
90
+ * document value predicts: a GLB or an instance module resolves and suspense
91
+ * commits a subtree that was not there a frame ago. This counter is that
92
+ * event, and it is what lets the walk be memoised across an orbit.
93
+ */
94
+ sceneEpoch: number;
95
+ /**
96
+ * The overlay's OWN repaint hook, registered by the component that draws
97
+ * the quads and called by `StageTools` when they move.
98
+ *
99
+ * It used to be a `bumpOverlay` reducer on `StageApp`, and that shape was a
100
+ * double render: a camera dolly with a selection committed the whole stage
101
+ * (ShotRuntime included, whose dependency-less `ClockDriver` effect draws a
102
+ * GL frame per commit), then the quads moved, then `bumpOverlay` committed
103
+ * it all AGAIN. Registering the listener at the overlay leaf means a moved
104
+ * quad re-renders one SVG and zero canvases.
105
+ */
106
+ onOverlay: (() => void) | null;
107
+ /** The CURRENT selection, in the order both quad arrays are parallel to. One
108
+ * list for both panes: the selection is one selection, only its projection
109
+ * differs. */
110
+ ids: string[];
111
+ /** Screen quads for `ids` in each pane, in that pane's LOCAL CSS px. A pane
112
+ * that is not on screen has an empty array rather than stale quads — an
113
+ * overlay drawn from last layout's numbers is worse than no overlay. */
114
+ quadsCamera: ScreenQuad[];
115
+ quadsWorking: ScreenQuad[];
116
+ /** Parallel to `ids`: is each selected layer locked? Written here rather than
117
+ * looked up by the overlay because `collectFrames` is the one place that
118
+ * already knows, and a second lookup could disagree with the `grabAt` that
119
+ * refuses the handle. */
120
+ locked: boolean[];
121
+ /**
122
+ * The multi-select's ONE box, in world and projected into each pane, or null.
123
+ *
124
+ * Null covers three different situations on purpose, because the overlay
125
+ * treats them identically: fewer than two layers selected, a selection that
126
+ * is not coplanar, and one that is not axis-parallel (`unionQuad` says which).
127
+ * In all three the members keep their own outlines and their own handles.
128
+ *
129
+ * All three projections in ONE field so they cannot go out of step: the world
130
+ * corners are the pivot the drag measures against and the screen corners are
131
+ * what the author grabbed, and a frame where those disagreed would scale
132
+ * about a box nobody drew.
133
+ */
134
+ union: { world: WorldQuad; camera: ScreenQuad | null; working: ScreenQuad | null } | null;
135
+ /** Every alive layer this frame, for picking and the marquee. Null before
136
+ * the canvas has committed once. */
137
+ frames: readonly LayerFrame[] | null;
138
+ /**
139
+ * The working pane's camera, or null when that pane is off screen.
140
+ *
141
+ * WRITTEN BY `StageTools` AT PRIORITY 0 AND READ BY `SplitRenderer` AT
142
+ * PRIORITY 1, which is the whole reason it lives on the feed rather than
143
+ * being owned by the renderer that draws with it. The camera that answers a
144
+ * pick and the camera that drew the pixel are then the same object in the
145
+ * same state, by construction — a second camera written a frame later would
146
+ * put every working-pane click an orbit-step away from what the author saw.
147
+ *
148
+ * A `Camera` and not a `PerspectiveCamera`: isolation puts an ORTHOGRAPHIC
149
+ * camera in this slot (see stage/isolation.ts), and every consumer here —
150
+ * raycasting, projection, `renderBins` — takes the base class already.
151
+ */
152
+ workingCamera: Camera | null;
153
+ /** Ray-pick at a point in the pane's own CSS px. Front-most first, where
154
+ * "front-most" is paint order in the camera pane and nearest in the working
155
+ * one. Null until the canvas exists; empty for a pane that is not on
156
+ * screen. */
157
+ pick: ((pane: StagePane, x: number, y: number) => string[]) | null;
158
+ /**
159
+ * The outline a CLICK here would select, already projected — or null over
160
+ * nothing.
161
+ *
162
+ * Separate from `pick` because the answer is a QUAD and not an id list: the
163
+ * overlay draws it, and projecting one frame stage-side is cheaper and more
164
+ * honest than sending an id to the chrome and waiting a round trip for a quad
165
+ * that will be one pointermove stale by the time it arrives.
166
+ *
167
+ * It walks to the same container `onPick` selects (`layerOps.outermost`), so
168
+ * the outline the author sees before they click IS the thing the click
169
+ * selects. A second walk here would be that bug.
170
+ */
171
+ hoverQuad: ((pane: StagePane, x: number, y: number) => ScreenQuad | null) | null;
172
+ /** Marquee, in the same coordinates. */
173
+ pickRect:
174
+ | ((pane: StagePane, rect: { x0: number; y0: number; x1: number; y1: number }) => string[])
175
+ | null;
176
+ /** Is this press a transform gesture on the current selection, and which?
177
+ * Null for a press that is a selection change instead. */
178
+ grabAt:
179
+ | ((pane: StagePane, x: number, y: number, selection: readonly string[]) => Grab | null)
180
+ | null;
181
+ /** The camera ray through a point, for the move gesture's unprojection.
182
+ * Null for a pane that has no camera on screen — a pane with no camera has
183
+ * no ray, and answering with the other pane's would silently drag the layer
184
+ * along a plane the author is not looking at. */
185
+ rayAt: ((pane: StagePane, x: number, y: number) => Ray | null) | null;
186
+ /**
187
+ * Did a press in the WORKING pane land on the shot camera's frustum gizmo,
188
+ * and on which part?
189
+ *
190
+ * No pane argument, because there is only one pane this can be true in: the
191
+ * frustum gizmo IS the camera pane's camera, drawn as an object. It cannot
192
+ * appear inside the view it represents.
193
+ */
194
+ frustumHit: ((x: number, y: number) => "body" | "apex" | null) | null;
195
+ /**
196
+ * Did a press in the WORKING pane land on one of the selection's three drag
197
+ * arms, and on which?
198
+ *
199
+ * No pane argument, for `frustumHit`'s reason: the gizmo is drawn in the
200
+ * working pane alone (the camera pane has resize handles, which the working
201
+ * pane deliberately does not — see `grabAt`).
202
+ *
203
+ * The answer carries the arm's ORIGIN AND DIRECTION as a snapshot rather
204
+ * than a live reference: the gizmo re-centres on the selection every frame,
205
+ * and an axis line that moved with the layer being dragged along it would
206
+ * chase its own translation.
207
+ */
208
+ gizmoHit: ((x: number, y: number) => GizmoGrab | null) | null;
209
+ /**
210
+ * The arm the pointer is over, or null.
211
+ *
212
+ * WRITTEN BY THE POINTER LANE, READ INSIDE THE FRAME LOOP, which is what
213
+ * keeps a hover off React entirely: `MoveGizmo` already runs at priority 0
214
+ * and re-colours a material there, so highlighting costs one uniform write
215
+ * per frame and zero renders. Blender's gizmos highlight under the cursor and
216
+ * ours did not, which is the difference between "is this grabbable" being
217
+ * obvious and being something the author finds out by trying.
218
+ */
219
+ hoverArm: GizmoGrab["axis"] | null;
220
+ }
221
+
222
+ export function createStageFeed(): StageFeed {
223
+ return {
224
+ version: 0,
225
+ sceneEpoch: 0,
226
+ onOverlay: null,
227
+ ids: [],
228
+ quadsCamera: [],
229
+ quadsWorking: [],
230
+ locked: [],
231
+ union: null,
232
+ frames: null,
233
+ workingCamera: null,
234
+ pick: null,
235
+ hoverQuad: null,
236
+ pickRect: null,
237
+ grabAt: null,
238
+ rayAt: null,
239
+ frustumHit: null,
240
+ gizmoHit: null,
241
+ hoverArm: null,
242
+ };
243
+ }