@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,582 @@
1
+ // The shot chrome <-> stage protocol, and the ONE place both halves agree.
2
+ //
3
+ // ── WHY A BRIDGE AT ALL ───────────────────────────────────────────────────
4
+ //
5
+ // The chrome is a PREBUILT STATIC bundle (so user tooling can never break the
6
+ // editor) and the stage is served through the PROJECT's Vite (so the three
7
+ // stack resolves to the studio's pinned copies). They are two documents, and
8
+ // the only thing that crosses is this interface — installed on
9
+ // `window.__shotStudio` by the stage's `boot()` before first paint, and polled
10
+ // for by the chrome's `connectStage`.
11
+ //
12
+ // SYNCHRONOUS BY DESIGN, and same-origin only. The motion and canvas lanes made
13
+ // the same call for the same reason: a seek that has to await a postMessage
14
+ // round trip cannot be driven from a pointermove without tearing. Hosting this
15
+ // chrome cross-origin would need an async rewrite; that is a known ceiling and
16
+ // not a bug.
17
+ //
18
+ // ── THREE-FREE ────────────────────────────────────────────────────────────
19
+ //
20
+ // Nothing in this file — or anywhere under src/ outside src/stage/ — may import
21
+ // `three`. The stage PROJECTS; the chrome receives screen-space numbers as
22
+ // plain data. test/sourceHygiene.test.ts is the gate, and the temptation it
23
+ // exists to catch is importing `Vector3` "just for the projection maths".
24
+ import type { Doc3D, FramingDelta, GestureDelta, GestureKind, PathData, SnapGuide } from "@dailies-studio/shot/doc";
25
+ // ONE definition of the tool union, in the table that also answers what each
26
+ // tool's key, surface, cursor and draw behaviour are. Re-exported here so the
27
+ // stage can take the protocol from one import.
28
+ import type { InsertTool, ShotTool } from "./tools.js";
29
+ // The pane vocabulary lives with the geometry that defines it. Importing it
30
+ // here rather than restating it is safe for the three-free rule and load
31
+ // bearing for the lane: `panes.ts` and `workingView.ts` are plain arithmetic
32
+ // with no three import precisely so both documents can share these types, and
33
+ // a second declaration of "what a split is" is a second thing to keep in step.
34
+ import type { StageLayout, StagePane } from "./stage/panes.js";
35
+ import type { ShadingMode } from "./stage/shading.js";
36
+ import type { WorkingView } from "./stage/workingView.js";
37
+
38
+ export type { InsertTool, ShotTool };
39
+
40
+ export type { FramingDelta, GestureDelta, GestureKind, SnapGuide };
41
+ export type { ShadingMode, StageLayout, StagePane, WorkingView };
42
+
43
+ /** Bumped when a change to this file would make an old stage and a new chrome
44
+ * disagree silently. `connectStage` refuses a mismatch rather than running.
45
+ *
46
+ * 2: the split view. A v1 stage has no `setStageLayout`, so a v2 chrome asking
47
+ * for a working pane would get a silently unchanged camera pane — exactly the
48
+ * half-working state the refusal exists for.
49
+ *
50
+ * 3: the camera lane. A v2 stage has no `setCameraFollowsView`, so a v3 chrome
51
+ * turning follow on would throw inside the push and, worse, would show its
52
+ * armed button over a pane that is not following anything.
53
+ *
54
+ * 4: `onWorkingViewChange` carries a WorkingViewOrigin. A v3 stage sends none,
55
+ * so every one of its view changes reads as "the author moved the view" — and
56
+ * the stage's own fit is one of them, which under follow rewrites the camera
57
+ * of every document that is merely OPENED while follow happens to be armed.
58
+ * Silent, dirtying and autosaved, which is exactly what this refusal is for.
59
+ *
60
+ * 5: `onDraw`. A v4 stage has no draw lane at all, so with the rect tool armed
61
+ * a drag would fall through to its marquee: the author draws a rectangle, sees
62
+ * a selection band, and gets no layer and no error. Every tool in the dock
63
+ * would look armed and half of them would do nothing.
64
+ *
65
+ * 6: `closeDoc` + `clearCaches`. Document close now really closes on the
66
+ * stage: the doc is dropped (unmounting every plane) and THEN the raster LRU
67
+ * — a module singleton in the STAGE's bundle, which the chrome may not
68
+ * import — is freed. A v5 stage has neither method and the push would throw;
69
+ * worse, it would keep rendering the closed document behind the file picker
70
+ * with every raster resident.
71
+ *
72
+ * 7: `onPenCommit`. The pen draws BEZIERS now, and a v6 stage can only report
73
+ * the polyline `onDraw` carries — so a v6 stage under a v7 chrome would hand
74
+ * back a path whose curves had been flattened to corners on the way, silently
75
+ * and only for paths the author had actually curved. The handle geometry has
76
+ * no representation in the older lane at all, which is what makes this a
77
+ * version and not an optional extra.
78
+ *
79
+ * 8: `setIsolation` and `onEnterLayer`. A v7 stage has no isolation at all, so a v8 chrome
80
+ * entering a subtree would show the whole scene, undimmed, at the scene
81
+ * camera — while its outline, its keymap and its inspector all believed the
82
+ * author was inside one card. Every pick would land on a layer the chrome
83
+ * considers out of scope, so clicking anything would read as a dead canvas.
84
+ *
85
+ * 9: `setShadingMode`, and `"roll"` in `GestureKind`. Two silent
86
+ * disagreements at once. A v8 stage has no `setShadingMode`, so a v9 chrome's
87
+ * ShadingBar would show an armed wireframe button over a pane still rendering
88
+ * clay — the armed-control-over-a-dead-pane state the refusal exists for. And
89
+ * a v9 stage can emit `{kind: "roll"}` across `onGesture`, which a v8
90
+ * chrome's `transformFor` has no arm for — `undefined` reaches `unchanged()`
91
+ * and throws inside the stage's own pointer path.
92
+ *
93
+ * 10: `setIsolationOrbit` + `onIsolationOrbitTaken`, and `"scale"` in
94
+ * `GestureKind`. Two more silent disagreements. A v9 stage keeps isolation's
95
+ * DERIVED orthographic rig in the working camera's slot forever, so a v10
96
+ * chrome that believed the author had taken the camera would refuse nothing,
97
+ * let follow mode write the shot camera from a pose with no pixels, and
98
+ * autosave it. And a v10 chrome can emit `{kind: "scale"}` across `onGesture`,
99
+ * which a v9 `transformFor` has no arm for — `undefined` reaches `unchanged()`
100
+ * and throws inside the stage's own pointer path, exactly as `"roll"` did at
101
+ * version 9.
102
+ *
103
+ * 11: `onContextMenu`, `onMeshStats`, and the key-light trio
104
+ * (`onKeyLightStart`/`onKeyLight`/`onKeyLightEnd`). All five are stage ->
105
+ * chrome, and the mismatch bites in both directions.
106
+ *
107
+ * A v11 stage under a v10 chrome calls handlers that are not there. Every one
108
+ * of them fires from inside a pointer lane — a right press, a gizmo drag, a
109
+ * model finishing its load — so the failure is a throw in the middle of a
110
+ * gesture, which is the same shape `"roll"` and `"scale"` had at 9 and 10.
111
+ *
112
+ * A v10 stage under a v11 chrome is the quiet direction and worse for it: the
113
+ * chrome would show a right-click menu that never opens, a key-light gizmo
114
+ * that never appears on a toon mesh, and an outliner whose triangle budget
115
+ * reports an empty scene however heavy the models are. Nothing throws and
116
+ * nothing is wrong on screen; the author simply believes three features are
117
+ * broken and one number is true. */
118
+ export const SHOT_BRIDGE_VERSION = 11;
119
+
120
+ /** What the stage reports once it has a renderer and a document. */
121
+ export interface ShotStageInfo {
122
+ /** The design canvas, design px. */
123
+ width: number;
124
+ height: number;
125
+ /** Master-timeline extent. */
126
+ durationFrames: number;
127
+ /** "webgpu" or "webgl" — which backend actually came up, for the status
128
+ * badge. A WebGPU request that fell back to WebGL2 is normal, not an error,
129
+ * but it is worth showing because it changes what the post stack costs. */
130
+ backend: string;
131
+ }
132
+
133
+ /** A screen-space quad for one layer: TL, TR, BR, BL as x,y pairs, in CSS
134
+ * pixels relative to the stage viewport's top-left.
135
+ *
136
+ * A QUAD AND NOT A RECT. Layers tilt, so a selected layer's outline is a
137
+ * trapezoid; an axis-aligned box would lie about the tilt in every frame and
138
+ * would put the resize handles somewhere the layer is not. */
139
+ export interface ShotScreenQuad {
140
+ id: string;
141
+ pts: readonly [number, number, number, number, number, number, number, number];
142
+ }
143
+
144
+ /** The stage's view of the working area: how much of the frame is on screen and
145
+ * where. Owned by the stage (it is a camera `setViewOffset`), mirrored to the
146
+ * chrome so the zoom readout and "fit" have something to read and write. */
147
+ export interface ShotView {
148
+ /** Screen px per design px at z = 0. */
149
+ zoom: number;
150
+ /** Pan of the working area, design px. */
151
+ panX: number;
152
+ panY: number;
153
+ }
154
+
155
+ /**
156
+ * WHY the working view moved, which only the stage can know.
157
+ *
158
+ * "Camera follows this view" turns a view change into a DOCUMENT EDIT, so the
159
+ * three cases stop being the same event the moment follow is armed:
160
+ *
161
+ * "gesture" — an orbit or a pan. The stage brackets it with the framing
162
+ * gesture lane, so the whole drag is one undo entry.
163
+ * "wheel" — a dolly. It has no press and no release, so nothing brackets
164
+ * it; the chrome closes it over silence instead.
165
+ * "fit" — the stage MIRRORING a view it derived itself, on a document
166
+ * open or an iframe reload. Nobody moved anything, so follow must not
167
+ * write. Discriminating this by value (is it equal to the fit?) would be a
168
+ * guess; the caller is the only half that knows.
169
+ */
170
+ export type WorkingViewOrigin = "gesture" | "wheel" | "fit";
171
+
172
+ /** What the chrome installs on the stage. Every handler must be total and must
173
+ * never throw back across the boundary — the stage calls them from inside its
174
+ * own render loop. */
175
+ export interface ShotBridgeHandlers {
176
+ /** The renderer is up and the stage is showing `doc`. Fires once per
177
+ * document load, and again after a Vite reload remints the stage. */
178
+ onReady(info: ShotStageInfo): void;
179
+ /** The stage failed in a way the author needs to see. */
180
+ onError(message: string): void;
181
+ /**
182
+ * A pick, in DOCUMENT order front-to-back — the WHOLE ordered hit list, not
183
+ * just the nearest.
184
+ *
185
+ * The chrome owns the policy (plain click takes the front, Cmd-click walks
186
+ * behind), because "what does a modified click mean" is a chrome question. A
187
+ * stage that answered it would have to know about modifier state, and then
188
+ * two places would decide selection.
189
+ *
190
+ * `pane` says which view the hits were read in, and it is not decoration:
191
+ * the camera pane orders them by PAINT ORDER and the working pane by
192
+ * DISTANCE (see `pickLayersByDistance`), so the meaning of "the front-most
193
+ * hit" — and therefore what a meta-click walks behind — depends on it.
194
+ */
195
+ onPick(hits: readonly string[], modifiers: PickModifiers, pane: StagePane): void;
196
+ /**
197
+ * A DOUBLE-CLICK: go inside whatever is under the cursor.
198
+ *
199
+ * The same ordered hit list `onPick` sends, and for the same reason — the
200
+ * chrome owns "which of these did the author mean", and it is the only half
201
+ * that knows what is already isolated. A stage that answered would need the
202
+ * isolation stack, and then two places would decide what entering means.
203
+ */
204
+ onEnterLayer(hits: readonly string[], pane: StagePane): void;
205
+ /**
206
+ * A RIGHT PRESS: the same ordered hit stack `onPick` sends, plus the point to
207
+ * open a menu at.
208
+ *
209
+ * `onPick`'s division exactly — the stage reports what is under the pointer
210
+ * and the chrome decides what a menu of it should say. A stage that built the
211
+ * entries would need the selection, the isolation stack and every store verb,
212
+ * and the chrome already has all three.
213
+ *
214
+ * `at` is in CLIENT coordinates and needs no translation: the stage iframe is
215
+ * `position: fixed; inset: 0`, so a point in the stage's document is the same
216
+ * point in the chrome's.
217
+ */
218
+ onContextMenu(hits: readonly string[], pane: StagePane, at: { x: number; y: number }): void;
219
+ /**
220
+ * A mesh layer's triangle count, once its model has loaded — or null when the
221
+ * layer unmounts or its model is swapped.
222
+ *
223
+ * PUSHED, not polled. The count is known exactly once, when the GLB is parsed,
224
+ * and it cannot change until a different asset arrives; a chrome that asked
225
+ * for it per frame would be asking a question whose answer it already had.
226
+ *
227
+ * It crosses the bridge at all because the chrome is three-free by test: the
228
+ * outliner wants to badge a heavy model and the budget wants to warn about a
229
+ * heavy scene, and neither half can read a `BufferGeometry`.
230
+ */
231
+ onMeshStats(layerId: string, triangles: number | null): void;
232
+ /**
233
+ * The key-light gizmo's drag, as the same three-call shape every other
234
+ * gesture uses: open a scrub, re-derive from its base on every emit, commit
235
+ * or discard once.
236
+ *
237
+ * `onKeyLightStart` returns false when the chrome refuses to open one (a
238
+ * scrub is already held), and the stage abandons the drag rather than
239
+ * emitting into nothing. `dir` is a direction in the DESIGN frame, already
240
+ * unit length. `commit: false` restores the base, which is what Escape means.
241
+ *
242
+ * There is no light in this lane and this is not one: it writes
243
+ * `mesh.shading.keyDir`, a shader uniform that fakes a key direction. See
244
+ * `MeshShading` — "no lights, ever".
245
+ */
246
+ onKeyLightStart(layerId: string): boolean;
247
+ onKeyLight(dir: readonly [number, number, number]): void;
248
+ onKeyLightEnd(commit: boolean): void;
249
+ /**
250
+ * The author just took the working camera INSIDE isolation.
251
+ *
252
+ * ── WHY ISOLATION HANDS THE CAMERA OVER AT ALL ──────────────────────────
253
+ *
254
+ * Entering a subtree puts a DERIVED orthographic head-on rig in the working
255
+ * camera's slot (stage/isolation.ts), because that is the only camera that
256
+ * can frame a rolled or face-down card: the orbit rig resets its up vector to
257
+ * world +y every frame and clamps its polar angle off both poles. The pose is
258
+ * 100 percent derived, so there is no parameter an orbit, a pan or a dolly
259
+ * can move — and the author's first instinct is to orbit, which did nothing
260
+ * at all and said nothing about why. Blender's local view scopes what you see
261
+ * and leaves the viewport camera completely free.
262
+ *
263
+ * So the derived rig is the ENTRY pose, not a cage. The first navigation
264
+ * gesture fires this, the chrome flips its bit and pushes it back down, and
265
+ * from then on the ordinary perspective orbit rig owns the pane. Home and
266
+ * numpad-dot clear it, which makes head-on the RESET rather than the only
267
+ * option.
268
+ *
269
+ * It is a separate handler rather than a flavour of `onWorkingViewChange`
270
+ * because it is a MODE change the chrome must persist and re-push, while a
271
+ * view change is a mirror the chrome is free to throttle.
272
+ */
273
+ onIsolationOrbitTaken(): void;
274
+ /** The stage's own pan/zoom changed. Camera pane only: the working pane's
275
+ * view is a `WorkingView`, and the two are not the same shape. */
276
+ onViewChange(view: ShotView): void;
277
+ /** The working pane's orbit / pan / dolly changed. `origin` says WHAT moved
278
+ * it, and follow mode is the reason it has to: see `WorkingViewOrigin`. */
279
+ onWorkingViewChange(view: WorkingView, origin?: WorkingViewOrigin): void;
280
+ /** Which pane the author last pressed, wheeled or dragged in. The chrome
281
+ * points its zoom controls and its fit shortcut at it, so it must follow the
282
+ * POINTER rather than the selection — an author can orbit the working pane
283
+ * with a camera-pane layer selected. */
284
+ onActivePane(pane: StagePane): void;
285
+
286
+ // ── the gesture lane ─────────────────────────────────────────────────────
287
+ //
288
+ // Split into three calls rather than one, because the middle one fires on
289
+ // every pointermove and the outer two are what make the whole drag ONE undo
290
+ // entry: `start` opens a history scrub, every `onGesture` re-derives from the
291
+ // scrub's base, and `end` commits or discards it. A single "here is the new
292
+ // document" call could not express that, and would cost an undo entry per
293
+ // pixel moved.
294
+ /** `screenScale` is SCREEN PX PER DESIGN PX in the pane the press landed in,
295
+ * which the stage is the only half able to answer: the camera pane's is
296
+ * `ShotView.zoom` and the working pane's is a perspective scale derived from
297
+ * the orbit distance. The chrome divides its snap threshold by it, so
298
+ * omitting it snaps a working-pane drag at the camera pane's zoom. */
299
+ /**
300
+ * …and it ANSWERS whether the scrub opened.
301
+ *
302
+ * Engine allows one scrub, so a start that arrives while another lane holds
303
+ * the history has to be refused. A pointer drag can absorb that silently —
304
+ * the author releases and tries again — but a MODAL transform arms and stays
305
+ * armed, so one that opened over somebody else's scrub would drive that
306
+ * scrub with its own deltas and commit it. The stage honours the answer.
307
+ */
308
+ onGestureStart(kind: GestureKind, layerIds: readonly string[], screenScale?: number): boolean;
309
+ /** The delta MEASURED FROM THE PRESS, never since the last move — the scrub
310
+ * re-derives from its base, so a coalesced move cannot make a drag drift. */
311
+ onGesture(delta: GestureDelta): void;
312
+ /** `false` = Escape or a lost pointer: restore exactly what was there. */
313
+ onGestureEnd(commit: boolean): void;
314
+
315
+ // ── the CAMERA's gesture lane ────────────────────────────────────────────
316
+ //
317
+ // A second lane rather than a `subject` on the first, because the two write
318
+ // different things through different ops and only one of them can be refused
319
+ // for being under a shot. One scrub is open at a time either way (engine's
320
+ // history throws on a second), which the pointer already guarantees.
321
+ //
322
+ // It brackets TWO gestures: a drag on the frustum gizmo, which sends deltas;
323
+ // and — while "camera follows this view" is armed — a working-pane orbit or
324
+ // pan, which sends none at all. The view change IS the payload there, and it
325
+ // already crosses as `onWorkingViewChange`.
326
+ /** Answers whether the scrub opened, for `onGestureStart`'s reason. */
327
+ onFramingGestureStart(): boolean;
328
+ /** Measured FROM THE PRESS, same rule as `onGesture`. `dzoom` is a factor. */
329
+ onFramingGesture(delta: FramingDelta): void;
330
+ onFramingGestureEnd(commit: boolean): void;
331
+
332
+ /**
333
+ * INSERT A LAYER. The camera pane's draw lane, and the pen's commit.
334
+ *
335
+ * ── THE POINTS ARE DESIGN COORDINATES, NOT SCREEN ONES ─────────────────
336
+ *
337
+ * The stage is the only half that can convert: the chrome knows the view but
338
+ * not the pane's pixel size, and `screenToDesign` needs both. So the numbers
339
+ * that cross are already in the schema's own space — design px from the
340
+ * CANVAS CENTRE, +y down (types.ts's coordinate contract) — exactly like the
341
+ * `SnapGuide` positions that cross the other way. The chrome never divides by
342
+ * a zoom, and there is no second place a pane origin could be subtracted.
343
+ *
344
+ * ── A CLICK IS TWO IDENTICAL POINTS ────────────────────────────────────
345
+ *
346
+ * "Did the pointer travel far enough to be a drag" is a SCREEN-px question
347
+ * and therefore the stage's, so a sub-threshold drag arrives here collapsed
348
+ * to `from === to` and the chrome's one rule is "a zero-size box means the
349
+ * default box, centred on the point". A `dragged` flag beside the points
350
+ * would be a second thing that can disagree with them.
351
+ *
352
+ * Two points for every draw tool. The PEN no longer rides this lane — it
353
+ * carries handles now and crosses as `onPenCommit` below — but `onDraw`
354
+ * still accepts `"path"`, because `layerFromDraw` remains the way an
355
+ * agent-free caller mints a polyline path.
356
+ */
357
+ onDraw(tool: InsertTool, points: readonly (readonly [number, number])[]): void;
358
+
359
+ /**
360
+ * COMMIT THE PEN'S PATH: anchors, their bezier handles, and whether the loop
361
+ * closed.
362
+ *
363
+ * ONE ARGUMENT, not `(points, closed)`. `closed` lives inside `PathData`
364
+ * already, and a flag beside the points would be a second thing that can
365
+ * disagree with them — the same rule `onDraw` states above for `dragged`.
366
+ *
367
+ * Coordinates follow `onDraw` exactly: design px from the canvas centre,
368
+ * +y down, converted by the stage because only it knows the pane's size.
369
+ * The chrome normalises them into the layer's own viewBox on arrival.
370
+ *
371
+ * Fewer than two anchors never reaches here — `penData` returns null and the
372
+ * stage commits nothing, because one anchor is a layer with no geometry and
373
+ * no way to select it.
374
+ */
375
+ onPenCommit(data: PathData): void;
376
+
377
+ /** A key the STAGE received. Returns true when the chrome consumed it, so the
378
+ * stage knows whether to preventDefault. Keyboard events go to ONE document,
379
+ * and once the canvas has focus that document is the stage. */
380
+ onKey(event: KeyEventFields, editing: boolean): boolean;
381
+ }
382
+
383
+ /** The fields the keymap reads. A plain object rather than a KeyboardEvent
384
+ * so it crosses the bridge as data and a test can build one. */
385
+ export interface KeyEventFields {
386
+ key: string;
387
+ shiftKey: boolean;
388
+ altKey: boolean;
389
+ ctrlKey: boolean;
390
+ metaKey: boolean;
391
+ /** `KeyboardEvent.code` — the numpad lane resolves on it. Optional so a
392
+ * probe or an older sender without it keeps today's answers. */
393
+ code?: string;
394
+ }
395
+
396
+ /**
397
+ * The chrome's furniture, as an inset into the stage, in CSS px per side.
398
+ *
399
+ * Zero on every side is the honest default: a stage that has not been told
400
+ * anything draws in the corners, which is where it drew before this existed.
401
+ */
402
+ export interface StageInset {
403
+ top: number;
404
+ right: number;
405
+ bottom: number;
406
+ left: number;
407
+ }
408
+
409
+ export interface PickModifiers {
410
+ shift: boolean;
411
+ meta: boolean;
412
+ alt: boolean;
413
+ /**
414
+ * The hits came from a MARQUEE, not from a click.
415
+ *
416
+ * ── WHY THE CHROME CANNOT TELL THEM APART WITHOUT THIS ──────────────────
417
+ *
418
+ * `onPick`'s list means two different things. For a click it is the ordered
419
+ * HIT STACK under one point — front-most first, and a plain click takes the
420
+ * front one while meta walks behind it. For a marquee it is the SET the box
421
+ * enclosed, and every one of them is selected.
422
+ *
423
+ * Without the discriminator the chrome ran the click policy over both, so a
424
+ * box drawn over five layers took `hits[0]` and selected exactly one. Box
425
+ * select has never worked.
426
+ *
427
+ * It also decides what an EMPTY list means. BOTH DESELECT — a rubber band
428
+ * that caught nothing is still the author saying "not that", which is what
429
+ * Figma and Blender both do. Only the ISOLATION half differs: an empty click
430
+ * leaves the subtree, and an empty marquee does not, because a band drawn
431
+ * inside a card that caught nothing is not a request to leave the card.
432
+ */
433
+ marquee?: boolean;
434
+ }
435
+
436
+ /** What the stage installs on `window.__shotStudio`. */
437
+ export interface ShotStageBridge {
438
+ version: number;
439
+ /** Register the chrome's handlers. The stage renders NOTHING until this is
440
+ * called: there is nothing worth painting before a chrome exists, and the
441
+ * document arrives immediately after. */
442
+ connect(handlers: ShotBridgeHandlers): void;
443
+ /** Replace the document. Cheap when the reference is unchanged. */
444
+ loadDoc(doc: Doc3D): void;
445
+ /** Park the studio clock on an integer MASTER frame and repaint. */
446
+ seek(frame: number): void;
447
+ /** Which layers are selected, for the overlays. */
448
+ setSelection(ids: readonly string[]): void;
449
+ /** Screen quads for the current selection at the current frame, or null when
450
+ * nothing is selected. The chrome polls this from its own render rather than
451
+ * being pushed at, so an overlay re-render is never scheduled by the stage.
452
+ *
453
+ * The pane defaults to "camera" — the pane that IS the export, and the only
454
+ * one that existed at v1 — so a caller that does not know about the split
455
+ * keeps getting the answer it always got. */
456
+ selectionQuads(pane?: StagePane): readonly ShotScreenQuad[] | null;
457
+ /**
458
+ * Which tool is armed, because it decides the PLANE a move unprojects onto:
459
+ * "select" slides a tilted layer along its own surface, "move" slides it flat
460
+ * with z untouched. See stage/interactions/move.ts — that distinction is the
461
+ * whole difference between the two, and it only exists once layers can tilt.
462
+ */
463
+ setTool(tool: ShotTool): void;
464
+ /** Smart-guide lines the chrome's snapping landed on, in DESIGN frame
465
+ * coordinates. Pushed rather than polled because they change only while a
466
+ * gesture is running and must vanish the instant it ends. */
467
+ setSnapGuides(guides: readonly SnapGuide[]): void;
468
+ /** Drive the working-area view. */
469
+ setView(view: ShotView): void;
470
+ /** Fit the design frame to the CAMERA PANE with a margin. Returns the view it
471
+ * landed on so the chrome's readout stays in step. */
472
+ fitView(): ShotView;
473
+ /**
474
+ * How many views are on screen, and how the width is divided.
475
+ *
476
+ * WORKSPACE STATE, not document state: it is never written to a Doc3D and
477
+ * never reaches an export. The stage holds no copy it was not handed, so this
478
+ * is re-pushed on every attach.
479
+ */
480
+ setStageLayout(layout: StageLayout): void;
481
+ /**
482
+ * ISOLATE A SUBTREE, or null for the whole scene.
483
+ *
484
+ * WORKSPACE STATE, like the layout: never written to a Doc3D, never in an
485
+ * export. The stage is handed one id and derives everything from the document
486
+ * it already has — the subtree's members, the head-on camera, and which
487
+ * layers to dim — so the chrome never sends a set that could disagree with
488
+ * the tree.
489
+ *
490
+ * It takes the WORKING PANE's slot rather than adding a third view. A
491
+ * scissored pane per mode is the trap SplitRenderer's header is about, and
492
+ * the two views an author wants while inside a card are "this card, flat" and
493
+ * "the shot", which is exactly the split that already exists.
494
+ */
495
+ setIsolation(layerId: string | null): void;
496
+ /**
497
+ * Inside isolation, does the AUTHOR own the working camera?
498
+ *
499
+ * False is the entry state and means the derived orthographic head-on rig
500
+ * draws the pane. True means the ordinary perspective orbit rig does, driven
501
+ * by `workingView` exactly as it is outside isolation. See
502
+ * `onIsolationOrbitTaken` for why the mode exists at all.
503
+ *
504
+ * Pushed rather than inferred, because the CHROME owns it: it is the half
505
+ * that clears the mode on Home, on numpad-dot and on leaving the subtree, and
506
+ * the half whose `writeFramingFromView` must refuse while the derived rig is
507
+ * in the slot. A stage that decided for itself would be a second opinion on
508
+ * which camera is drawing.
509
+ *
510
+ * `setIsolation` resets it to false on its own, so entering a subtree never
511
+ * inherits the last one's mode.
512
+ */
513
+ setIsolationOrbit(taken: boolean): void;
514
+ /**
515
+ * How far the chrome's own furniture reaches INTO the stage, per side, in CSS
516
+ * px.
517
+ *
518
+ * ── WHY THE STAGE CANNOT MEASURE THIS ITSELF ────────────────────────────
519
+ *
520
+ * A pane is the WHOLE WINDOW (`paneRects`), because the stage iframe is
521
+ * pinned `position: fixed; inset: 0` and the chrome's panels float ON TOP of
522
+ * it. So "the top-right corner of the working pane" is behind the shell
523
+ * header, and there is no measurement the stage's document can take to
524
+ * discover that — the panels are in the other document.
525
+ *
526
+ * `Overlays` carried a hardcoded 116 for the top edge and a note saying the
527
+ * real fix was this message, "worth it when a second overlay needs the same
528
+ * answer". Four now do, and two of them — the isolation bar and the follow
529
+ * bar — sat entirely behind the opaque header, while the G/R/S pill, the only
530
+ * thing that says a modal transform is live, has never been visible in the
531
+ * default layout at all.
532
+ *
533
+ * PAINT ONLY. `paneRects` must not change: insetting the pane rects would
534
+ * move the scissor and the hit test together and shrink the rendered picture
535
+ * to fit the chrome. This positions overlay FURNITURE and nothing else.
536
+ *
537
+ * Editor state, never a document field.
538
+ */
539
+ setStageInset(inset: StageInset): void;
540
+ /**
541
+ * The working pane's shading (solid / wireframe / material / rendered).
542
+ *
543
+ * WORKSPACE STATE like the layout: never written to a Doc3D, never in an
544
+ * export, re-pushed on every attach. The camera pane is not addressable
545
+ * here — it is always the graded render, by construction.
546
+ */
547
+ setShadingMode(mode: ShadingMode): void;
548
+ /** Drive the working pane's orbit camera. */
549
+ setWorkingView(view: WorkingView): void;
550
+ /** Frame the whole canvas in the working pane. Returns the view it landed on,
551
+ * the same contract `fitView` has — the stage owns the derivation because it
552
+ * owns the lens the distance is derived from. */
553
+ fitWorkingView(): WorkingView;
554
+ /**
555
+ * "Camera follows this view" is armed.
556
+ *
557
+ * The stage needs it for two things it is the only half able to do: bracket
558
+ * an orbit with the framing gesture lane, so one drag is one undo entry; and
559
+ * draw the affordance INSIDE the working pane. Blender's long-running
560
+ * complaint about its equivalent is that it is easy to forget it is on, and a
561
+ * checkbox in a panel the author is not looking at is exactly how.
562
+ */
563
+ setCameraFollowsView(on: boolean): void;
564
+ /** Drop the document: the stage renders nothing and every layer plane
565
+ * unmounts, releasing its raster pins and texture bindings. The close
566
+ * half of `loadDoc`. */
567
+ closeDoc(): void;
568
+ /** Drop the stage's raster cache. Called on document CLOSE, AFTER
569
+ * `closeDoc` — the bitmaps are known dead because the doc-clear that
570
+ * precedes this call unmounts every plane, not merely because the chrome
571
+ * closed a panel. Not called on scene switch: surviving a switch warm is
572
+ * the whole reason the cache is module-level. */
573
+ clearCaches(): void;
574
+ /** Tear the stage down (a chrome unmount, or a bridge version mismatch). */
575
+ dispose(): void;
576
+ }
577
+
578
+ declare global {
579
+ interface Window {
580
+ __shotStudio?: ShotStageBridge;
581
+ }
582
+ }
@@ -0,0 +1,40 @@
1
+ // The eight channels the block system writes, each with its label and its
2
+ // resolved-value formatter.
3
+ //
4
+ // This list IS resolve.ts's combinator table, in the order the composition is
5
+ // written there — and it is the chrome's ONE copy. The inspector's Resolved
6
+ // section and the dock's channel rows both read it, so an eighth channel added
7
+ // in resolve.ts without a row here is a value the author has no way to see in
8
+ // either place, which is how a delta model becomes folklore.
9
+ import { formatValue } from "@dailies-studio/ui";
10
+ import type { LayerDelta, LayerState } from "@dailies-studio/shot/doc";
11
+
12
+ /** The channel names, exactly as `LayerDelta` declares them. */
13
+ export type Channel = keyof Required<LayerDelta>;
14
+
15
+ export interface ChannelSpec {
16
+ key: Channel;
17
+ label: string;
18
+ /** The channel's value as the author sees it — from `resolveLayerAt`'s
19
+ * output, never a second fold. */
20
+ read(state: LayerState): string;
21
+ }
22
+
23
+ export const CHANNELS: readonly ChannelSpec[] = [
24
+ { key: "opacity", label: "Opacity", read: (s) => formatValue(s.opacity, 0.01) },
25
+ // Position, size, tilt and roll read the layer's own value WITH the delta
26
+ // folded in, because that is the number on screen.
27
+ { key: "offset", label: "Position", read: (s) => s.position.map((v) => formatValue(v, 0.1)).join(", ") },
28
+ { key: "scale", label: "Size", read: (s) => `${formatValue(s.size[0], 0.1)} × ${formatValue(s.size[1], 0.1)}` },
29
+ { key: "tilt", label: "Tilt", read: (s) => `${formatValue(s.tilt[0], 0.1)}°, ${formatValue(s.tilt[1], 0.1)}°` },
30
+ { key: "roll", label: "Roll", read: (s) => `${formatValue(s.rollDeg, 0.1)}°` },
31
+ // Blur, reveal and stagger have no base at all: they exist only as block
32
+ // output, so the resolved value IS the fold.
33
+ { key: "blurPx", label: "Blur", read: (s) => `${formatValue(s.blurPx, 0.1)} px` },
34
+ { key: "reveal", label: "Reveal", read: (s) => `${formatValue(s.reveal * 100, 1)}%` },
35
+ { key: "textPhase", label: "Stagger", read: (s) => `${formatValue(s.textPhase * 100, 1)}%` },
36
+ ];
37
+
38
+ export const CHANNEL_BY_KEY: ReadonlyMap<Channel, ChannelSpec> = new Map(
39
+ CHANNELS.map((c) => [c.key, c]),
40
+ );
package/src/main.tsx ADDED
@@ -0,0 +1,15 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { createShotEditorStore } from "./state/store.js";
4
+ import { StoreProvider } from "./state/context.js";
5
+ import { App } from "./app/App.js";
6
+ import "./styles.css";
7
+
8
+ const store = createShotEditorStore();
9
+ createRoot(document.getElementById("root")!).render(
10
+ <StrictMode>
11
+ <StoreProvider store={store}>
12
+ <App />
13
+ </StoreProvider>
14
+ </StrictMode>,
15
+ );