@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,4094 @@
1
+ // The shot chrome's one store. Engine-owned history, one document, one stage.
2
+ //
3
+ // ── THE THREE RULES THIS FILE EXISTS TO KEEP ─────────────────────────────
4
+ //
5
+ // 1. EVERY DOCUMENT EDIT IS A `transaction()`. Undo/redo is engine's
6
+ // `createHistory<Doc3D>`, not a hand-rolled stack, so a gesture that wants
7
+ // "one undo entry for a whole drag" uses `scrub()` and gets it by
8
+ // construction rather than by remembering to coalesce.
9
+ //
10
+ // 2. THE STAGE IS PUSHED AT, NEVER POLLED. `loadDoc` on every history change,
11
+ // `seek` on every frame change, `setSelection` on every selection write. The
12
+ // stage holds no copy of anything it was not handed.
13
+ //
14
+ // 3. A SAVE ALWAYS CLAIMS ITS BASELINE. `baseRev` is the rev the open document
15
+ // was read at; a mismatch comes back 409 and the author is told to reload,
16
+ // never silently merged. `null` claims a create.
17
+ import { createHistory } from "@dailies-studio/engine";
18
+ import type { History } from "@dailies-studio/engine";
19
+ import {
20
+ ASSET_ID_PREFIX,
21
+ LAYER_ID_PREFIX,
22
+ MAX_AUDIO_GAIN,
23
+ addAudioClip,
24
+ addLayer,
25
+ addMarker,
26
+ addPresetBlock,
27
+ addShot,
28
+ alignLayers,
29
+ addComponentInstance,
30
+ clonerLayers,
31
+ createComponent,
32
+ deleteComponent,
33
+ homeOf,
34
+ patchHome,
35
+ renameComponent,
36
+ animateGesture,
37
+ applyFramingDelta,
38
+ applyGesture,
39
+ applyInstanceRebinds,
40
+ applyToTransform,
41
+ instanceContractRefFor,
42
+ instancePropPlan,
43
+ cameraAt,
44
+ createShotDoc,
45
+ framingForLayers,
46
+ docDurationFramesCached,
47
+ duplicateLayers,
48
+ freshId,
49
+ groupLayers,
50
+ masterToSceneCached,
51
+ moveLayer,
52
+ canHoldChildren,
53
+ outermost as outermostIn,
54
+ parentBaseTransform,
55
+ parentOf,
56
+ rigidOf,
57
+ toLocal,
58
+ withDescendants,
59
+ relativesOf,
60
+ sceneToMaster,
61
+ MAX_FRAMING_ZOOM,
62
+ MIN_BLOCK_FRAMES,
63
+ MIN_FRAMING_ZOOM,
64
+ removeLayers,
65
+ renameLayer,
66
+ reorderLayer,
67
+ restackLayer,
68
+ moveBlock,
69
+ moveShot,
70
+ removeAudioClip,
71
+ removeBlock,
72
+ removeMarker,
73
+ removeShot,
74
+ reorderScene,
75
+ setBlockEase,
76
+ setBlockParams,
77
+ setBlockPreset,
78
+ setLayerFlags,
79
+ setSceneTransition,
80
+ sizeChangesPixels,
81
+ splitBlock,
82
+ splitScene,
83
+ trimBlock,
84
+ trimScene,
85
+ trimShot,
86
+ snapMove,
87
+ ungroupLayer,
88
+ worldBaseTransform,
89
+ updateAudioClip,
90
+ updateMarker,
91
+ usedIdsOf,
92
+ createScene,
93
+ writeFraming,
94
+ } from "@dailies-studio/shot/doc";
95
+ import type {
96
+ AlignMode,
97
+ AnimBlock,
98
+ BlockSlot,
99
+ Doc3D,
100
+ Framing,
101
+ FramingDelta,
102
+ GestureDelta,
103
+ GestureKind,
104
+ InstanceContractRef,
105
+ InstanceLayer3D,
106
+ MarkerRef,
107
+ MoveShot,
108
+ Scene3D,
109
+ SceneTransition,
110
+ Shot,
111
+ ShotAssetKind,
112
+ SnapGuide,
113
+ } from "@dailies-studio/shot/doc";
114
+ import { patchProposalIn } from "@dailies-studio/agent-client";
115
+ import type {
116
+ AgentCredentialsInfo,
117
+ AgentProposalPatch,
118
+ AgentTranscriptItem,
119
+ } from "@dailies-studio/agent-client";
120
+ import type { RenderJob } from "../api.js";
121
+ import type { Ease, Value } from "@dailies-studio/engine";
122
+ import type { Scrub } from "@dailies-studio/engine";
123
+ import * as api from "../api.js";
124
+ import { ApiError } from "../api.js";
125
+ import { isOwnEcho } from "./clientId.js";
126
+ import { DOCK_HEIGHT, RAIL_WIDTH, clampSize, dockCeiling } from "./panelSize.js";
127
+ import { INITIAL_STATE, INITIAL_VIEW } from "./types.js";
128
+ import type { ShotEditorState } from "./types.js";
129
+ import { deviceLayer, htmlLayer, instanceLayer, layerFromDraw, meshLayer, penLayer } from "./insert.js";
130
+ import { TRIANGLE_CAP_REFUSAL, TRIANGLE_HARD_CAP, sumTriangles } from "./triangles.js";
131
+ import type { DrawPoint, InsertContext, NewLayer3D } from "./insert.js";
132
+ import { isEditable, resolveKey } from "./keymap.js";
133
+ import type { KeyEventLike } from "./keymap.js";
134
+ import { FIT_MARGIN, clampZoom } from "../stage/view.js";
135
+ import { clampSplitRatio } from "../stage/panes.js";
136
+ import { nextShading } from "../stage/shading.js";
137
+ import {
138
+ DOLLY_FALLOFF_PX,
139
+ WORKING_FOV_DEG,
140
+ dollyBounds,
141
+ dollyBy,
142
+ framingFromWorkingView,
143
+ orbitByDegrees,
144
+ presetWorkingView,
145
+ workingViewForFraming,
146
+ } from "../stage/workingView.js";
147
+ import { CAMERA_ROW_ID, writeAudioDrag, writeLifespan } from "../panels/timeline/model.js";
148
+ import type { AudioDrag, LifespanDrag } from "../panels/timeline/model.js";
149
+ import { wavDurationSeconds } from "@dailies-studio/timeline";
150
+ import type { InsertTool, ShotTool } from "../tools.js";
151
+ import type {
152
+ PickModifiers,
153
+ ShadingMode,
154
+ ShotBridgeHandlers,
155
+ ShotScreenQuad,
156
+ ShotStageBridge,
157
+ ShotStageInfo,
158
+ ShotView,
159
+ StageLayout,
160
+ StagePane,
161
+ WorkingView,
162
+ WorkingViewOrigin,
163
+ } from "../bridgeTypes.js";
164
+
165
+ /** How close a moving edge has to come to a target before it is pulled onto it,
166
+ * in SCREEN px. Divided by the zoom at use, so the pull feels the same size on
167
+ * screen at every zoom — snapping that got stronger as you zoomed out is the
168
+ * classic version of this bug. */
169
+ const SNAP_THRESHOLD_PX = 6;
170
+
171
+ /** A stand-in target for a relayed key that landed in a field on the STAGE. The
172
+ * stage cannot send a DOM node across the bridge, so it sends the verdict and
173
+ * this reconstitutes something `isEditable` agrees with. */
174
+ const EDITING_TARGET = { tagName: "INPUT", isContentEditable: false } as unknown as EventTarget;
175
+
176
+ /** Autosave debounce. Long enough that a drag is one save, short enough that
177
+ * closing the tab a beat after an edit does not lose it. */
178
+ const AUTOSAVE_MS = 400;
179
+
180
+ /**
181
+ * Every "what is selected" bit, cleared. Loading a document must set ALL of
182
+ * them, and `set` merges, so a swap that only cleared `selection` left the
183
+ * others pointing into the document that just went away.
184
+ *
185
+ * That is not a near miss: ids in this lane are deterministic slugs minted
186
+ * against one document's used ids, so the FIRST marker of every shot is
187
+ * `mk_marker` and a stale `selectedMarker` resolves against its successor. The
188
+ * inspector then opens on a marker the author never clicked and Delete removes
189
+ * it, because `deleteSelection` consults the marker before the layers.
190
+ */
191
+ const CLEARED_SELECTION = {
192
+ selection: [] as readonly string[],
193
+ selectedBlock: null,
194
+ selectedMarker: null,
195
+ imagePick: null,
196
+ // Isolation is a layer id too, and the same deterministic-slug argument
197
+ // applies to it exactly: `l3_card` exists in most shots, so an isolation
198
+ // carried across a document swap would silently enter a DIFFERENT card and
199
+ // scope every pick to it.
200
+ isolation: null,
201
+ editingDef: null,
202
+ } satisfies Partial<ShotEditorState>;
203
+
204
+ /**
205
+ * How long the wheel has to go quiet before a follow-mode dolly counts as
206
+ * finished.
207
+ *
208
+ * A wheel is the one authoring gesture with no end: a trackpad flick is thirty
209
+ * to eighty events and no release. Follow mode makes each of them a document
210
+ * write, so without a bracket one flick is thirty undo entries — while the pan
211
+ * and the orbit on the same pane are one each. Silence is the only end signal a
212
+ * wheel has.
213
+ *
214
+ * Long enough to bridge the gaps inside one flick, short enough that the scrub
215
+ * it holds open is closed before the author's hand reaches a panel — and every
216
+ * other lane that takes the history closes it first anyway, so the window is a
217
+ * latency question and never a correctness one.
218
+ */
219
+ const WHEEL_IDLE_MS = 180;
220
+
221
+ /** How far behind the chrome's `workingView` mirror may trail the stage's live
222
+ * value while follow is off. Nothing renders from it — it feeds persistence
223
+ * and the keyboard zoom's read — so 10 Hz is indistinguishable from live. */
224
+ const WORKING_VIEW_MIRROR_MS = 100;
225
+
226
+ /**
227
+ * One press of the zoom buttons, expressed as the wheel travel that produces
228
+ * the camera pane's own 1.25x step.
229
+ *
230
+ * Routed through `dollyBy` rather than written onto `distance` so the working
231
+ * pane's clamps stay the only place a distance is bounded — and so "zoom in"
232
+ * means the same RATIO in both panes, which is the only sense in which the two
233
+ * can be the same control.
234
+ */
235
+ const WORKING_ZOOM_STEP_PX = DOLLY_FALLOFF_PX * Math.log(1.25);
236
+
237
+ /**
238
+ * A new camera move's length: ONE SECOND of the document's own fps.
239
+ *
240
+ * Derived rather than a frame count, because a move is a CINEMATIC duration. A
241
+ * fixed 30 would be a third of a second on a 90fps document and two seconds on
242
+ * a 15fps one, which is the same number meaning two different things.
243
+ */
244
+ const NEW_SHOT_SECONDS = 1;
245
+
246
+ /**
247
+ * Said by every lane that wants the history while a POINTER holds it. Engine
248
+ * allows one scrub, so the alternative is an exception thrown at the author
249
+ * from inside somebody else's event handler.
250
+ *
251
+ * WORDED FOR EVERY CALLER, not just the camera. `undo` is the one that has to
252
+ * be covered and is the easiest to miss: it bypasses `edit` entirely, and
253
+ * `commit()` clears the redo stack, so an undo taken mid-drag does not merely
254
+ * land inside the drag's entry — it destroys that entry on BOTH stacks.
255
+ */
256
+ const HISTORY_BUSY =
257
+ "A drag is still running, so this would land inside its own undo entry — " +
258
+ "finish the drag, then try again.";
259
+
260
+ export interface ShotEditorStore {
261
+ getState(): ShotEditorState;
262
+ subscribe(listener: () => void): () => void;
263
+
264
+ /** Handed to `connectStage`; the stage calls these. */
265
+ readonly stageHandlers: ShotBridgeHandlers;
266
+ attachStage(bridge: ShotStageBridge | null): void;
267
+ /** The live bridge, for the overlay layer's per-frame quad read. Null until
268
+ * the stage connects. */
269
+ stage(): ShotStageBridge | null;
270
+ selectionQuads(): readonly ShotScreenQuad[] | null;
271
+
272
+ refreshShots(): Promise<void>;
273
+ openShot(name: string): Promise<void>;
274
+ createShot(name: string): Promise<void>;
275
+ closeShot(): void;
276
+ /** Adopt a write that landed from somewhere else (another tab, an agent). */
277
+ adoptExternalChange(payload: { name?: string; origin?: string; clientId?: string }): void;
278
+
279
+ /**
280
+ * The ONE document-edit seam. `fn` must be pure and must return the SAME
281
+ * reference when nothing changed — that is what makes a no-op edit cost no
282
+ * history entry and no save.
283
+ *
284
+ * ── IT ANSWERS WHETHER IT RAN ─────────────────────────────────────────
285
+ *
286
+ * False means the edit was REFUSED, and the refusal is already in `error`.
287
+ * Every caller that follows an edit with its own `set({ error })` was wiping
288
+ * that message: a mid-drag arrow nudge hit `historyBusy`, which wrote
289
+ * HISTORY_BUSY, and then the caller's own "nothing to nudge" (or its `null`)
290
+ * overwrote it — so the key did nothing and said nothing, which is the exact
291
+ * silence this lane keeps removing. Ten call sites shared the bug and share
292
+ * this fix.
293
+ */
294
+ edit(fn: (doc: Doc3D) => Doc3D): boolean;
295
+ undo(): void;
296
+ redo(): void;
297
+ /** Force the pending autosave now (a doc switch, a close, a blur). */
298
+ flush(): Promise<void>;
299
+
300
+ seek(frame: number): void;
301
+ setPlaying(playing: boolean): void;
302
+ /** Advance one frame; the transport's rAF loop calls this. */
303
+ step(delta: number): void;
304
+
305
+ select(ids: readonly string[]): void;
306
+ /**
307
+ * Bring the playhead into a scene, so what the author is about to click is on
308
+ * screen. A no-op when it already is.
309
+ *
310
+ * The dock shows every scene at once and the STAGE shows exactly one: the one
311
+ * at the playhead. So a row press in another scene has to navigate, or the
312
+ * author selects a layer that draws no handles, opens an inspector on
313
+ * something invisible, and `select` — which orders ids within the current
314
+ * scene — quietly resolves the selection to nothing.
315
+ *
316
+ * SELECTING navigates; CREATING does not. "Show me this" moving the playhead
317
+ * is the request; "+ shot", which puts a new thing AT the playhead, would be
318
+ * silently changing where its own write lands, so that one refuses by name
319
+ * instead.
320
+ */
321
+ focusScene(sceneId: string): void;
322
+ setScene(sceneId: string | null): void;
323
+ setTool(tool: ShotTool): void;
324
+ setInspectorMode(mode: "design" | "animate"): void;
325
+ setAnimateMode(on: boolean): void;
326
+
327
+ /** THE ONLY KEY BINDING SITE. Returns true when the event was consumed, so
328
+ * the caller knows whether to preventDefault. */
329
+ handleKey(event: KeyEventLike & { target?: EventTarget | null }): boolean;
330
+
331
+ /** Layer-tree edits, each one transaction. The sidebar and the keymap drive
332
+ * the same functions, so "what does delete do" has one answer. */
333
+ deleteSelection(): void;
334
+ duplicateSelection(): void;
335
+ /** Align or distribute the selection on the design plane. */
336
+ alignSelection(mode: AlignMode): void;
337
+ groupSelection(): void;
338
+ ungroupSelection(): void;
339
+ toggleFlag(flag: "hidden" | "locked"): void;
340
+ renameLayer(id: string, label: string): void;
341
+ reorderLayer(id: string, toIndex: number): void;
342
+ /** Reparent and/or reorder, keeping the layer where it is on screen. The
343
+ * outline's drop target; `parent: null` is the scene root. */
344
+ moveLayerTo(id: string, to: { parent?: string | null; index?: number }): void;
345
+ // ── components ────────────────────────────────────────────────────────────
346
+ /** Turn the selection into a reusable definition and leave one instance. */
347
+ createComponentFromSelection(name?: string): void;
348
+ /** Another placement of a definition, at its own size. */
349
+ placeComponent(defId: string): void;
350
+ /** Refuses while instances exist, naming where they are. */
351
+ deleteComponent(defId: string): void;
352
+ renameComponent(defId: string, name: string): void;
353
+ /** One exposed field on one instance. `null` clears it back to the
354
+ * definition's own value. */
355
+ setComponentOverride(layerId: string, key: string, value: Value | null): void;
356
+ /** Scope the OUTLINE to a definition's interior, or null for the scene. */
357
+ editComponent(defId: string | null): void;
358
+
359
+ /** Edit this subtree alone: the working pane frames it head-on, everything
360
+ * outside it dims and stops being pickable. */
361
+ enterIsolation(id: string): void;
362
+ /** Up ONE level, to the isolated layer's parent — or out to the scene when
363
+ * it had none. Entering walks down, so leaving walks back up. */
364
+ exitIsolation(): void;
365
+ /** All the way out to the scene, however deep. The breadcrumb's first crumb;
366
+ * Escape is the one-level version. */
367
+ exitIsolationAll(): void;
368
+ restack(id: string, to: "front" | "back" | "forward" | "backward"): void;
369
+ nudge(dx: number, dy: number): void;
370
+ selectAll(): void;
371
+
372
+ // ── the insertion lane ───────────────────────────────────────────────────
373
+ //
374
+ // The draw tools arrive through `stageHandlers.onDraw` (the stage owns the
375
+ // pointer); these are the paths a MENU drives, plus the second half of the
376
+ // image tool, which has to wait on a file.
377
+ /**
378
+ * Finish a drawn media layer with the file the author picked, or drop the
379
+ * pending box when they cancelled (`null`).
380
+ *
381
+ * An IMAGE OR A VIDEO, decided by the kind the server sniffed — the Frame
382
+ * tool draws one box and the file fills it. Rejects with a message the caller
383
+ * shows. Asset and layer land in ONE undo entry — see `insertAssetLayer`.
384
+ */
385
+ completeImageDraw(file: File | null): Promise<void>;
386
+ /** Upload a .glb and insert a mesh layer naming it, at the playhead. */
387
+ addMeshLayer(file: File): Promise<void>;
388
+ /** Insert a device slab of a preset from the registry, at the playhead. */
389
+ addDeviceLayer(preset: string): void;
390
+ /** Insert an html snippet layer at the playhead. No drag tool: a snippet is
391
+ * authored in the inspector, and a rubber band would only pick its box. */
392
+ addHtmlLayer(): void;
393
+ /**
394
+ * Wrap the selection in a cloner.
395
+ *
396
+ * There is deliberately no "insert an empty cloner": `validateShotDoc`
397
+ * refuses a cloner with no children, so the empty one would be a document the
398
+ * author could not save. A no-op with nothing selected is the honest answer,
399
+ * and the menu disables the row rather than offering it.
400
+ */
401
+ addClonerLayer(): void;
402
+
403
+ // ── the component registry (instance layers) ─────────────────────────────
404
+ /** Fetch `GET /api/components` into `library`. Never throws: a failed fetch
405
+ * sets `libraryError` and leaves `libraryLoaded` false, because "the lookup
406
+ * failed" and "there are no components" must never look the same. */
407
+ refreshLibrary(): Promise<void>;
408
+ /** Fetch and cache ONE component's contract. Idempotent and deduped. */
409
+ loadContract(componentId: string): Promise<void>;
410
+ /** Mount a contracted component as a layer at the playhead. The registry row
411
+ * supplies the binding (hash + import-or-snapshot); a row whose host is not
412
+ * "three" is refused here as well as at the save gate, so the menu can never
413
+ * produce a document the server would reject. */
414
+ addInstanceLayer(componentId: string): Promise<void>;
415
+ /** Set ONE contract-keyed prop on an instance layer. */
416
+ setInstanceProp(layerId: string, key: string, value: Value): void;
417
+ /** Clear one stored prop, so it falls back to the contract's default. Also
418
+ * how a quarantined override is finally discarded — by the AUTHOR, explicitly,
419
+ * which is the only way one ever goes away. */
420
+ clearInstanceProp(layerId: string, key: string): void;
421
+ setInstanceClockOffset(layerId: string, frames: number): void;
422
+ /**
423
+ * Re-bind an instance to its component's CURRENT contract.
424
+ *
425
+ * Records the live hash, re-decides import-vs-snapshot, and moves every value
426
+ * the rename/orphan planner classifies as a `rebind` onto its new key.
427
+ * Orphans are LEFT WHERE THEY ARE — the quarantine — because dropping them is
428
+ * the documented silent-data-loss failure this whole lane is written against.
429
+ * Never automatic: it is a statement that the current contract is the one
430
+ * this instance means, and only the author can make it.
431
+ */
432
+ rebindInstance(layerId: string): Promise<string | null>;
433
+
434
+ // ── the timeline dock ────────────────────────────────────────────────────
435
+ setDockCollapsed(collapsed: boolean): void;
436
+ setRailCollapsed(collapsed: boolean): void;
437
+ setInspectorCollapsed(collapsed: boolean): void;
438
+ /** Both clamp; both take the RAW dragged value, so the handle never has to
439
+ * know the bounds. */
440
+ setRailWidth(width: number): void;
441
+ setDockHeight(height: number): void;
442
+ /** Hide every panel, or bring them all back. See the implementation. */
443
+ togglePanels(): void;
444
+ setDockPpf(ppf: number): void;
445
+
446
+ // ---- the audio lane --------------------------------------------------------
447
+ /**
448
+ * Upload a file, measure the STORED wav, and register asset + clip in ONE
449
+ * undo entry. Rejects with a message the lane shows; never leaves half of it
450
+ * on the document.
451
+ */
452
+ importAudio(file: File, atFrame?: number): Promise<void>;
453
+ /** One capture of the live `beginBlockDrag` scrub. See `writeAudioDrag`. */
454
+ dragAudio(clipId: string, mode: AudioDrag, delta: number): void;
455
+ /** Linear 0..2, also one capture of the live scrub. */
456
+ dragAudioGain(clipId: string, gain: number): void;
457
+ toggleAudioMute(clipId: string): void;
458
+ removeAudio(clipId: string): void;
459
+ /** PREVIEW monitoring, never the mix. */
460
+ setPreviewMuted(muted: boolean): void;
461
+ setPreviewVolume(volume: number): void;
462
+
463
+ // ---- the marker lane -------------------------------------------------------
464
+ selectMarker(markerId: string | null): void;
465
+ /**
466
+ * `M`, and the lane's `+`. ONE transaction, and the new mark becomes the
467
+ * inspector's subject.
468
+ *
469
+ * A second press at the same frame SELECTS the mark already there rather than
470
+ * stacking an invisible duplicate on it. Premiere does the same, and the
471
+ * alternative is a document with two marks the author can only tell apart by
472
+ * deleting one.
473
+ */
474
+ addMarkerAtPlayhead(): void;
475
+ /** One capture of the live `beginBlockDrag` scrub, so a marker drag is one
476
+ * undo entry like every other gesture on the strip. */
477
+ dragMarker(markerId: string, frame: number): void;
478
+ /** The inspector's fields. One transaction each. */
479
+ setMarker(markerId: string, patch: Partial<Omit<MarkerRef, "id">>): void;
480
+ removeMarker(markerId: string): void;
481
+
482
+ selectBlock(at: { sceneId: string; layerId: string; blockId: string } | null): void;
483
+ /**
484
+ * The dock's "fit the whole document across the lane", registered by the dock
485
+ * because only the dock knows how many pixels wide its lane is.
486
+ *
487
+ * The same shape `fitView` uses against the stage bridge: the store owns the
488
+ * verb so the keymap has one place to reach, and the surface that owns the
489
+ * measurement owns the arithmetic. Null on unmount.
490
+ */
491
+ attachDockFit(fit: ((sceneId?: string) => void) | null): void;
492
+ /** No argument fits the whole document; a scene id frames THAT SHOT across
493
+ * the lane (the strip's double-click). */
494
+ fitDock(sceneId?: string): void;
495
+
496
+ // ── export ───────────────────────────────────────────────────────────────
497
+ setExportOpen(open: boolean): void;
498
+ /** Mirror one render job off the SSE bus (or off the POST that started it). */
499
+ setRenderJob(job: RenderJob | null): void;
500
+
501
+ // ── the agent drawer ─────────────────────────────────────────────────────
502
+ setAgentOpen(open: boolean): void;
503
+ setAgentModel(model: string | null): void;
504
+ setAgentCredentials(info: AgentCredentialsInfo | null, error?: string): void;
505
+ setAgentRunning(running: boolean): void;
506
+ setAgentError(error: string | null): void;
507
+ setAgentSession(sessionId: string): void;
508
+ setAgentCost(costUsd: number | null): void;
509
+ appendAgentItem(item: AgentTranscriptItem): void;
510
+ patchAgentProposal(id: string, patch: AgentProposalPatch): void;
511
+ clearAgentTranscript(): void;
512
+ /** One scrub for a whole trim or move, exactly like a canvas gesture. Shared
513
+ * by the block bars, the lifespan bars and the scene strip's trim: only one
514
+ * of them can have the pointer, so one lane is all they need. */
515
+ beginBlockDrag(): void;
516
+ dragBlock(
517
+ sceneId: string,
518
+ layerId: string,
519
+ blockId: string,
520
+ edge: "body" | "in" | "out",
521
+ toFrame: number,
522
+ ): void;
523
+ /** A lifespan bar's body, its two edges, or a slip. `delta` is measured from
524
+ * the scrub's BASE, not from the last move. */
525
+ dragLifespan(sceneId: string, layerId: string, mode: LifespanDrag, delta: number): void;
526
+ endBlockDrag(commit: boolean): void;
527
+ trimScene(sceneId: string, durationFrames: number): void;
528
+ reorderScene(sceneId: string, toIndex: number): void;
529
+ /** The blend this shot plays INTO the next one; null clears it. Stored on the
530
+ * outgoing shot, so it travels with the content it was authored against. */
531
+ setSceneTransition(sceneId: string, next: SceneTransition | null): void;
532
+ /** Append a scene to the master timeline and seek into it. The scene strip is
533
+ * the only place scenes are created, reordered and trimmed. */
534
+ addScene(): void;
535
+ /** ⌘B. Splits the focused BLOCK when there is one, else the current SCENE. */
536
+ splitAtPlayhead(): void;
537
+
538
+ // ── blocks ───────────────────────────────────────────────────────────────
539
+ /** Add an In / Out / During preset block to every selected layer. */
540
+ addBlock(slot: BlockSlot, preset: string, durationFrames?: number): void;
541
+ setBlockParams(layerId: string, blockId: string, params: Record<string, Value>): void;
542
+ setBlockEase(layerId: string, blockId: string, ease: AnimBlock["ease"]): void;
543
+ setBlockTiming(layerId: string, blockId: string, frame: number, durationFrames: number): void;
544
+ /** Swap a block's preset IN PLACE, keeping its window and its ease. */
545
+ setBlockPreset(layerId: string, blockId: string, preset: string): void;
546
+ removeBlock(layerId: string, blockId: string): void;
547
+ /** Clear one In/Out preset from the whole selection — the palette's toggle-off. */
548
+ removeBlocksByPreset(slot: BlockSlot, preset: string): void;
549
+ setView(view: ShotView): void;
550
+ fitView(): void;
551
+ /** Zoom the ACTIVE pane one step: the camera view's zoom, or a working-view
552
+ * dolly. ONE verb for the header buttons and for ⌘+/⌘−, so the two cannot
553
+ * route to different panes. */
554
+ zoomActivePane(direction: 1 | -1): void;
555
+ /** Fit the ACTIVE pane — what ⇧Z does, shared with the header readout. */
556
+ fitActivePane(): void;
557
+ /** How many panes the stage shows. Clamps the ratio and keeps `activePane`
558
+ * pointing at a pane that actually exists. */
559
+ setStageLayout(layout: StageLayout): void;
560
+ setWorkingView(view: WorkingView): void;
561
+ fitWorkingView(): void;
562
+ /** Frame the SELECTION in the working view, keeping the author's angles —
563
+ * numpad-dot. Falls back to `fitWorkingView` (frame all) on an empty or
564
+ * extentless selection, so the key never reads dead. It derives from editor
565
+ * state and writes no document of its own; the write it CAN cause is
566
+ * follow mode's, through `setWorkingView`, on the documented rule that
567
+ * follow is follow whatever moved the view. */
568
+ fitWorkingViewToSelection(): void;
569
+ setActivePane(pane: StagePane): void;
570
+
571
+ // ── the camera, authored from the working pane ───────────────────────────
572
+ //
573
+ // All three go through ONE document op (camera.ts's `writeFraming`) and
574
+ // therefore through one three-way fork: base write, refusal, or a move
575
+ // ending at the playhead. What differs is only which framing they ask for.
576
+ /** Arm "camera follows this view". Never called a lock — see types.ts. */
577
+ setCameraFollowsView(on: boolean): void;
578
+ /** The working pane's shading. Workspace state; the camera pane is always
579
+ * the graded render and is not addressable here. */
580
+ setShadingMode(mode: ShadingMode): void;
581
+
582
+ // ── mesh materials ───────────────────────────────────────────────────────
583
+ /** Open the material picker for these mesh layers. Editor state: nothing
584
+ * reaches the document until a card is chosen. */
585
+ openMaterialPicker(layerIds: readonly string[]): void;
586
+ closeMaterialPicker(): void;
587
+ /** Paint a catalog TSL generator over every named mesh layer's albedo, in
588
+ * ONE undo entry. */
589
+ applyMeshMaterial(layerIds: readonly string[], generatorId: string): void;
590
+ /** Back to the model's own materials. */
591
+ removeMeshMaterial(layerIds: readonly string[]): void;
592
+ /** Point the shot camera where the working pane is looking, or refuse when
593
+ * that pose is not expressible as a framing. */
594
+ alignCameraToWorkingView(): void;
595
+ /** Frame the selection, with the same margin the stage's own fit uses. */
596
+ fitCameraToSelection(): void;
597
+ /** Type a framing field. THE SAME SEAM A DRAG USES, so the inspector and the
598
+ * frustum cannot disagree about when a base write is invisible. */
599
+ setSceneCamera(patch: Partial<Framing>): void;
600
+
601
+ // ── one camera move ──────────────────────────────────────────────────────
602
+ //
603
+ // These three write a SHOT and never `scene.camera`, so they are outside the
604
+ // three-way fork above by construction: a shot's `from` and `to` are visible
605
+ // at its own frames whatever the playhead is doing, which is exactly why the
606
+ // panel that edits them can be plain fields.
607
+ setShotFraming(shotId: string, end: "from" | "to", patch: Partial<Framing>): void;
608
+ /** The TWO-POSE kinds only. A path is authored by an agent, through
609
+ * `waypoints`, and converting a move into one from a segmented control
610
+ * would need a waypoint editor this chrome does not have. */
611
+ setShotKind(shotId: string, kind: MoveShot["kind"]): void;
612
+ setShotEase(shotId: string, ease: Ease): void;
613
+ /** ⇧C, and the dock's "+ shot". A move that is STILL at both ends until the
614
+ * author drags the frustum or types a field. `sceneId` is which camera row
615
+ * asked; omitted (the key) means the scene the playhead is in. */
616
+ addShotAtPlayhead(sceneId?: string): void;
617
+ dismissError(): void;
618
+ /** Surface a refusal from a panel. Null clears. Panels do not own the error
619
+ * strip, so this is the one way in. */
620
+ reportError(message: string | null): void;
621
+ }
622
+
623
+ export function createShotEditorStore(): ShotEditorStore {
624
+ let state: ShotEditorState = INITIAL_STATE;
625
+ const listeners = new Set<() => void>();
626
+ let docHistory: History<Doc3D> | null = null;
627
+ let bridge: ShotStageBridge | null = null;
628
+ /** The dock's own fit, registered while it is mounted. */
629
+ let dockFit: ((sceneId?: string) => void) | null = null;
630
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
631
+ /** The stage's working view, mirrored at a trailing 10 Hz while follow is
632
+ * off — see onWorkingViewChange. */
633
+ let pendingWorkingView: WorkingView | null = null;
634
+ let workingViewMirror: ReturnType<typeof setTimeout> | null = null;
635
+ type SnapBox = Parameters<typeof snapMove>[0][number];
636
+ /** The live drag, or null. Holds the history scrub that makes it one entry,
637
+ * and the screen scale of the PANE it started in — see `snapFor`. */
638
+ let gesture: {
639
+ kind: GestureKind;
640
+ layerIds: string[];
641
+ scrub: Scrub<Doc3D>;
642
+ screenScale: number | null;
643
+ /** snapFor's box lists, built once per gesture instead of per pointermove:
644
+ * they derive from the scrub BASE and the selection, both fixed for the
645
+ * whole drag. Keyed on the scene because a playing timeline can cross a
646
+ * scene boundary mid-drag, which must rebuild rather than snap against
647
+ * the previous scene's layers. */
648
+ snap: { forScene: string; moving: SnapBox[]; others: SnapBox[] } | null;
649
+ } | null = null;
650
+ /**
651
+ * The live CAMERA drag, or null. A separate lane from `gesture` because the
652
+ * two write different things through different ops, and only this one can be
653
+ * refused for sitting under a shot.
654
+ *
655
+ * Never both: engine's history allows one scrub at a time and throws on a
656
+ * second, which a pointer already guarantees — but the guards below say so
657
+ * rather than relying on it, because a lost pointerup must not turn the next
658
+ * press into an exception thrown across the bridge.
659
+ */
660
+ let framing: Scrub<Doc3D> | null = null;
661
+ /**
662
+ * The live KEY-LIGHT drag, or null. A third lane for the same reason the
663
+ * second one exists: it writes a different thing (one mesh layer's shading)
664
+ * through a different path, and the three guard each other rather than
665
+ * relying on a pointer to be the only way in.
666
+ */
667
+ let keyLight: { layerId: string; scrub: Scrub<Doc3D> } | null = null;
668
+ /**
669
+ * The idle timer that closes a follow-mode WHEEL dolly's framing scrub.
670
+ *
671
+ * Non-null means this store — not a pointer — opened `framing`, and is the
672
+ * only thing allowed to close it. Every other lane that wants the history
673
+ * flushes it first (`closeWheelFraming`), because engine allows exactly one
674
+ * scrub and a wheel's is open during a window nothing else can see.
675
+ */
676
+ let wheelFraming: ReturnType<typeof setTimeout> | null = null;
677
+ let savePromise: Promise<void> = Promise.resolve();
678
+ /**
679
+ * THE GENERATION FENCE. Every async load carries the generation it started
680
+ * in; a response from an older one is DISCARDED rather than applied. Without
681
+ * it, opening B while A is still loading resolves A last and shows A under
682
+ * B's name — which reads as data loss and is impossible to reproduce.
683
+ */
684
+ let generation = 0;
685
+
686
+ function set(patch: Partial<ShotEditorState>): void {
687
+ let changed = false;
688
+ for (const key of Object.keys(patch) as (keyof ShotEditorState)[]) {
689
+ if (!Object.is(state[key], patch[key])) {
690
+ changed = true;
691
+ break;
692
+ }
693
+ }
694
+ if (!changed) return;
695
+ state = { ...state, ...patch };
696
+ for (const l of listeners) l();
697
+ }
698
+
699
+ function pushDocToStage(doc: Doc3D): void {
700
+ bridge?.loadDoc(doc);
701
+ // ISOLATION RIDES EVERY DOCUMENT PUSH, not only the ones that changed it.
702
+ //
703
+ // The stage validates the id against ITS OWN document (bridgeClient's
704
+ // `sceneWithLayer`), so the push is the only thing that can clear an
705
+ // isolation the new document does not contain. Without it, opening a second
706
+ // shot while inside a card left the stage holding the first document's id:
707
+ // `withDescendants` then answers a set of one id that no layer matches, so
708
+ // the dim keeps NOTHING, every layer fades, and `pickable` filters down to
709
+ // empty. The author gets a uniformly dimmed pane where no click does
710
+ // anything, and the chrome shows no isolation at all to explain it.
711
+ //
712
+ // AFTER `loadDoc`, because the validation reads the document this pushed.
713
+ // `bridgeClient.set` no-ops on `Object.is` equality, so re-pushing an
714
+ // unchanged id costs nothing and this can be unconditional.
715
+ bridge?.setIsolation(state.isolation);
716
+ // AND THE CAMERA MODE BEHIND IT, because `setIsolation` resets the stage's
717
+ // copy to head-on on its own. Without this, a Vite reload — or any document
718
+ // push — would drop an author who had taken the camera straight back to the
719
+ // derived rig, which reads as the view snapping for no reason.
720
+ if (state.isolationOrbit) bridge?.setIsolationOrbit(true);
721
+ }
722
+
723
+ /**
724
+ * The workspace bits the stage cannot know, re-pushed on every attach.
725
+ *
726
+ * DOCUMENT-INDEPENDENT, so unlike the doc/frame/selection re-push this one is
727
+ * NOT gated on a document being open: a fresh stage that connected before any
728
+ * document exists still has to come up in the layout the author left, or the
729
+ * split silently collapses on every Vite reload.
730
+ */
731
+ function pushWorkspaceToStage(): void {
732
+ bridge?.setStageLayout(state.stageLayout);
733
+ bridge?.setWorkingView(state.workingView);
734
+ bridge?.setCameraFollowsView(state.cameraFollowsView);
735
+ bridge?.setShadingMode(state.shadingMode);
736
+ // THE ARMED TOOL IS WORKSPACE STATE TOO, and it was the one piece pushed
737
+ // only from `setTool`. A Vite reload of the stage therefore came back with
738
+ // the select tool while the dock still lit the rect (or the pen), and the
739
+ // author's next drag did something they had not asked for.
740
+ bridge?.setTool(state.tool);
741
+ // NO ISOLATION HERE. It used to ride the workspace push, but isolation
742
+ // names a LAYER, so it is document-scoped and belongs with the document —
743
+ // see `pushDocToStage`. Every path that reaches this function with a
744
+ // document open pushes that document first, so a Vite reload still comes
745
+ // back inside the subtree the author was in.
746
+ }
747
+
748
+ /** Clamp a master frame into the open document. A frame past the end is not
749
+ * an error (a scene got trimmed), it is a frame that has to come back. */
750
+ function clampFrame(doc: Doc3D | null, frame: number): number {
751
+ if (doc === null) return 0;
752
+ const last = Math.max(0, docDurationFramesCached(doc) - 1);
753
+ return Math.max(0, Math.min(last, Math.round(frame)));
754
+ }
755
+
756
+ /**
757
+ * The container a LAYER-TREE edit writes to.
758
+ *
759
+ * A component definition while one is open in the outline, the current shot
760
+ * otherwise. Every op in `layerOps` takes a container id rather than a scene
761
+ * id (see home.ts), so routing the eight tree verbs through this one function
762
+ * is the whole of what makes a definition's interior editable — with the same
763
+ * drag, the same rename, the same flags and the same undo.
764
+ *
765
+ * The PLAYHEAD's scene is deliberately not this: inserting at the playhead,
766
+ * placing a block and splitting are scene-scoped whatever the outline is
767
+ * showing.
768
+ */
769
+ function layerContainer(): string | null {
770
+ return state.editingDef ?? state.sceneId;
771
+ }
772
+
773
+ /** The scene the playhead is in, which is what the inspector and the micro
774
+ * timeline follow unless the author has pinned another one. */
775
+ function sceneAt(doc: Doc3D | null, frame: number): string | null {
776
+ if (doc === null) return null;
777
+ return masterToSceneCached(doc, frame)?.scene.id ?? doc.scenes[0] ?? null;
778
+ }
779
+
780
+ function onHistoryChange(doc: Doc3D): void {
781
+ const frame = clampFrame(doc, state.frame);
782
+ const sceneId = sceneAt(doc, frame);
783
+ // THE ONE INVALIDATION POINT for isolation. Every document write lands
784
+ // here — undo, redo, a delete, an agent edit — so validating the id once
785
+ // in this function covers all of them, and there is no second place that
786
+ // could forget. A subtree whose root is gone leaves the author inside a
787
+ // pane framing nothing, with the surroundings unpickable and no way out.
788
+ //
789
+ // BOTH SCOPES, and the second one was missing. `isolationStillReal` asks
790
+ // whether the layer is anywhere in the document, which is right for a
791
+ // delete; it says nothing about whether it is in the scene now ON SCREEN.
792
+ // A `reorderScene`, `trimScene`, `splitScene` or an undo can move the
793
+ // playhead into a different scene, and the crumb then claimed an isolation
794
+ // the stage refuses to scope to — with `selectAll` scoped to it selecting
795
+ // nothing. `seek` and `setScene` already run this; the history seam is the
796
+ // third door into the same state and had only half the guard.
797
+ const alive = isolationStillReal(doc, state.isolation) ? state.isolation : null;
798
+ // Against the COMMITTED doc, not `state.doc`: this runs before the `set`
799
+ // below, so the store's copy is still the previous document.
800
+ const isolation = alive === null ? null : isolationForScene(sceneId, doc);
801
+ // The same guard for the outline's subject, and for the same reason: a
802
+ // definition can be deleted, undone away or removed by an agent, and an
803
+ // outline scoped to one that is gone shows an empty tree with no way back.
804
+ const editingDef =
805
+ state.editingDef !== null && doc.components?.[state.editingDef] !== undefined
806
+ ? state.editingDef
807
+ : null;
808
+ set({
809
+ doc,
810
+ frame,
811
+ sceneId,
812
+ isolation,
813
+ // An isolation that went stale takes its camera mode with it, or the next
814
+ // subtree would open in a perspective pose derived from a layer that no
815
+ // longer exists.
816
+ ...(isolation === null ? { isolationOrbit: false } : {}),
817
+ editingDef,
818
+ dirty: true,
819
+ canUndo: docHistory?.canUndo ?? false,
820
+ canRedo: docHistory?.canRedo ?? false,
821
+ });
822
+ // `pushDocToStage` carries the isolation, so there is no second push here.
823
+ pushDocToStage(doc);
824
+ bridge?.seek(frame);
825
+ scheduleSave();
826
+ }
827
+
828
+ /** Is an isolated layer still in the document? Its ANCESTORS matter too:
829
+ * deleting a group takes its children with it, and a child id that survives
830
+ * in nothing is exactly the stale case. `layersById` is the whole answer —
831
+ * `removeLayers` drops the subtree, so a live entry means a live layer. */
832
+ function isolationStillReal(doc: Doc3D, id: string | null): boolean {
833
+ if (id === null) return false;
834
+ return Object.values(doc.scenesById).some((s) => s.layersById[id] !== undefined);
835
+ }
836
+
837
+ /**
838
+ * The isolation, dropped when the SCENE ON SCREEN does not hold it.
839
+ *
840
+ * DOCUMENT scope is right for `isolationStillReal` above and SCENE scope is
841
+ * right here, and the two are not in tension: an edit made while the playhead
842
+ * sits in another scene must not drop a valid isolation, but the playhead
843
+ * ARRIVING in another scene means the subtree the author is inside is not on
844
+ * screen any more. The stage already refuses to scope to it (StageApp's
845
+ * `isolated` memo), so leaving the chrome's copy set would put a breadcrumb
846
+ * and an outline scope over a pane showing the whole scene — two truths, and
847
+ * the author's next click would disagree with what the panel says.
848
+ *
849
+ * Scrubbing back does NOT restore it. That is the honest half: an isolation
850
+ * the author cannot see is not one they are in.
851
+ */
852
+ function isolationForScene(sceneId: string | null, doc: Doc3D | null = state.doc): string | null {
853
+ const id = state.isolation;
854
+ if (id === null || sceneId === null) return id;
855
+ return doc?.scenesById[sceneId]?.layersById[id] === undefined ? null : id;
856
+ }
857
+
858
+ /**
859
+ * Re-read the mirrored undo/redo flags.
860
+ *
861
+ * `scrub.commit()` records the undo entry WITHOUT a document notification —
862
+ * the captures already notified, and the commit changes no document, only the
863
+ * stacks. So the flags a capture mirrored say "nothing to undo", and the undo
864
+ * button stays disabled after a drag until some later edit happens to refresh
865
+ * it. Both other chromes call this at exactly the same place, with the same
866
+ * reason written down.
867
+ */
868
+ function syncHistoryFlags(): void {
869
+ set({ canUndo: docHistory?.canUndo ?? false, canRedo: docHistory?.canRedo ?? false });
870
+ }
871
+
872
+ /**
873
+ * A pointer already holds the history. Say so and refuse.
874
+ *
875
+ * THE SEAM, not a fourteenth per-keymap-arm guard. `writeCamera` has carried
876
+ * this shape since the camera lane landed; promoting it to `edit`, `undo` and
877
+ * `redo` is what makes every OTHER caller safe at once, because all of them
878
+ * route through one of those three.
879
+ *
880
+ * Reachable from the stage's own key relay: a keystroke during a drag is
881
+ * ordinary (⌘Z, Delete, an arrow step), and without this it threw out of the
882
+ * stage's keydown handler, across the bridge, mid-gesture.
883
+ */
884
+ function historyBusy(): boolean {
885
+ if (gesture === null && framing === null) return false;
886
+ set({ error: HISTORY_BUSY });
887
+ return true;
888
+ }
889
+
890
+ /**
891
+ * Say what an edit refused, WITHOUT overwriting a refusal to run it at all.
892
+ *
893
+ * Nine actions follow `store.edit` with their own `set({ error: refusal })`,
894
+ * and every one of them was wiping `HISTORY_BUSY`: a refused edit never runs
895
+ * its body, so `refusal` is still the `null` it was initialised to, and the
896
+ * `set` replaced the message the guard had just written. The keystroke did
897
+ * nothing and said nothing.
898
+ *
899
+ * `ran` comes straight from `edit`'s return, so the two facts cannot drift.
900
+ */
901
+ function sayRefusal(ran: boolean, refusal: string | null): void {
902
+ if (ran) set({ error: refusal });
903
+ }
904
+
905
+ /**
906
+ * Is there no working pane to aim a view key at? If so, SAY SO.
907
+ *
908
+ * Six keys — the numpad presets, ⇧C's fit, the orbit steps and numpad-5's
909
+ * ortho — all return `true` from `handleKey` (so the browser's own binding is
910
+ * suppressed) and then did nothing at all in the camera-only layout. That
911
+ * layout is the INITIAL state, so a fresh session's numpad was dead and
912
+ * silent, which is the worst version: the author cannot tell a key that is
913
+ * not bound from one that is refusing.
914
+ *
915
+ * They are guarded rather than allowed because with follow armed
916
+ * `setWorkingView` WRITES the shot camera, and camera-only layout has neither
917
+ * of follow's indicators on screen — so the key would rewrite and autosave
918
+ * the camera with nothing to say it had.
919
+ */
920
+ function noWorkingPane(): boolean {
921
+ if (state.stageLayout.mode !== "camera") return false;
922
+ set({
923
+ error:
924
+ `that key aims the working view, and this layout shows the camera only. ` +
925
+ `Press 1 for the working view or 2 for the split.`,
926
+ });
927
+ return true;
928
+ }
929
+
930
+ function scheduleSave(): void {
931
+ if (saveTimer !== null) clearTimeout(saveTimer);
932
+ saveTimer = setTimeout(() => {
933
+ saveTimer = null;
934
+ void runSave();
935
+ }, AUTOSAVE_MS);
936
+ }
937
+
938
+ function runSave(): Promise<void> {
939
+ const name = state.name;
940
+ const doc = state.doc;
941
+ if (name === null || doc === null) return Promise.resolve();
942
+ const gen = generation;
943
+ set({ saveState: "saving" });
944
+ // Serialised behind the previous save: two overlapping PUTs would race on
945
+ // `rev` and the second would 409 against a baseline the first just moved.
946
+ savePromise = savePromise.then(async () => {
947
+ if (gen !== generation) return;
948
+ try {
949
+ const result = await api.saveShot(name, doc, state.rev);
950
+ if (gen !== generation) return;
951
+ set({
952
+ rev: result.rev,
953
+ saveState: "saved",
954
+ // Only clean if nothing landed WHILE the save was in flight.
955
+ dirty: state.doc !== doc,
956
+ error: null,
957
+ emitWarning: result.warning ?? null,
958
+ });
959
+ if (state.doc !== doc) scheduleSave();
960
+ } catch (e) {
961
+ if (gen !== generation) return;
962
+ const message = e instanceof Error ? e.message : String(e);
963
+ set({
964
+ saveState: "error",
965
+ error:
966
+ e instanceof ApiError && e.conflict
967
+ ? `${message} (your edits are still here — copy anything you need before reloading)`
968
+ : message,
969
+ });
970
+ }
971
+ });
972
+ return savePromise;
973
+ }
974
+
975
+ /**
976
+ * Pull a move onto the frame's and the other layers' edges and centres.
977
+ *
978
+ * MOVES ONLY. A resize snapping its handle sounds appealing and is a
979
+ * different computation (the moving edge is one edge, not the box), and a
980
+ * tilt has nothing to snap to; doing them badly here would be worse than not
981
+ * doing them.
982
+ *
983
+ * `zoom` IS THE PANE'S OWN, not the camera pane's. `grabAt` returns a move in
984
+ * the working pane too, and that pane has no zoom — it has an orbit distance,
985
+ * and its screen scale is the perspective quantity the stage sends at gesture
986
+ * start. Dividing a SCREEN threshold by a number belonging to a pane that may
987
+ * not even be on screen is how a working-only drag ends up snapping from 60
988
+ * design px away, or not at all.
989
+ *
990
+ * Named ceiling: the guides come back in DESIGN frame coordinates and the
991
+ * overlay can only draw them in the camera pane, because an axis-aligned
992
+ * design line is not an axis-aligned screen line under a free orbit. A
993
+ * working-only layout therefore snaps at the right distance and draws no
994
+ * guide; showing them there needs projected segments, not a second threshold.
995
+ */
996
+ function snapFor(
997
+ base: Doc3D,
998
+ sceneId: string,
999
+ layerIds: readonly string[],
1000
+ delta: GestureDelta,
1001
+ zoom: number,
1002
+ ): { delta: GestureDelta; guides: SnapGuide[] } {
1003
+ if (delta.kind !== "move") return { delta, guides: [] };
1004
+ const scene = base.scenesById[sceneId];
1005
+ if (scene === undefined) return { delta, guides: [] };
1006
+ // The box lists derive from the scrub BASE and the selection, both fixed
1007
+ // for the whole drag — so they are built once per gesture and cached on
1008
+ // it, not rebuilt per pointermove. `worldBaseTransform` walks the layer's
1009
+ // ancestor chain, and running that for every layer in the scene on every
1010
+ // move was the single biggest per-move cost in this lane.
1011
+ let boxes = gesture?.snap ?? null;
1012
+ if (boxes === null || boxes.forScene !== sceneId) {
1013
+ const selected = new Set(layerIds);
1014
+ const moving: SnapBox[] = [];
1015
+ const others: SnapBox[] = [];
1016
+ for (const layer of Object.values(scene.layersById)) {
1017
+ // WORLD positions, not stored ones. A nested layer's
1018
+ // `transform.position` is local to its parent, so snapping a root
1019
+ // layer's edge against a grouped layer's stored x compares two
1020
+ // different frames and lands the guide nowhere near the thing it
1021
+ // claims to align to. The gesture delta is world-space too (the stage
1022
+ // unprojected a pointer onto a plane), so the whole comparison happens
1023
+ // in one frame.
1024
+ const world = worldBaseTransform(scene, layer.id).position;
1025
+ const box = {
1026
+ position: [world[0], -world[1], -world[2]] as [number, number, number],
1027
+ size: layer.transform.size,
1028
+ };
1029
+ if (selected.has(layer.id)) moving.push(box);
1030
+ else others.push(box);
1031
+ }
1032
+ boxes = { forScene: sceneId, moving, others };
1033
+ if (gesture !== null) gesture.snap = boxes;
1034
+ }
1035
+ const snapped = snapMove(boxes.moving, boxes.others, base.size, delta, SNAP_THRESHOLD_PX / Math.max(zoom, 1e-6));
1036
+ return {
1037
+ delta: { ...delta, dx: snapped.dx, dy: snapped.dy },
1038
+ guides: snapped.guides,
1039
+ };
1040
+ }
1041
+
1042
+ /**
1043
+ * THE ONE SEAM EVERY CAMERA AUTHOR GOES THROUGH.
1044
+ *
1045
+ * `writeFraming` owns the three-way fork (base write / refusal / a move
1046
+ * ending at the playhead) so that a frustum drag, an orbit under follow, an
1047
+ * align and a fit cannot disagree about what animate mode means or about when
1048
+ * a write is invisible. All this adds is where the entry lands: inside the
1049
+ * CAMERA's open scrub for a drag, or its own transaction for a button.
1050
+ *
1051
+ * ── WHICH LANE OWNS THE HISTORY ─────────────────────────────────────────
1052
+ *
1053
+ * Engine allows one scrub at a time, and the two lanes here share one
1054
+ * `History`. So "no camera scrub open" does NOT mean "free to open a
1055
+ * transaction": a layer drag or a timeline block drag holds `gesture` across
1056
+ * a whole pointer hold, and `transaction()` throws while any scrub is live.
1057
+ * ⌥C during a drag, or a wheel dolly under follow during one, used to throw
1058
+ * that out of the stage's own event handler.
1059
+ *
1060
+ * Folding the write into the OTHER lane's scrub is not the fix either:
1061
+ * `capture` always re-derives from ITS base, so a camera write would wipe the
1062
+ * transform in progress until the next pointermove re-captured it. A lane
1063
+ * already open wins, and the camera says so — the same rule
1064
+ * `onFramingGestureStart` states, and the same refusal-over-corruption answer
1065
+ * every other fork in this file gives.
1066
+ */
1067
+ function writeCamera(next: (current: Framing) => Framing): void {
1068
+ const sceneId = state.sceneId;
1069
+ const doc = state.doc;
1070
+ if (sceneId === null || doc === null) return;
1071
+ if (framing === null && gesture !== null) {
1072
+ set({ error: HISTORY_BUSY });
1073
+ return;
1074
+ }
1075
+ const sceneFrame = masterToSceneCached(doc, state.frame)?.sceneFrame ?? 0;
1076
+ let refusal: string | null = null;
1077
+ const apply = (base: Doc3D): Doc3D => {
1078
+ const out = writeFraming(base, sceneId, sceneFrame, base.fps, state.animateMode, next);
1079
+ refusal = out.refusal;
1080
+ return out.doc;
1081
+ };
1082
+ if (framing === null) store.edit(apply);
1083
+ else framing.capture(apply);
1084
+ // Shown, never swallowed: a camera drag that silently did nothing is the
1085
+ // two-truths failure this whole lane refuses by name to avoid.
1086
+ set({ error: refusal });
1087
+ }
1088
+
1089
+ /** Project a working-pane pose onto the framing model and write it. Shared by
1090
+ * the align button and by follow mode, which differ only in what triggers
1091
+ * them and whether a scrub happens to be open. */
1092
+ function writeFramingFromView(view: WorkingView): void {
1093
+ const doc = state.doc;
1094
+ if (doc === null) return;
1095
+ // THE DERIVED RIG REPLACED THE POSE THIS PROJECTS. On entering a subtree
1096
+ // the working pane draws an orthographic head-on rig (isolation.ts), so
1097
+ // `state.workingView` is a pose with no reader and no pixels — projecting
1098
+ // it onto the shot camera writes a framing from a viewpoint nobody is
1099
+ // looking at, dirties the document and autosaves it. Refused for BOTH
1100
+ // callers here rather than in each: follow mode, which would do it silently
1101
+ // per gesture, and ⌥C, which is an author asking for it and gets told why.
1102
+ //
1103
+ // RE-KEYED, NOT DELETED, when isolation learned to hand the camera over:
1104
+ // once `isolationOrbit` is true the orbit rig is genuinely drawing the pane
1105
+ // and a framing write is legal again, so the refusal turns off with the
1106
+ // derived rig rather than with the mode.
1107
+ if (state.isolation !== null && !state.isolationOrbit) {
1108
+ set({
1109
+ error:
1110
+ "The working pane is showing this subtree head-on rather than a viewpoint the shot " +
1111
+ "camera could take — orbit the pane to take the camera, or leave isolation (Esc), " +
1112
+ "then align.",
1113
+ });
1114
+ return;
1115
+ }
1116
+ const projected = framingFromWorkingView(view, doc.size, WORKING_FOV_DEG);
1117
+ if (!projected.ok) {
1118
+ set({ error: projected.refusal });
1119
+ return;
1120
+ }
1121
+ // ROLL AND FOCUS ARE KEPT. The working camera cannot express either, so
1122
+ // taking them from the view would zero an authored roll and pull focus to
1123
+ // the canvas every time the author aligned.
1124
+ writeCamera((current) => ({
1125
+ ...current,
1126
+ x: projected.x,
1127
+ y: projected.y,
1128
+ zoom: projected.zoom,
1129
+ }));
1130
+ }
1131
+
1132
+ /**
1133
+ * Give the wheel the bracket it cannot give itself: the first notch opens the
1134
+ * framing scrub, and `WHEEL_IDLE_MS` of silence commits it.
1135
+ *
1136
+ * Only when nothing else holds the history. A wheel turned mid-drag folds
1137
+ * into that drag's scrub instead, which is one undo entry either way and does
1138
+ * not leave this store closing a scrub it never opened.
1139
+ */
1140
+ function armWheelFraming(): void {
1141
+ if (wheelFraming !== null) {
1142
+ clearTimeout(wheelFraming);
1143
+ } else {
1144
+ if (gesture !== null || framing !== null) return;
1145
+ stageHandlers.onFramingGestureStart();
1146
+ if (framing === null) return; // no document open: nothing was started
1147
+ }
1148
+ wheelFraming = setTimeout(closeWheelFraming, WHEEL_IDLE_MS);
1149
+ }
1150
+
1151
+ /** Close a wheel's scrub NOW. Called by the idle timer, and by every other
1152
+ * lane before it takes the history — engine allows exactly one scrub, and a
1153
+ * wheel's is the only one open while no pointer is down. */
1154
+ function closeWheelFraming(): void {
1155
+ if (wheelFraming === null) return;
1156
+ clearTimeout(wheelFraming);
1157
+ wheelFraming = null;
1158
+ stageHandlers.onFramingGestureEnd(true);
1159
+ }
1160
+
1161
+ // ── the insertion lane ────────────────────────────────────────────────────
1162
+ //
1163
+ // Two shapes, and the split is the ASSET. Without one the layer is built from
1164
+ // the open document and inserted; with one, the asset registration and the
1165
+ // insert have to be the same transaction — an undo that removed the layer and
1166
+ // left an orphan asset behind, or removed the asset and left a layer pointing
1167
+ // at nothing, is a document `validateShotDoc` refuses to save, produced BY
1168
+ // THE UNDO BUTTON. Same reasoning `importAudio` carries.
1169
+
1170
+ /**
1171
+ * Where the playhead is and how long its scene is: the two numbers every
1172
+ * insert needs to land in TIME. Null when nothing can be inserted.
1173
+ *
1174
+ * BOTH come from the SAME `masterToScene` call, and neither from
1175
+ * `state.sceneId`. The pin and the playhead can diverge (see `focusScene`),
1176
+ * and a scene taken from one with a frame taken from the other writes a
1177
+ * lifetime measured on a foreign clock into a scene the stage is not showing
1178
+ * — the layer appears nowhere, and `onHistoryChange` snapping the pin back to
1179
+ * the playhead's scene then makes the follow-up `select` filter it out too.
1180
+ * The playhead is what the stage renders and what every other create in this
1181
+ * chrome lands on, so it is the one source.
1182
+ *
1183
+ * `state.sceneId` is the fallback for a playhead that is in no scene at all
1184
+ * (a zero-length tail), where landing in the pinned scene beats refusing.
1185
+ */
1186
+ function insertContext(): {
1187
+ sceneId: string;
1188
+ scene: Scene3D;
1189
+ ctx: InsertContext;
1190
+ parent?: string;
1191
+ } | null {
1192
+ const doc = state.doc;
1193
+ if (doc === null) return null;
1194
+ const at = masterToSceneCached(doc, state.frame);
1195
+ const sceneId = at?.sceneId ?? state.sceneId;
1196
+ const scene = sceneId === null ? undefined : doc.scenesById[sceneId];
1197
+ if (sceneId === null || scene === undefined) return null;
1198
+ // ── WHAT IS DRAWN INSIDE A CARD BELONGS TO THE CARD ──────────────────
1199
+ //
1200
+ // Everything outside the isolated subtree is dimmed and unpickable, so an
1201
+ // insert that landed beside it appeared to go nowhere: it drew at the
1202
+ // scene root, dimmed, un-clickable, with the outline showing nothing new.
1203
+ //
1204
+ // ONLY WHEN THE ISOLATED LAYER CAN HOLD CHILDREN. `enterIsolation`
1205
+ // validates that the layer exists and nothing else, so a plain rect can be
1206
+ // the subject — and handing one to `addLayer` as a parent throws out of
1207
+ // `withChildren`. Falling back to a root insert is the honest answer: the
1208
+ // author gets the layer, in the scene, where the outline can find it.
1209
+ const isolated = state.isolation === null ? undefined : scene.layersById[state.isolation];
1210
+ const parent = isolated !== undefined && canHoldChildren(isolated) ? isolated.id : undefined;
1211
+ return {
1212
+ sceneId,
1213
+ scene,
1214
+ ctx: {
1215
+ sceneFrame: at?.sceneId === sceneId ? at.sceneFrame : 0,
1216
+ durationFrames: scene.durationFrames,
1217
+ },
1218
+ ...(parent === undefined ? {} : { parent }),
1219
+ };
1220
+ }
1221
+
1222
+ /**
1223
+ * Insert a built layer, and select it.
1224
+ *
1225
+ * The id is MINTED HERE rather than read back out of the new document:
1226
+ * `addLayer` derives it from the same `usedIdsOf`, and knowing it up front is
1227
+ * what lets the insert select what it just made without diffing two layer
1228
+ * maps to find out which one is new.
1229
+ */
1230
+ function insertLayer(layer: NewLayer3D): void {
1231
+ const at = insertContext();
1232
+ const doc = state.doc;
1233
+ if (at === null || doc === null) return;
1234
+ const id = freshId(usedIdsOf(doc), LAYER_ID_PREFIX, layer.kind);
1235
+ store.edit((base) => addLayerAt(base, at, { ...layer, id }));
1236
+ select([id]);
1237
+ }
1238
+
1239
+ /**
1240
+ * `addLayer`, with the isolation parent and the rebase applied.
1241
+ *
1242
+ * ONE FUNCTION, because there are TWO insert paths and only one of them had
1243
+ * this: `insertAssetLayer` calls the same `insertContext()` and used to
1244
+ * discard its `parent`, so inside a subtree a drawn rect became a child while
1245
+ * an imported image or `.glb` landed at the scene root — dimmed, unpickable,
1246
+ * and invisible in an outline scoped to the card.
1247
+ */
1248
+ function addLayerAt(
1249
+ base: Doc3D,
1250
+ at: { sceneId: string; parent?: string },
1251
+ layer: NewLayer3D & { id: string },
1252
+ ): Doc3D {
1253
+ return addLayer(base, {
1254
+ sceneId: at.sceneId,
1255
+ layer: { ...layer, transform: rebasedForParent(base, at.sceneId, at.parent, layer.transform) },
1256
+ ...(at.parent === undefined ? {} : { parent: at.parent }),
1257
+ });
1258
+ }
1259
+
1260
+ /**
1261
+ * A transform authored in SCENE coordinates, expressed in a parent's frame.
1262
+ *
1263
+ * Every builder in the insert lane works in design coordinates: the draw
1264
+ * tools hand back a box in the scene's own space, and so does the device
1265
+ * preset. But `transform.position` is local to the layer's PARENT, so
1266
+ * spreading `parent` without this puts the drawn rectangle at the group's own
1267
+ * offset and roll — visibly somewhere the author did not draw it, and further
1268
+ * off the deeper or the more rotated the group is.
1269
+ *
1270
+ * Identity at the root, which is why every existing insert is byte-identical.
1271
+ */
1272
+ function rebasedForParent(
1273
+ doc: Doc3D,
1274
+ sceneId: string,
1275
+ parent: string | undefined,
1276
+ transform: NewLayer3D["transform"],
1277
+ ): NewLayer3D["transform"] {
1278
+ const scene = doc.scenesById[sceneId];
1279
+ if (parent === undefined || scene === undefined) return transform;
1280
+ // `toLocal` answers position / tilt / roll only: size and opacity are
1281
+ // untouched by nesting, so the caller's own survive the spread.
1282
+ return { ...transform, ...toLocal(parentBaseTransform(scene, parent), rigidOf(transform)) };
1283
+ }
1284
+
1285
+ /** …and the same thing for a layer that names an uploaded asset, in ONE
1286
+ * transaction. The asset is deduped by PATH: the store is content-addressed,
1287
+ * so a re-import of identical bytes must not mint a second `ShotAsset` the
1288
+ * exporter would then fetch twice. */
1289
+ function insertAssetLayer(
1290
+ uploaded: api.UploadedAsset,
1291
+ filename: string,
1292
+ kind: ShotAssetKind,
1293
+ build: (assetId: string, ctx: InsertContext) => NewLayer3D | null,
1294
+ ): void {
1295
+ const at = insertContext();
1296
+ if (at === null) return;
1297
+ let created: string | null = null;
1298
+ store.edit((base) => {
1299
+ const used = usedIdsOf(base);
1300
+ const existing = Object.values(base.assets).find((a) => a.path === uploaded.path);
1301
+ const asset = existing ?? {
1302
+ id: freshId(used, ASSET_ID_PREFIX, slug(filename.replace(/\.[^.]*$/, ""))),
1303
+ path: uploaded.path,
1304
+ hash: uploaded.hash,
1305
+ kind,
1306
+ };
1307
+ const layer = build(asset.id, at.ctx);
1308
+ if (layer === null) return base;
1309
+ used.add(asset.id);
1310
+ created = freshId(used, LAYER_ID_PREFIX, layer.kind);
1311
+ const withAsset =
1312
+ existing === undefined ? { ...base, assets: { ...base.assets, [asset.id]: asset } } : base;
1313
+ // Through the SAME seam `insertLayer` uses, so an imported image and a
1314
+ // drawn rect land in the same place inside a subtree.
1315
+ return addLayerAt(withAsset, at, { ...layer, id: created });
1316
+ });
1317
+ if (created !== null) select([created]);
1318
+ }
1319
+
1320
+ /** One instance block edited in place, as one transaction. Layer-addressed
1321
+ * rather than selection-addressed: an instance's props belong to ONE
1322
+ * component's contract, so fanning a prop write across a mixed selection
1323
+ * would write keys the other layers' components never had. */
1324
+ function patchInstance(
1325
+ layerId: string,
1326
+ next: (inst: InstanceLayer3D["instance"]) => InstanceLayer3D["instance"],
1327
+ ): void {
1328
+ const sceneId = state.sceneId;
1329
+ if (sceneId === null) return;
1330
+ store.edit((doc) => {
1331
+ const scene = doc.scenesById[sceneId];
1332
+ const layer = scene?.layersById[layerId];
1333
+ if (scene === undefined || layer === undefined || layer.kind !== "instance") return doc;
1334
+ const instance = next(layer.instance);
1335
+ if (instance === layer.instance) return doc;
1336
+ return {
1337
+ ...doc,
1338
+ scenesById: {
1339
+ ...doc.scenesById,
1340
+ [sceneId]: {
1341
+ ...scene,
1342
+ layersById: { ...scene.layersById, [layerId]: { ...layer, instance } },
1343
+ },
1344
+ },
1345
+ };
1346
+ });
1347
+ }
1348
+
1349
+ /**
1350
+ * The contract binding recorded on an instance layer, from a registry detail.
1351
+ *
1352
+ * `instanceContractRefFor` owns the import-vs-snapshot decision, and it is
1353
+ * the SAME function the 2D lane's embeds go through — a chrome-side
1354
+ * re-derivation of that rule is what emitted unresolvable imports for
1355
+ * builtins in that lane.
1356
+ */
1357
+ function bindingFor(detail: api.ComponentDetail): {
1358
+ contractHash: string;
1359
+ contract: InstanceContractRef;
1360
+ } {
1361
+ return {
1362
+ contractHash: detail.contract.contractHash,
1363
+ contract: instanceContractRefFor({
1364
+ availability: detail.contractAvailability ?? "inferred",
1365
+ contract: detail.contract,
1366
+ }),
1367
+ };
1368
+ }
1369
+
1370
+ /**
1371
+ * Upload a file, refuse it if it is none of the kinds the caller takes, and
1372
+ * report WHICH one it turned out to be.
1373
+ *
1374
+ * A LIST rather than one kind, because the Frame picker takes either media
1375
+ * kind and the caller has to fork on the answer. The kind is the SERVER's:
1376
+ * it comes off the stored filename's extension, which is the same answer the
1377
+ * save gate and the exporter will get, and a browser-side MIME guess that
1378
+ * disagreed would mint a layer kind the document cannot back.
1379
+ *
1380
+ * THE GENERATION FENCE, for the reason every async load in this file carries
1381
+ * one: the author can switch documents while an upload is in flight, and a
1382
+ * layer written into whichever shot happens to be open when it lands is a
1383
+ * layer in the wrong film. The bytes are still in the store, so re-importing
1384
+ * is the whole recovery.
1385
+ */
1386
+ async function uploadOfKind<K extends ShotAssetKind>(
1387
+ file: File,
1388
+ kinds: readonly K[],
1389
+ gen: number,
1390
+ ): Promise<{ uploaded: api.UploadedAsset; kind: K }> {
1391
+ const uploaded = await api.uploadAsset(file);
1392
+ if (gen !== generation) throw new Error(`${file.name} was imported into a scene that has closed`);
1393
+ const kind = uploaded.kind;
1394
+ // The wire's `kind` is a plain string — the server's extension map knows
1395
+ // kinds this union does not (hdri) — so the membership test IS the
1396
+ // narrowing, and the cast below is exactly what it proved.
1397
+ if (kind === undefined || !(kinds as readonly string[]).includes(kind)) {
1398
+ throw new Error(
1399
+ `${file.name} is not ${kindsPhrase(kinds)} ` +
1400
+ `(the store saw it as ${uploaded.kind ?? "an unknown kind"})`,
1401
+ );
1402
+ }
1403
+ return { uploaded, kind: kind as K };
1404
+ }
1405
+
1406
+ /** A draw gesture that needs no asset. Null from the builder — a pen with
1407
+ * nothing in it — inserts nothing rather than a layer the author would have
1408
+ * to find and delete. */
1409
+ function insertDrawn(tool: InsertTool, points: readonly DrawPoint[]): void {
1410
+ const at = insertContext();
1411
+ if (at === null) return;
1412
+ const layer = layerFromDraw(tool, points, at.ctx);
1413
+ if (layer !== null) insertLayer(layer);
1414
+ // BACK TO SELECT, whether or not anything landed. The armed tool is a mode,
1415
+ // and leaving the author in it after a gesture that produced nothing is how
1416
+ // the next click draws a second thing they did not ask for.
1417
+ store.setTool("select");
1418
+ }
1419
+
1420
+ /**
1421
+ * The container a click on this layer should select: its outermost ancestor,
1422
+ * stopping at the ISOLATION boundary.
1423
+ *
1424
+ * The boundary is what makes entering a subtree mean anything. Without it,
1425
+ * clicking a child inside an isolated card would walk all the way back up to
1426
+ * the card the author had just entered, and going in would change nothing.
1427
+ */
1428
+ function outermost(id: string): string {
1429
+ const sceneId = state.sceneId;
1430
+ const scene = sceneId === null ? undefined : state.doc?.scenesById[sceneId];
1431
+ return scene === undefined ? id : outermostIn(scene, id, state.isolation);
1432
+ }
1433
+
1434
+ const stageHandlers: ShotBridgeHandlers = {
1435
+ onReady(info: ShotStageInfo) {
1436
+ set({ stageInfo: info, stageError: null });
1437
+ // The stage just (re)mounted — a Vite reload remints it — so re-push
1438
+ // everything it cannot know. Order matters: doc, then frame, then
1439
+ // selection, because the later two address into the former.
1440
+ if (state.doc !== null) {
1441
+ pushDocToStage(state.doc);
1442
+ bridge?.seek(state.frame);
1443
+ bridge?.setSelection(state.selection);
1444
+ }
1445
+ pushWorkspaceToStage();
1446
+ },
1447
+ onError(message: string) {
1448
+ set({ stageError: message });
1449
+ },
1450
+ onPick(hits: readonly string[], modifiers: PickModifiers, pane: StagePane) {
1451
+ // A pick IS an activation, so it lands here as well as through
1452
+ // `onActivePane`. Both routes exist because a wheel or an orbit activates
1453
+ // a pane without changing the selection, and a marquee changes the
1454
+ // selection from a pointerdown that already reported its pane.
1455
+ set({ activePane: pane });
1456
+ // THE CHROME OWNS THE POLICY (see bridgeTypes). Plain click takes the
1457
+ // front-most hit; shift extends; meta walks BEHIND the current selection,
1458
+ // which is the whole reason the stage sends the ordered list.
1459
+ if (hits.length === 0) {
1460
+ if (modifiers.shift) return;
1461
+ // ── CLICKING AWAY LEAVES THE SUBTREE ────────────────────────────
1462
+ //
1463
+ // Isolation scopes picking to the subtree, so everything outside it
1464
+ // already answers an empty hit list — which means "click outside to
1465
+ // leave" needs no second gesture, only this branch. `StageTools` has
1466
+ // claimed for a while that it already worked; now it does.
1467
+ //
1468
+ // NOT FOR AN EMPTY MARQUEE. A rubber band drawn inside a card that
1469
+ // caught nothing is not a request to leave the card, and routing both
1470
+ // through this handler is why the flag exists.
1471
+ if (state.isolation !== null && modifiers.marquee !== true) {
1472
+ store.exitIsolation();
1473
+ // `exitIsolation` selects the boundary layer so the author can see
1474
+ // where they came out of; an explicit click on nothing means nothing
1475
+ // selected, so the clear has to follow it.
1476
+ select([]);
1477
+ return;
1478
+ }
1479
+ select([]);
1480
+ return;
1481
+ }
1482
+ // ── A MARQUEE IS A SET, NOT A HIT STACK ─────────────────────────────
1483
+ //
1484
+ // Every enclosed layer is selected, resolved through the same
1485
+ // `outermost` walk a click uses so box-selecting across a group's
1486
+ // children selects the group — which is what Figma does and what the
1487
+ // outline expects. `select` dedupes and document-orders, so the
1488
+ // many-to-one collapse comes out right for free.
1489
+ //
1490
+ // SHIFT APPENDS rather than toggling: reusing the click path's toggle
1491
+ // would DESELECT anything the box happened to re-touch, which is the
1492
+ // opposite of what dragging a bigger box means.
1493
+ if (modifiers.marquee === true) {
1494
+ const inBox = hits.map(outermost);
1495
+ select(modifiers.shift ? [...state.selection, ...inBox] : inBox);
1496
+ return;
1497
+ }
1498
+ // A CLICK SELECTS THE OUTERMOST CONTAINER, not the leaf under the
1499
+ // cursor. That is what makes a group a thing rather than a label: click a
1500
+ // card, get the card; go inside it (Enter, or double-click) and the same
1501
+ // click gets the child. Meta-click still walks the raw hit list, which is
1502
+ // the escape hatch for reaching a leaf without entering anything.
1503
+ const front = outermost(hits[0]!);
1504
+ if (modifiers.meta) {
1505
+ const at = hits.indexOf(state.selection[0] ?? "");
1506
+ select([hits[(at + 1) % hits.length]!]);
1507
+ return;
1508
+ }
1509
+ if (modifiers.shift) {
1510
+ select(
1511
+ state.selection.includes(front)
1512
+ ? state.selection.filter((id) => id !== front)
1513
+ : [...state.selection, front],
1514
+ );
1515
+ return;
1516
+ }
1517
+ select([front]);
1518
+ },
1519
+ onEnterLayer(hits: readonly string[], pane: StagePane) {
1520
+ set({ activePane: pane });
1521
+ // The same layer a single click would have selected — its outermost
1522
+ // container, stopping at the boundary the author is already inside. So
1523
+ // click-then-double-click walks INTO the thing that was highlighted,
1524
+ // rather than jumping to whichever leaf happened to be under the pixel.
1525
+ const first = hits[0];
1526
+ if (first === undefined) return;
1527
+ store.enterIsolation(outermost(first));
1528
+ },
1529
+ onIsolationOrbitTaken() {
1530
+ // The author navigated the working pane while the derived head-on rig
1531
+ // was drawing it. From here the ordinary orbit rig owns the pane, so the
1532
+ // framing refusal turns off and the nav gizmo becomes meaningful again.
1533
+ // Pushed straight back down: the stage reads the bit to pick a camera.
1534
+ if (state.isolation === null || state.isolationOrbit) return;
1535
+ set({ isolationOrbit: true });
1536
+ bridge?.setIsolationOrbit(true);
1537
+ },
1538
+ onViewChange(view: ShotView) {
1539
+ set({ view });
1540
+ },
1541
+ onWorkingViewChange(workingView: WorkingView, origin: WorkingViewOrigin = "gesture") {
1542
+ // THE MIRROR IS TRAILING-THROTTLED while follow is off. This fires per
1543
+ // pointermove of every orbit/pan/dolly, no chrome component subscribes
1544
+ // to `workingView` live (it exists for persistence and for the keyboard
1545
+ // zoom's read), and every `set` is a full listener sweep — a few hundred
1546
+ // selector evaluations with the inspector open, buying zero renders.
1547
+ // Under follow the write-through below still runs per event, because the
1548
+ // camera write is the payload there, not the mirror.
1549
+ if (origin === "fit" || !state.cameraFollowsView) {
1550
+ pendingWorkingView = workingView;
1551
+ workingViewMirror ??= setTimeout(() => {
1552
+ workingViewMirror = null;
1553
+ if (pendingWorkingView !== null) set({ workingView: pendingWorkingView });
1554
+ pendingWorkingView = null;
1555
+ }, WORKING_VIEW_MIRROR_MS);
1556
+ if (origin === "fit") return;
1557
+ } else {
1558
+ set({ workingView });
1559
+ }
1560
+ // A FIT is the stage mirroring a view it derived ITSELF — on a document
1561
+ // open, or on an iframe reload. Nobody moved anything, so following it
1562
+ // would rewrite the camera of every shot merely opened while follow
1563
+ // happens to be armed (it is a workspace pref and crosses a switch),
1564
+ // dirty it, and autosave it with no author input.
1565
+ if (!state.cameraFollowsView) return;
1566
+ // FOLLOW MODE. The view change IS the payload — there is no delta to send
1567
+ // — so the write happens here, inside whatever scrub brackets the
1568
+ // gesture. An orbit or a pan is bracketed by the stage's pointer lane; a
1569
+ // wheel has no release, so it gets one from silence instead.
1570
+ if (origin === "wheel") armWheelFraming();
1571
+ writeFramingFromView(workingView);
1572
+ },
1573
+ onActivePane(activePane: StagePane) {
1574
+ // …and CLOSE the stage menu. This is the seam every stage press and
1575
+ // wheel already crosses, and it has to be the one: the iframe is a
1576
+ // different document, so a pointerdown inside it never reaches the
1577
+ // chrome window's own outside-click listener. For a right press the
1578
+ // order is this handler then `onContextMenu`, and React batches both, so
1579
+ // the effect sees the newly opened menu rather than the cleared one.
1580
+ if (state.stageMenu !== null) set({ activePane, stageMenu: null });
1581
+ else set({ activePane });
1582
+ },
1583
+ onDraw(tool, points) {
1584
+ if (state.doc === null || state.sceneId === null) return;
1585
+ if (tool === "image") {
1586
+ // THE BOX WAITS FOR THE FILE. A file dialog only opens from a click on
1587
+ // a real `<input>`, which this is not, so the geometry parks in state
1588
+ // and the toolbar's hidden input finishes the job. Copied out because
1589
+ // the array belongs to the stage's own gesture, which is over.
1590
+ set({ imagePick: points.map((p): DrawPoint => [p[0], p[1]]) });
1591
+ // …and the tool is spent either way: the pick can be cancelled, and an
1592
+ // author left holding the image tool would draw a second box trying to
1593
+ // work out why nothing appeared.
1594
+ store.setTool("select");
1595
+ return;
1596
+ }
1597
+ insertDrawn(tool, points);
1598
+ },
1599
+
1600
+ /** The pen's commit. Same shape as `insertDrawn` — build, insert if the
1601
+ * builder answered, and disarm either way — but through `penLayer`,
1602
+ * because a path with handles is not something `layerFromDraw`'s bare
1603
+ * points can describe. */
1604
+ onPenCommit(data) {
1605
+ const at = insertContext();
1606
+ if (at === null) return;
1607
+ const layer = penLayer(data, at.ctx);
1608
+ if (layer !== null) insertLayer(layer);
1609
+ store.setTool("select");
1610
+ },
1611
+
1612
+ onKey(event, editing) {
1613
+ // Straight into the SAME resolver the chrome's own listener uses, so a
1614
+ // shortcut cannot mean one thing with the canvas focused and another with
1615
+ // a panel focused.
1616
+ return store.handleKey({ ...event, target: editing ? EDITING_TARGET : null });
1617
+ },
1618
+
1619
+ // ── the gesture lane ────────────────────────────────────────────────────
1620
+ //
1621
+ // ONE DRAG IS ONE UNDO ENTRY, and that is `scrub()` doing the work rather
1622
+ // than this code coalescing. Every `onGesture` re-derives from the scrub's
1623
+ // BASE — the document as it was at press — so a coalesced pointermove, a
1624
+ // snap that bites and un-bites, or a refusal partway through cannot make
1625
+ // the drag drift. Escape is `discard()`, which restores the base exactly
1626
+ // because it never left.
1627
+ onGestureStart(kind, layerIds, screenScale) {
1628
+ if (docHistory === null || state.doc === null) return false;
1629
+ closeWheelFraming();
1630
+ // ONE SCRUB AT A TIME, stated rather than relied on — the same guard
1631
+ // `onFramingGestureStart` carries, and for the same reason: a lost
1632
+ // pointerup must not turn the next press into an exception thrown across
1633
+ // the bridge from inside the stage's own pointer handler.
1634
+ //
1635
+ // THE ANSWER IS RETURNED, which the modal lane needs. A pointer drag can
1636
+ // afford to fail silently — the author releases and tries again — but a
1637
+ // MODAL arms and stays armed, so one that opened over somebody else's
1638
+ // scrub would drive that scrub with its own deltas and commit it.
1639
+ if (gesture !== null || framing !== null) return false;
1640
+ gesture = {
1641
+ kind,
1642
+ layerIds: [...layerIds],
1643
+ scrub: docHistory.scrub(),
1644
+ screenScale: screenScale ?? null,
1645
+ snap: null,
1646
+ };
1647
+ set({ error: null });
1648
+ return true;
1649
+ },
1650
+ onGesture(delta) {
1651
+ const active = gesture;
1652
+ if (active === null || state.sceneId === null || state.doc === null) return;
1653
+ const sceneId = state.sceneId;
1654
+ const sceneFrame = masterToSceneCached(state.doc, state.frame)?.sceneFrame ?? 0;
1655
+ let refusals: string[] = [];
1656
+ let guides: SnapGuide[] = [];
1657
+ active.scrub.capture((base) => {
1658
+ // ── THE ONE FORK ANIMATE MODE OWNS ────────────────────────────────
1659
+ //
1660
+ // ON: the drag authors a BLOCK ending at the playhead. OFF: it writes
1661
+ // the layer's own values. Both are legitimate and both are one undo
1662
+ // entry; what must never happen is the author not knowing which they
1663
+ // got, which is why the mode is a deliberate ⇧A and never a side effect
1664
+ // of looking at a panel.
1665
+ //
1666
+ // Snapping is deliberately NOT applied in animate mode: the author is
1667
+ // authoring a MOTION, and pulling its end onto another layer's edge
1668
+ // would quantise the animation to whatever happened to be nearby.
1669
+ if (state.animateMode) {
1670
+ const out = animateGesture(base, sceneId, active.layerIds, delta, sceneFrame, base.fps);
1671
+ refusals = out.refusals;
1672
+ guides = [];
1673
+ return out.doc;
1674
+ }
1675
+ const snapped = snapFor(
1676
+ base,
1677
+ sceneId,
1678
+ active.layerIds,
1679
+ delta,
1680
+ // The pane's own screen scale when the stage sent one. The camera
1681
+ // pane's zoom is the fallback, which is what a caller with no pane —
1682
+ // a test, or the timeline dock — is asking for anyway.
1683
+ active.screenScale ?? state.view.zoom,
1684
+ );
1685
+ guides = snapped.guides;
1686
+ const out = applyGesture(base, sceneId, active.layerIds, snapped.delta, sceneFrame, base.fps);
1687
+ refusals = out.refusals;
1688
+ return out.doc;
1689
+ });
1690
+ bridge?.setSnapGuides(guides);
1691
+ // ── A BOX THAT IS BOOKKEEPING, NOT PICTURE ────────────────────────
1692
+ //
1693
+ // A group's or a cloner's `size` bounds children that carry their own, so
1694
+ // the SIZE half of a resize is a document write that draws nothing. The
1695
+ // position half is not: `resizeTransform` shifts the box's centre by half
1696
+ // of what the edge moved, and for a container that translates the whole
1697
+ // subtree. So this is a word and not a refusal, and it says which half.
1698
+ //
1699
+ // RESIZE ONLY. A `scale` about a pivot is mostly the translation — the
1700
+ // author asked to move the arrangement apart, which is exactly what a
1701
+ // container does — and calling that a no-op would be the lie.
1702
+ //
1703
+ // `sizeChangesPixels` and NOT `layerDraws`: a `component` draws nothing
1704
+ // and is not pickable, but its box feeds `instanceScale`, so resizing an
1705
+ // instance really does resize the picture.
1706
+ const layers = state.doc.scenesById[sceneId]?.layersById ?? {};
1707
+ const inert =
1708
+ delta.kind === "resize"
1709
+ ? active.layerIds
1710
+ .map((id) => layers[id])
1711
+ .find((l) => l !== undefined && !sizeChangesPixels(l))
1712
+ : undefined;
1713
+ set({
1714
+ // The layer-level refusals come first: one of them is a layer that did
1715
+ // not move at all, which outranks one that moved without showing it.
1716
+ error:
1717
+ refusals[0] ??
1718
+ (inert === undefined
1719
+ ? null
1720
+ : `"${inert.label}" is a ${inert.kind}, so its box only bounds its children. ` +
1721
+ `Dragging the handle moves it, but growing the box draws nothing.`),
1722
+ });
1723
+ },
1724
+ onGestureEnd(commit) {
1725
+ const active = gesture;
1726
+ gesture = null;
1727
+ if (active === null) return;
1728
+ bridge?.setSnapGuides([]);
1729
+ if (commit) active.scrub.commit();
1730
+ else active.scrub.discard();
1731
+ syncHistoryFlags();
1732
+ },
1733
+
1734
+ onFramingGestureStart() {
1735
+ if (docHistory === null || state.doc === null) return false;
1736
+ closeWheelFraming();
1737
+ // One scrub at a time. A lane already open wins rather than throwing
1738
+ // across the bridge from inside the stage's own pointer handler.
1739
+ if (gesture !== null || framing !== null) return false;
1740
+ framing = docHistory.scrub();
1741
+ set({ error: null });
1742
+ return true;
1743
+ },
1744
+ onFramingGesture(delta: FramingDelta) {
1745
+ if (framing === null) return;
1746
+ writeCamera((current) => applyFramingDelta(current, delta));
1747
+ },
1748
+ onFramingGestureEnd(commit) {
1749
+ const active = framing;
1750
+ framing = null;
1751
+ if (active === null) return;
1752
+ // `commit(): false` is Escape, and it restores the pre-drag framing
1753
+ // EXACTLY because every capture re-derived from the base — the document
1754
+ // never left it.
1755
+ if (commit) active.commit();
1756
+ else active.discard();
1757
+ syncHistoryFlags();
1758
+ },
1759
+
1760
+ // ── THE KEY-LIGHT ARM ─────────────────────────────────────────────────
1761
+ //
1762
+ // The same three-call shape as the two lanes above, for the same reasons: a
1763
+ // scrub opened at the travel gate, every emit re-derived from its base, one
1764
+ // undo entry per drag, and Escape restoring exactly because the document
1765
+ // never left that base.
1766
+ //
1767
+ // It is not a light. `mesh.shading.keyDir` is a shader uniform naming a
1768
+ // fake key direction; this lane has no lights and is not getting any.
1769
+ onKeyLightStart(layerId: string) {
1770
+ if (docHistory === null || state.doc === null) return false;
1771
+ closeWheelFraming();
1772
+ if (gesture !== null || framing !== null || keyLight !== null) return false;
1773
+ keyLight = { layerId, scrub: docHistory.scrub() };
1774
+ set({ error: null });
1775
+ return true;
1776
+ },
1777
+ onKeyLight(dir) {
1778
+ const active = keyLight;
1779
+ if (active === null || state.sceneId === null) return;
1780
+ const sceneId = state.sceneId;
1781
+ // Non-finite components would reach `keyU.normalize()` as a NaN vector
1782
+ // and blank the model, so they are dropped rather than written. The
1783
+ // gizmo cannot produce them, which is exactly why the guard is here and
1784
+ // not there: this is the boundary, and a boundary checks.
1785
+ if (!dir.every((n) => Number.isFinite(n))) return;
1786
+ active.scrub.capture((base) => {
1787
+ const scene = base.scenesById[sceneId];
1788
+ const layer = scene?.layersById[active.layerId];
1789
+ if (scene === undefined || layer === undefined || layer.kind !== "mesh") return base;
1790
+ const prior = layer.mesh.shading;
1791
+ return {
1792
+ ...base,
1793
+ scenesById: {
1794
+ ...base.scenesById,
1795
+ [sceneId]: {
1796
+ ...scene,
1797
+ layersById: {
1798
+ ...scene.layersById,
1799
+ [active.layerId]: {
1800
+ ...layer,
1801
+ mesh: {
1802
+ ...layer.mesh,
1803
+ // The prior toon fields are carried, not replaced: dragging
1804
+ // the arm must not silently reset the band count or the rim
1805
+ // the author already set.
1806
+ shading: {
1807
+ ...(prior?.mode === "toon" ? prior : {}),
1808
+ mode: "toon",
1809
+ keyDir: [dir[0], dir[1], dir[2]],
1810
+ },
1811
+ },
1812
+ },
1813
+ },
1814
+ },
1815
+ },
1816
+ };
1817
+ });
1818
+ },
1819
+ onKeyLightEnd(commit) {
1820
+ const active = keyLight;
1821
+ keyLight = null;
1822
+ if (active === null) return;
1823
+ if (commit) active.scrub.commit();
1824
+ else active.scrub.discard();
1825
+ syncHistoryFlags();
1826
+ },
1827
+
1828
+ onContextMenu(hits, pane, at) {
1829
+ set({ activePane: pane });
1830
+ // EMPTY SPACE OPENS NOTHING, and deselects nothing. There is no paste
1831
+ // verb and no scene-level entry in this menu, so a menu there would be a
1832
+ // list of things that cannot act — and a right-click is a question about
1833
+ // something, not an edit.
1834
+ const first = hits[0];
1835
+ if (first === undefined) return;
1836
+ // The plain-click policy, deliberately reused rather than restated: the
1837
+ // outermost container, unless it is already part of the selection, in
1838
+ // which case the whole selection stands and the menu acts on all of it.
1839
+ const front = outermost(first);
1840
+ if (!state.selection.includes(front)) select([front]);
1841
+ // A FRESH OBJECT EVERY TIME. `StageMenu` opens from this field's
1842
+ // identity, so a second right-click at the same point on the same layer
1843
+ // has to look different or the menu would open once and never again.
1844
+ set({ stageMenu: { at: { x: at.x, y: at.y }, hits: [...hits], pane } });
1845
+ },
1846
+ onMeshStats(layerId, triangles) {
1847
+ const current = state.meshTriangles;
1848
+ if (triangles === null) {
1849
+ if (!(layerId in current)) return;
1850
+ const next = { ...current };
1851
+ delete next[layerId];
1852
+ set({ meshTriangles: next });
1853
+ return;
1854
+ }
1855
+ if (current[layerId] === triangles) return;
1856
+ set({ meshTriangles: { ...current, [layerId]: triangles } });
1857
+ },
1858
+ };
1859
+
1860
+ function select(ids: readonly string[]): void {
1861
+ // Deduped and in DOCUMENT order, so every consumer — the inspector's
1862
+ // fan-out, the layers panel, a multi-edit transaction — sees one canonical
1863
+ // ordering and none of them has to sort.
1864
+ //
1865
+ // ── WHICH HOME THE IDS ARE ORDERED AGAINST, AND WHY BOTH ─────────────
1866
+ //
1867
+ // Through `homeOf` and the CONTAINER, never `scenesById[sceneId]` alone:
1868
+ // inside a component definition (`editingDef`) the clicked ids live in
1869
+ // the definition's own `layersById`, and ordering against the scene's map
1870
+ // filtered every one of them out — clicking a row inside a definition
1871
+ // selected nothing, silently.
1872
+ //
1873
+ // But the container alone is the SAME BUG POINTING THE OTHER WAY. Half
1874
+ // this chrome is scene-scoped whatever the outline shows (`layerContainer`
1875
+ // and `insertContext` both say so): `placeComponent` puts an instance in
1876
+ // the SCENE and selects it, an insert draws into the scene, and every
1877
+ // stage pick reports scene ids because the stage is never told about
1878
+ // `editingDef` at all. With a definition open, ordering those against the
1879
+ // definition dropped every one and cleared the selection instead.
1880
+ //
1881
+ // So: order against the container, then take anything left over from the
1882
+ // scene — and LEAVE THE DEFINITION when that happens. An id that lives in
1883
+ // the scene means the caller addressed the scene, and the container-scoped
1884
+ // verbs (`toggleFlag`, `renameLayer`, `groupSelection`) must address the
1885
+ // home the selection actually lives in or they no-op against the wrong
1886
+ // one. Ids in neither are still dropped: that is how a stale id from a
1887
+ // deleted layer stops here rather than in a panel.
1888
+ const container = layerContainer();
1889
+ const doc = state.doc;
1890
+ const home = doc !== null && container !== null ? homeOf(doc, container) : null;
1891
+ const sceneHome =
1892
+ doc !== null && state.sceneId !== null && container !== state.sceneId
1893
+ ? homeOf(doc, state.sceneId)
1894
+ : null;
1895
+ const wanted = new Set(ids);
1896
+ const orderById = new Map<string, number>();
1897
+ for (const l of Object.values(home?.layersById ?? {})) {
1898
+ if (wanted.has(l.id)) orderById.set(l.id, l.order);
1899
+ }
1900
+ let leftDefinition = false;
1901
+ if (orderById.size < wanted.size && sceneHome !== null) {
1902
+ for (const l of Object.values(sceneHome.layersById)) {
1903
+ if (!wanted.has(l.id) || orderById.has(l.id)) continue;
1904
+ orderById.set(l.id, l.order);
1905
+ leftDefinition = true;
1906
+ }
1907
+ }
1908
+ const ordered =
1909
+ home === null && sceneHome === null
1910
+ ? [...wanted]
1911
+ : [...orderById.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id);
1912
+ // Above the early return, like the two focus clears below: the ids can be
1913
+ // unchanged (re-selecting what was already selected) while the container
1914
+ // still has to follow them out of the definition.
1915
+ if (leftDefinition) set({ editingDef: null });
1916
+ // A FOCUS THE NEW SELECTION DOES NOT OWN GOES FIRST, above the early return.
1917
+ // The camera row focuses a shot WITHOUT touching the layer selection
1918
+ // (BlockRow skips `select` for CAMERA_ROW_ID, and ⇧C focuses a shot outright),
1919
+ // so "click the layer I already had selected" is the ordinary way back — and
1920
+ // it is exactly the call whose ids are unchanged. Leaving the focus behind
1921
+ // strands the inspector on a subject the author navigated away from, and
1922
+ // `deleteSelection` consults the same field first, so Delete would remove the
1923
+ // shot rather than the layer just clicked.
1924
+ //
1925
+ // The `includes` is load-bearing, not caution: LifespanRow's `pick()` fires on
1926
+ // every pointerdown of a lifespan drag, so an unconditional clear would drop
1927
+ // the author's focused block the instant they touched its own layer's bar.
1928
+ if (state.selectedBlock !== null && !ordered.includes(state.selectedBlock.layerId)) {
1929
+ set({ selectedBlock: null });
1930
+ }
1931
+ // A MARKER IS ALWAYS DROPPED, unconditionally, and above the early return
1932
+ // with it. There is no "the marker belongs to this layer" test to make —
1933
+ // markers are document-level — so the rule is simply that clicking a layer
1934
+ // makes the layer the subject, which is what stops Delete from removing a
1935
+ // beat the author had stopped looking at.
1936
+ if (state.selectedMarker !== null) set({ selectedMarker: null });
1937
+ if (sameIds(ordered, state.selection)) return;
1938
+ // A block belongs to a layer, so changing the layer selection drops the
1939
+ // focused block rather than leaving the dock pointing at a row the author
1940
+ // has navigated away from.
1941
+ set({ selection: ordered, selectedBlock: null });
1942
+ bridge?.setSelection(ordered);
1943
+ }
1944
+
1945
+ /**
1946
+ * Put the open document's NAME in the URL, and nothing else.
1947
+ *
1948
+ * That is the only thing boot reads back, so it is the only thing worth
1949
+ * writing: a reload lands on the document the author was in, at frame 0.
1950
+ * The playhead is deliberately not mirrored — `seek` calls no `replaceState`
1951
+ * at all, and per-frame history writes (~30/s) trip Safari's rate limit
1952
+ * (SecurityError past ~100 calls/30s).
1953
+ */
1954
+ function mirrorUrl(): void {
1955
+ if (typeof window === "undefined") return;
1956
+ const url = new URL(window.location.href);
1957
+ if (state.name === null) url.searchParams.delete("shot");
1958
+ else url.searchParams.set("shot", state.name);
1959
+ window.history.replaceState(null, "", url);
1960
+ }
1961
+
1962
+ async function loadInto(name: string, gen: number): Promise<void> {
1963
+ const loaded = await api.loadShot(name);
1964
+ if (gen !== generation) return;
1965
+ // ── AN OPEN GESTURE DIES WITH THE HISTORY IT WAS TAKEN AGAINST ───────
1966
+ //
1967
+ // `closeShot` has always discarded these; a RELOAD needs it just as much
1968
+ // and had no such line. The document id does not change when the same
1969
+ // shot is re-read (an external write adopted through
1970
+ // `adoptExternalChange`), so nothing downstream notices the swap either:
1971
+ // the stage's own mid-gesture guards key on `doc.id`. Left open, the next
1972
+ // capture runs against the DEAD history — whose `onHistoryChange` is
1973
+ // still subscribed — and republishes the pre-reload document over the one
1974
+ // just loaded, then autosaves it with the fresh rev. A pointer drag makes
1975
+ // that window milliseconds wide; a modal transform holds it open for as
1976
+ // long as the author leaves the key pressed.
1977
+ gesture?.scrub.discard();
1978
+ gesture = null;
1979
+ framing?.discard();
1980
+ framing = null;
1981
+ if (wheelFraming !== null) {
1982
+ clearTimeout(wheelFraming);
1983
+ wheelFraming = null;
1984
+ }
1985
+ docHistory = createHistory<Doc3D>(loaded.doc);
1986
+ docHistory.subscribe(onHistoryChange);
1987
+ const frame = 0;
1988
+ set({
1989
+ name: loaded.name,
1990
+ doc: loaded.doc,
1991
+ rev: loaded.rev,
1992
+ frame,
1993
+ sceneId: sceneAt(loaded.doc, frame),
1994
+ ...CLEARED_SELECTION,
1995
+ playing: false,
1996
+ dirty: false,
1997
+ saveState: "idle",
1998
+ error: null,
1999
+ emitWarning: null,
2000
+ canUndo: false,
2001
+ canRedo: false,
2002
+ });
2003
+ mirrorUrl();
2004
+ pushDocToStage(loaded.doc);
2005
+ bridge?.seek(frame);
2006
+ bridge?.setSelection([]);
2007
+ }
2008
+
2009
+ const store: ShotEditorStore = {
2010
+ getState: () => state,
2011
+ subscribe(listener) {
2012
+ listeners.add(listener);
2013
+ return () => listeners.delete(listener);
2014
+ },
2015
+
2016
+ stageHandlers,
2017
+
2018
+ attachStage(next) {
2019
+ bridge = next;
2020
+ if (next === null) {
2021
+ set({ stageInfo: null });
2022
+ return;
2023
+ }
2024
+ if (state.doc !== null) {
2025
+ // Through `pushDocToStage`, not `next.loadDoc`: the isolation rides the
2026
+ // document push now, and a second entry point that pushed the document
2027
+ // without it is exactly how the stage ends up scoped to a layer the
2028
+ // document it is holding does not contain.
2029
+ pushDocToStage(state.doc);
2030
+ next.seek(state.frame);
2031
+ next.setSelection(state.selection);
2032
+ }
2033
+ pushWorkspaceToStage();
2034
+ },
2035
+ stage: () => bridge,
2036
+ selectionQuads: () => bridge?.selectionQuads() ?? null,
2037
+
2038
+ async refreshShots() {
2039
+ try {
2040
+ const { shots } = await api.listShots();
2041
+ set({ shots });
2042
+ } catch (e) {
2043
+ set({ error: e instanceof Error ? e.message : String(e) });
2044
+ }
2045
+ },
2046
+
2047
+ async openShot(name) {
2048
+ if (name === state.name) return;
2049
+ // REFUSE rather than replace: `loadInto` swaps `state.doc`, so opening
2050
+ // over a document whose last save failed destroys edits that exist
2051
+ // nowhere else. `flush` has already put the reason in `state.error`.
2052
+ try {
2053
+ await store.flush();
2054
+ } catch {
2055
+ return;
2056
+ }
2057
+ const gen = ++generation;
2058
+ try {
2059
+ await loadInto(name, gen);
2060
+ } catch (e) {
2061
+ if (gen !== generation) return;
2062
+ set({ error: e instanceof Error ? e.message : String(e) });
2063
+ }
2064
+ },
2065
+
2066
+ async createShot(name) {
2067
+ // Same refusal as `openShot`: creating swaps the open document out.
2068
+ try {
2069
+ await store.flush();
2070
+ } catch {
2071
+ return;
2072
+ }
2073
+ const gen = ++generation;
2074
+ // PascalCase for the component name; the store name stays as typed.
2075
+ const componentName = pascalCase(name);
2076
+ const doc = createShotDoc({ id: `sht_${slug(name)}`, name: componentName });
2077
+ try {
2078
+ // `null` is the CREATE claim: a name already in use comes back 409
2079
+ // rather than overwriting whatever is there.
2080
+ const result = await api.saveShot(name, doc, null);
2081
+ if (gen !== generation) return;
2082
+ docHistory = createHistory<Doc3D>(doc);
2083
+ docHistory.subscribe(onHistoryChange);
2084
+ set({
2085
+ name,
2086
+ doc,
2087
+ rev: result.rev,
2088
+ frame: 0,
2089
+ sceneId: doc.scenes[0] ?? null,
2090
+ ...CLEARED_SELECTION,
2091
+ dirty: false,
2092
+ saveState: "saved",
2093
+ error: null,
2094
+ emitWarning: result.warning ?? null,
2095
+ canUndo: false,
2096
+ canRedo: false,
2097
+ });
2098
+ mirrorUrl();
2099
+ pushDocToStage(doc);
2100
+ // THE SAME TWO PUSHES `loadInto` ENDS WITH, and for the same reason:
2101
+ // the stage keeps whatever frame and selection the last document left
2102
+ // it parked at, so a new shot opened from a scrubbed one drew a blank
2103
+ // pane at a master frame the fresh document does not reach.
2104
+ bridge?.seek(0);
2105
+ bridge?.setSelection([]);
2106
+ void store.refreshShots();
2107
+ } catch (e) {
2108
+ if (gen !== generation) return;
2109
+ set({ error: e instanceof Error ? e.message : String(e) });
2110
+ }
2111
+ },
2112
+
2113
+ closeShot() {
2114
+ // An open scrub outlives the document it was taken against; discarding it
2115
+ // is the only thing that keeps history consistent across a close.
2116
+ if (wheelFraming !== null) {
2117
+ clearTimeout(wheelFraming);
2118
+ wheelFraming = null;
2119
+ }
2120
+ gesture?.scrub.discard();
2121
+ gesture = null;
2122
+ framing?.discard();
2123
+ framing = null;
2124
+ generation++;
2125
+ docHistory = null;
2126
+ // Close for REAL on the stage: drop the doc first (unmounting every
2127
+ // plane — without this the stage keeps rendering the closed document
2128
+ // behind the file picker), then free the raster cache. Bridge verbs and
2129
+ // not calls, because the cache is a singleton in the stage's bundle.
2130
+ bridge?.closeDoc();
2131
+ bridge?.clearCaches();
2132
+ set({ ...INITIAL_STATE, shots: state.shots, ...workspacePrefs(state) });
2133
+ mirrorUrl();
2134
+ },
2135
+
2136
+ adoptExternalChange(payload) {
2137
+ // Our own echo, or a different document: nothing to do.
2138
+ if (isOwnEcho(payload)) return;
2139
+ if (payload.name !== undefined && payload.name !== state.name) {
2140
+ void store.refreshShots();
2141
+ return;
2142
+ }
2143
+ if (state.name === null) return;
2144
+ // Someone else wrote OUR document. Adopting silently would throw away
2145
+ // local edits, so a dirty chrome is told instead of overwritten.
2146
+ if (state.dirty) {
2147
+ set({
2148
+ error:
2149
+ `this scene changed somewhere else while you had unsaved edits — ` +
2150
+ `your version is still open; save it under another name or reload to take theirs`,
2151
+ });
2152
+ return;
2153
+ }
2154
+ const gen = ++generation;
2155
+ void loadInto(state.name, gen).catch(() => {
2156
+ /* a deleted document surfaces on the next list refresh */
2157
+ });
2158
+ },
2159
+
2160
+ edit(fn) {
2161
+ if (docHistory === null) return false;
2162
+ // A wheel dolly's scrub is closed by silence, so a panel edit can land
2163
+ // inside that window with no pointer down anywhere. Committing the flick
2164
+ // first is what keeps `transaction()` from throwing at the author.
2165
+ closeWheelFraming();
2166
+ if (historyBusy()) return false;
2167
+ docHistory.transaction(fn);
2168
+ return true;
2169
+ },
2170
+ undo() {
2171
+ closeWheelFraming();
2172
+ if (historyBusy()) return;
2173
+ docHistory?.undo();
2174
+ },
2175
+ redo() {
2176
+ closeWheelFraming();
2177
+ if (historyBusy()) return;
2178
+ docHistory?.redo();
2179
+ },
2180
+ /**
2181
+ * Get what is in memory onto disk, or THROW.
2182
+ *
2183
+ * Every caller uses this as "the disk now has my edits" immediately before
2184
+ * it overwrites or DROPS the in-memory document (a checkpoint restore, a
2185
+ * document switch, an agent apply). A flush that reports a success it did
2186
+ * not achieve turns each of those into silent data loss.
2187
+ *
2188
+ * The debounce check alone is not that guarantee. `runSave`'s catch sets
2189
+ * `saveState: "error"` and does NOT reschedule, so a failed autosave (a 409
2190
+ * from a second surface writing the same shot is the ordinary way in)
2191
+ * leaves `dirty` true with no timer armed: this used to see `saveTimer ===
2192
+ * null`, await an already-settled promise, and return as if it had written.
2193
+ *
2194
+ * NO RETRY, deliberately. The failure that gets here is a 409 against a
2195
+ * baseline someone else moved, and re-sending the same rev produces the
2196
+ * same 409 — a second blind PUT over a version this chrome has not seen is
2197
+ * the corruption, not the cure. Refusing keeps the document open, in
2198
+ * memory, with the reason already on screen.
2199
+ */
2200
+ async flush() {
2201
+ if (saveTimer !== null) {
2202
+ clearTimeout(saveTimer);
2203
+ saveTimer = null;
2204
+ await runSave();
2205
+ }
2206
+ await savePromise;
2207
+ if (state.saveState === "error") {
2208
+ throw new Error(
2209
+ `${state.error ?? "the last save failed"}. This scene is not on disk, so nothing that ` +
2210
+ `replaces or closes it may run yet`,
2211
+ );
2212
+ }
2213
+ },
2214
+
2215
+ seek(frame) {
2216
+ const next = clampFrame(state.doc, frame);
2217
+ if (next === state.frame) return;
2218
+ const sceneId = sceneAt(state.doc, next);
2219
+ // ONE `set`, not two. A seek fires per frame during playback and every
2220
+ // `set` is a full listener sweep, so the isolation scope is folded into
2221
+ // the same patch rather than routed through `setScene`.
2222
+ const isolation = isolationForScene(sceneId);
2223
+ const wasIsolated = state.isolation;
2224
+ set({ frame: next, sceneId, isolation, ...(isolation === null ? { isolationOrbit: false } : {}) });
2225
+ bridge?.seek(next);
2226
+ if (isolation !== wasIsolated) bridge?.setIsolation(isolation);
2227
+ // No mirrorUrl: the URL carries only the document name, which a seek
2228
+ // never changes.
2229
+ },
2230
+ setPlaying(playing) {
2231
+ set({ playing });
2232
+ },
2233
+ step(delta) {
2234
+ if (state.doc === null) return;
2235
+ const total = docDurationFramesCached(state.doc);
2236
+ if (total <= 0) return;
2237
+ // Wraps, because a preview loop is what an author wants from the space
2238
+ // bar; stopping at the end would need a second key to get back.
2239
+ const next = ((state.frame + delta) % total + total) % total;
2240
+ store.seek(next);
2241
+ },
2242
+
2243
+ select,
2244
+ focusScene(sceneId) {
2245
+ if (state.doc === null || sceneId === state.sceneId) return;
2246
+ const at = sceneToMaster(state.doc, sceneId, 0);
2247
+ if (at !== null) store.seek(at);
2248
+ // PINNED HERE, and not left to `seek`'s side effect. `seek` early-returns
2249
+ // when the target frame is the one the playhead is already on, and that is
2250
+ // precisely the state this repairs: FilePanel pins a scene without moving
2251
+ // the playhead, so the pin and the playhead have already diverged and the
2252
+ // seek back is a no-op. Every caller follows this with `select`, which
2253
+ // orders within `state.sceneId` — an unrepaired pin makes that selection
2254
+ // resolve to empty and silently clear whatever was selected before.
2255
+ //
2256
+ // After the seek rather than before, so this wins unconditionally: a
2257
+ // zero-length scene would otherwise have `sceneAt` hand the pin to its
2258
+ // neighbour.
2259
+ store.setScene(sceneId);
2260
+ },
2261
+ setTool(tool) {
2262
+ set({ tool });
2263
+ bridge?.setTool(tool);
2264
+ },
2265
+ setInspectorMode(inspectorMode) {
2266
+ // A VIEW choice only. It must never arm `animateMode` — see types.ts.
2267
+ set({ inspectorMode });
2268
+ },
2269
+ setAnimateMode(animateMode) {
2270
+ // …and turning animate ON follows the inspector to the Animate tab,
2271
+ // because the author is about to author blocks and wants to see them. The
2272
+ // dependency runs one way only.
2273
+ set({ animateMode, inspectorMode: animateMode ? "animate" : state.inspectorMode });
2274
+ },
2275
+
2276
+ handleKey(event) {
2277
+ const action = resolveKey(event, {
2278
+ editing: isEditable(event.target ?? null),
2279
+ hasSelection: state.selection.length > 0,
2280
+ isolation: state.isolation !== null,
2281
+ });
2282
+ if (action === null) return false;
2283
+ switch (action.kind) {
2284
+ case "tool":
2285
+ store.setTool(action.tool);
2286
+ return true;
2287
+ case "undo":
2288
+ store.undo();
2289
+ return true;
2290
+ case "redo":
2291
+ store.redo();
2292
+ return true;
2293
+ case "save":
2294
+ // `flush` throws when it could not write; the failure is already in
2295
+ // `state.error` and on screen, and nothing here destroys anything.
2296
+ void store.flush().catch(() => {});
2297
+ return true;
2298
+ case "playToggle":
2299
+ store.setPlaying(!state.playing);
2300
+ return true;
2301
+ case "step":
2302
+ store.setPlaying(false);
2303
+ store.step(action.delta);
2304
+ return true;
2305
+ case "nudge":
2306
+ store.nudge(action.dx, action.dy);
2307
+ return true;
2308
+ // The three view verbs address the ACTIVE PANE. One set of keys for two
2309
+ // panes is what the pane bit is for; a second set would be four
2310
+ // shortcuts for one idea, and the author would still have to know which
2311
+ // pane they were in.
2312
+ case "fit":
2313
+ store.fitActivePane();
2314
+ return true;
2315
+ case "fitTimeline":
2316
+ store.fitDock();
2317
+ return true;
2318
+ case "zoom":
2319
+ store.zoomActivePane(action.direction);
2320
+ return true;
2321
+ case "zoomReset":
2322
+ // "100%" has no meaning for an orbit camera — there is no pixel that
2323
+ // is one design pixel — so the reset the author actually wants there
2324
+ // is the fit.
2325
+ if (state.activePane === "working") store.fitWorkingView();
2326
+ else store.setView({ ...state.view, zoom: 1 });
2327
+ return true;
2328
+ case "stageLayout": {
2329
+ // `toggle` is numpad-0's alone: press it once to go to the camera,
2330
+ // press it again to come back. The 1 / 2 / 3 row deliberately does
2331
+ // NOT toggle — pressing 2 twice must leave you in the split, because
2332
+ // those keys name a layout rather than a place to visit.
2333
+ //
2334
+ // Back to the SPLIT and not to the working pane: the split is what
2335
+ // numpad-0 was almost certainly pressed from, and it is the only
2336
+ // mode from which both the camera and the author's own view are
2337
+ // visible.
2338
+ const mode =
2339
+ action.toggle === true && state.stageLayout.mode === action.mode ? "split" : action.mode;
2340
+ store.setStageLayout({ ...state.stageLayout, mode });
2341
+ return true;
2342
+ }
2343
+ case "shadingCycle":
2344
+ // Only with a working pane on screen: cycling an invisible pane's
2345
+ // mode is a state change the author cannot see happen.
2346
+ if (state.stageLayout.mode !== "camera") {
2347
+ store.setShadingMode(nextShading(state.shadingMode));
2348
+ }
2349
+ return true;
2350
+ case "alignCameraToView":
2351
+ store.alignCameraToWorkingView();
2352
+ return true;
2353
+ // NO `addShot` ARM. ⇧C was that key and is Blender's fit-all now, so
2354
+ // the action had no producer left. `store.addShotAtPlayhead` stays —
2355
+ // the timeline's `+ shot` button is its caller.
2356
+ case "addMarker":
2357
+ store.addMarkerAtPlayhead();
2358
+ return true;
2359
+ case "togglePanel":
2360
+ if (action.panel === "rail") store.setRailCollapsed(!state.railCollapsed);
2361
+ else if (action.panel === "inspector") {
2362
+ store.setInspectorCollapsed(!state.inspectorCollapsed);
2363
+ } else store.togglePanels();
2364
+ return true;
2365
+ case "animateToggle":
2366
+ store.setAnimateMode(!state.animateMode);
2367
+ return true;
2368
+ case "openExport":
2369
+ // Only with a document open: an export dialog over nothing would
2370
+ // offer to render a shot that does not exist.
2371
+ if (state.name !== null) store.setExportOpen(true);
2372
+ return true;
2373
+ case "deleteSelection":
2374
+ store.deleteSelection();
2375
+ return true;
2376
+ case "duplicate":
2377
+ store.duplicateSelection();
2378
+ return true;
2379
+ case "selectAll":
2380
+ store.selectAll();
2381
+ return true;
2382
+ case "deselect":
2383
+ select([]);
2384
+ return true;
2385
+ case "group":
2386
+ store.groupSelection();
2387
+ return true;
2388
+ case "ungroup":
2389
+ store.ungroupSelection();
2390
+ return true;
2391
+ case "toggleLock":
2392
+ store.toggleFlag("locked");
2393
+ return true;
2394
+ case "toggleHidden":
2395
+ store.toggleFlag("hidden");
2396
+ return true;
2397
+ case "splitAtPlayhead":
2398
+ store.splitAtPlayhead();
2399
+ return true;
2400
+ case "enterIsolation": {
2401
+ const first = state.selection[0];
2402
+ if (first !== undefined) store.enterIsolation(first);
2403
+ return true;
2404
+ }
2405
+ case "exitIsolation":
2406
+ store.exitIsolation();
2407
+ return true;
2408
+ // BOTH GUARDED ON A VISIBLE WORKING PANE, for the reason `shadingCycle`
2409
+ // is — and here it is not merely invisible but WRITING: with follow
2410
+ // armed, `setWorkingView` writes the shot camera, and in camera-only
2411
+ // layout both of follow's indicators are off screen (CameraViewBar
2412
+ // returns null, the in-pane badge has no pane), so a numpad key would
2413
+ // rewrite and autosave the camera with nothing on screen to say so.
2414
+ case "workingViewPreset":
2415
+ if (noWorkingPane()) return true;
2416
+ store.setWorkingView(presetWorkingView(state.workingView, action.preset));
2417
+ return true;
2418
+ case "fitSelection":
2419
+ if (noWorkingPane()) return true;
2420
+ store.fitWorkingViewToSelection();
2421
+ return true;
2422
+ // The next three are guarded on a VISIBLE working pane, exactly as
2423
+ // `workingViewPreset` above is and for the same reason: with follow
2424
+ // armed `setWorkingView` writes the shot camera, and in camera-only
2425
+ // layout both of follow's indicators are off screen — so a numpad key
2426
+ // would rewrite and autosave the camera with nothing on screen to say
2427
+ // so.
2428
+ case "orbitStep":
2429
+ if (noWorkingPane()) return true;
2430
+ store.setWorkingView(orbitByDegrees(state.workingView, action.dx, action.dy));
2431
+ return true;
2432
+ case "toggleWorkingOrtho":
2433
+ if (noWorkingPane()) return true;
2434
+ store.setWorkingView({ ...state.workingView, ortho: state.workingView.ortho !== true });
2435
+ return true;
2436
+ case "setHidden": {
2437
+ // EXPLICIT, unlike `toggleFlag`, which reads `selection[0]`'s own
2438
+ // flag — so hiding five layers one at a time left no key that could
2439
+ // bring them back, the selection having gone with them.
2440
+ const sceneId = layerContainer();
2441
+ const home = state.doc === null || sceneId === null ? null : homeOf(state.doc, sceneId);
2442
+ if (sceneId === null || home === null) return true;
2443
+ // ── THE SCOPE ON SCREEN, not the whole container ────────────────
2444
+ //
2445
+ // Inside isolation the author can only see one subtree, so "hide
2446
+ // everything else" and "reveal everything" both mean everything IN
2447
+ // THAT SUBTREE. Reaching outside it writes `hidden` onto layers the
2448
+ // pane never showed, which the author discovers on leaving.
2449
+ // DESCENDANTS ONLY, not `relativesOf`: the isolation root's own
2450
+ // ancestors are above the boundary and off screen with everything
2451
+ // else outside it.
2452
+ const scoped =
2453
+ state.isolation === null
2454
+ ? Object.keys(home.layersById)
2455
+ : [...withDescendants(home, [state.isolation])];
2456
+ // ── AND WHAT ⇧H MUST LEAVE ALONE ────────────────────────────────
2457
+ //
2458
+ // `hidden` CASCADES, so "everything minus the selection" hid the
2459
+ // selection's own ancestors and the cascade blanked the selection
2460
+ // itself — and inside isolation the root is an ancestor by
2461
+ // definition, so the whole pane went. `relativesOf` is the fix and
2462
+ // lives in packages/shot beside the walk it shares.
2463
+ const keep = relativesOf(home, state.selection);
2464
+ const ids =
2465
+ action.scope === "reveal"
2466
+ ? scoped
2467
+ : action.scope === "others"
2468
+ ? scoped.filter((id) => !keep.has(id))
2469
+ : [...new Set(state.selection)];
2470
+ if (ids.length === 0) return true;
2471
+ store.edit((doc) => setLayerFlags(doc, sceneId, ids, { hidden: action.scope !== "reveal" }));
2472
+ return true;
2473
+ }
2474
+ case "clearTransform": {
2475
+ // Back to the layer's own zero on ONE channel — Blender's ⌥G / ⌥R /
2476
+ // ⌥S. The fastest way out of a transform that went wrong several
2477
+ // edits ago, which undo cannot reach once anything else has happened.
2478
+ const sceneId = layerContainer();
2479
+ if (sceneId === null || state.selection.length === 0) return true;
2480
+ const ids = state.selection;
2481
+ store.edit((doc) => {
2482
+ const home = homeOf(doc, sceneId);
2483
+ if (home === null) return doc;
2484
+ const layersById = { ...home.layersById };
2485
+ let changed = false;
2486
+ for (const id of ids) {
2487
+ const layer = layersById[id];
2488
+ if (layer === undefined || layer.locked === true) continue;
2489
+ const t = layer.transform;
2490
+ // SIZE IS NOT CLEARED TO ZERO. A layer with no extent has no
2491
+ // handles left to grab and no way back, which is the same reason
2492
+ // `clampSize` exists — so ⌥S resets the two tilt-free axes to the
2493
+ // layer's own square rather than to nothing.
2494
+ const next =
2495
+ action.channel === "offset"
2496
+ ? { ...t, position: [0, 0, 0] as [number, number, number] }
2497
+ : action.channel === "rotation"
2498
+ ? { ...t, tilt: [0, 0] as [number, number], rollDeg: 0 }
2499
+ : t;
2500
+ if (next === t) continue;
2501
+ layersById[id] = { ...layer, transform: next };
2502
+ changed = true;
2503
+ }
2504
+ return changed ? patchHome(doc, sceneId, { ...home, layersById }) : doc;
2505
+ });
2506
+ // ⌥S has no honest answer yet: `size` is authored geometry rather
2507
+ // than a transform channel, so "clear" would mean picking a size the
2508
+ // author never chose. Named rather than silently doing nothing.
2509
+ if (action.channel === "scale") {
2510
+ set({
2511
+ error:
2512
+ "A layer's size is its own geometry rather than a transform, so there is no scale " +
2513
+ "to clear — set W and H in the inspector, or undo the resize.",
2514
+ });
2515
+ }
2516
+ return true;
2517
+ }
2518
+ case "wireframeToggle":
2519
+ // Blender's ⇧Z: into wireframe, and back to whatever was there.
2520
+ if (state.stageLayout.mode !== "camera") {
2521
+ store.setShadingMode(
2522
+ state.shadingMode === "wireframe" ? (state.previousShading ?? "solid") : "wireframe",
2523
+ );
2524
+ }
2525
+ return true;
2526
+ case "boxSelect":
2527
+ // STAGE-LOCAL. The letter has one owner (this table), and the stage
2528
+ // reads the armed bit in its own marquee lane — no bridge message and
2529
+ // no store verb, so there is nothing for this arm to do.
2530
+ return false;
2531
+ case "toggleIsolation":
2532
+ // Blender's local view, in or out. `selection[0]` is the subject
2533
+ // going in, which is the same rule ⌘Enter uses.
2534
+ if (state.isolation !== null) store.exitIsolation();
2535
+ else if (state.selection[0] !== undefined) store.enterIsolation(state.selection[0]);
2536
+ return true;
2537
+ case "penCommit":
2538
+ case "penAbandon":
2539
+ case "modalStart":
2540
+ case "modalAxis":
2541
+ case "modalNumber":
2542
+ case "modalNumberBack":
2543
+ case "modalConfirm":
2544
+ case "modalCancel":
2545
+ // UNREACHABLE FROM HERE, and listed rather than defaulted so it stays
2546
+ // that way. The pen and the modal transforms live in the stage, which
2547
+ // resolves these with `penActive` / `workingPane` / `transformModal`
2548
+ // in its own capture-phase listener and consumes them there; this
2549
+ // call site never sets those bits, so the resolver cannot answer
2550
+ // them. If either lane ever moves into the chrome, this arm is a
2551
+ // compile error waiting to be filled in rather than a key that
2552
+ // silently does nothing.
2553
+ return false;
2554
+ }
2555
+ },
2556
+
2557
+ deleteSelection() {
2558
+ // ── THE SUBJECT ORDER: marker, then block, then layers ──────────────
2559
+ //
2560
+ // Most specific first, and each of the three is cleared by selecting one
2561
+ // of the others (see `select`, `selectBlock`, `selectMarker`) — so the
2562
+ // order below never has to arbitrate between two things the author
2563
+ // believes are both selected.
2564
+ //
2565
+ // The MARKER is above the `sceneId` guard on purpose: a marker is timed on
2566
+ // master frames and belongs to no scene, so gating it on one would make
2567
+ // Delete silently do nothing on a document whose playhead is outside every
2568
+ // scene.
2569
+ const marker = state.selectedMarker;
2570
+ if (marker !== null) {
2571
+ store.removeMarker(marker);
2572
+ return;
2573
+ }
2574
+ const sceneId = state.sceneId;
2575
+ if (sceneId === null) return;
2576
+ // A FOCUSED BLOCK is the subject when there is one: the author is looking
2577
+ // at the dock, and deleting the whole layer because a block was selected
2578
+ // would be the worst possible reading of the same key.
2579
+ const focused = state.selectedBlock;
2580
+ if (focused !== null) {
2581
+ set({ selectedBlock: null });
2582
+ // THE BLOCK'S OWN SCENE, which is why it is carried on the selection.
2583
+ // `sceneId` is where the playhead is, and on one master strip those are
2584
+ // routinely different — deleting against the wrong one is a silent
2585
+ // no-op, the worst possible reading of a Delete key.
2586
+ store.edit((doc) =>
2587
+ focused.layerId === CAMERA_ROW_ID
2588
+ ? removeShot(doc, focused.sceneId, focused.blockId)
2589
+ : removeBlock(doc, focused.sceneId, focused.layerId, focused.blockId),
2590
+ );
2591
+ return;
2592
+ }
2593
+ const ids = state.selection;
2594
+ if (ids.length === 0) return;
2595
+ select([]);
2596
+ store.edit((doc) => removeLayers(doc, sceneId, ids));
2597
+ },
2598
+ duplicateSelection() {
2599
+ const ids = state.selection;
2600
+ if (ids.length === 0 || state.sceneId === null) return;
2601
+ const sceneId = layerContainer()!;
2602
+ let fresh: string[] = [];
2603
+ store.edit((doc) => {
2604
+ const out = duplicateLayers(doc, sceneId, ids);
2605
+ fresh = out.ids;
2606
+ return out.doc;
2607
+ });
2608
+ // The COPIES become the selection, so a duplicate-then-drag is one
2609
+ // gesture rather than two — which is what ⌘D is for.
2610
+ if (fresh.length > 0) select(fresh);
2611
+ },
2612
+ alignSelection(mode) {
2613
+ const sceneId = state.sceneId;
2614
+ const open = state.doc;
2615
+ if (sceneId === null || open === null || state.selection.length === 0) return;
2616
+ const ids = state.selection;
2617
+ // The PLAYHEAD is an input: bounds are measured on what is RENDERED at
2618
+ // this frame, so aligning at frame 0 and at frame 40 are different edits
2619
+ // on a layer a block is moving. See align.ts.
2620
+ const at = masterToSceneCached(open, state.frame)?.sceneFrame ?? 0;
2621
+ let refusal: string | null = null;
2622
+ const ran = store.edit((doc) => {
2623
+ const out = alignLayers(doc, sceneId, ids, mode, at);
2624
+ refusal = out.refusal;
2625
+ return out.doc;
2626
+ });
2627
+ // `edit` collapses a same-document return into no history entry, so a
2628
+ // refusal costs nothing and an already-aligned selection records no undo.
2629
+ sayRefusal(ran, refusal);
2630
+ },
2631
+ enterIsolation(id) {
2632
+ const sceneId = state.sceneId;
2633
+ const scene = sceneId === null ? undefined : state.doc?.scenesById[sceneId];
2634
+ if (scene === undefined || scene.layersById[id] === undefined) return;
2635
+ // ALWAYS HEAD-ON ON ENTRY, whichever subtree and whatever the last one's
2636
+ // camera was doing. `setIsolation` resets the stage's own copy too.
2637
+ set({ isolation: id, isolationOrbit: false, selection: [id] });
2638
+ // ISOLATION LIVES IN THE WORKING PANE, so entering with only the camera
2639
+ // pane on screen would dim the scene and show the head-on view nowhere —
2640
+ // the mode would look broken rather than off.
2641
+ //
2642
+ // ponytail: the layout is not restored on exit, so an author who entered
2643
+ // from camera-only comes back to a split. Restoring it needs a
2644
+ // remembered mode and a rule for what happens when they change the
2645
+ // layout while inside, which is more state than "press the layout button
2646
+ // again" is worth.
2647
+ if (state.stageLayout.mode === "camera") {
2648
+ store.setStageLayout({ ...state.stageLayout, mode: "split" });
2649
+ }
2650
+ bridge?.setIsolation(id);
2651
+ },
2652
+ exitIsolationAll() {
2653
+ if (state.isolation === null) return;
2654
+ set({ isolation: null, isolationOrbit: false });
2655
+ bridge?.setIsolation(null);
2656
+ },
2657
+ exitIsolation() {
2658
+ // ONE LEVEL, not all the way out. Entering is a walk down the tree and
2659
+ // leaving has to be its inverse, or a nested subtree can only be left by
2660
+ // jumping to the scene — which loses the author's place.
2661
+ const at = state.isolation;
2662
+ if (at === null) return;
2663
+ const sceneId = state.sceneId;
2664
+ const scene = sceneId === null ? undefined : state.doc?.scenesById[sceneId];
2665
+ const up = scene === undefined ? null : parentOf(scene, at);
2666
+ set({ isolation: up, isolationOrbit: false, selection: [at] });
2667
+ bridge?.setIsolation(up);
2668
+ },
2669
+ moveLayerTo(id, to) {
2670
+ const sceneId = layerContainer();
2671
+ if (sceneId === null) return;
2672
+ let refusal: string | null = null;
2673
+ const ran = store.edit((doc) => {
2674
+ const out = moveLayer(doc, sceneId, id, to);
2675
+ refusal = out.refusal ?? null;
2676
+ return out.doc;
2677
+ });
2678
+ sayRefusal(ran, refusal);
2679
+ },
2680
+ groupSelection() {
2681
+ const ids = state.selection;
2682
+ const sceneId = layerContainer();
2683
+ if (ids.length < 2 || sceneId === null) return;
2684
+ let groupId: string | null = null;
2685
+ let refusal: string | null = null;
2686
+ const ran = store.edit((doc) => {
2687
+ const out = groupLayers(doc, sceneId, ids);
2688
+ groupId = out.id;
2689
+ refusal = out.refusal ?? null;
2690
+ return out.doc;
2691
+ });
2692
+ // Same shape as align's: `edit` collapses a same-document return into no
2693
+ // history entry, so a refusal costs nothing and says why in the strip.
2694
+ sayRefusal(ran, refusal);
2695
+ if (groupId !== null) select([groupId]);
2696
+ },
2697
+ ungroupSelection() {
2698
+ const sceneId = layerContainer();
2699
+ if (sceneId === null || state.selection.length === 0) return;
2700
+ const targets = state.selection;
2701
+ let freed: string[] = [];
2702
+ let refusal: string | null = null;
2703
+ const ran = store.edit((doc) => {
2704
+ let next = doc;
2705
+ for (const id of targets) {
2706
+ const out = ungroupLayer(next, sceneId, id);
2707
+ next = out.doc;
2708
+ freed = [...freed, ...out.ids];
2709
+ // The FIRST refusal, kept: dissolving a multi-selection stops being
2710
+ // legible once two messages compete for one strip.
2711
+ if (refusal === null && out.refusal !== undefined) refusal = out.refusal;
2712
+ }
2713
+ return next;
2714
+ });
2715
+ sayRefusal(ran, refusal);
2716
+ if (freed.length > 0) select(freed);
2717
+ },
2718
+ toggleFlag(flag) {
2719
+ const ids = state.selection;
2720
+ const sceneId = layerContainer();
2721
+ if (ids.length === 0 || sceneId === null || state.doc === null) return;
2722
+ // Toggle from the FIRST selected layer, so a mixed selection lands on one
2723
+ // value rather than each layer flipping to its own opposite. Read through
2724
+ // `homeOf`: the container can be a `cmp_` definition id, which
2725
+ // `scenesById` answers with undefined — and an undefined first meant
2726
+ // `next` was always true, so a hidden layer inside a definition could
2727
+ // never be un-hidden from here.
2728
+ const first = homeOf(state.doc, sceneId)?.layersById[ids[0]!];
2729
+ const next = first?.[flag] !== true;
2730
+ store.edit((doc) => setLayerFlags(doc, sceneId, ids, { [flag]: next }));
2731
+ },
2732
+ renameLayer(id, label) {
2733
+ const sceneId = layerContainer();
2734
+ if (sceneId === null) return;
2735
+ store.edit((doc) => renameLayer(doc, sceneId, id, label));
2736
+ },
2737
+ reorderLayer(id, toIndex) {
2738
+ const sceneId = layerContainer();
2739
+ if (sceneId === null) return;
2740
+ store.edit((doc) => reorderLayer(doc, sceneId, id, toIndex));
2741
+ },
2742
+ restack(id, to) {
2743
+ const sceneId = layerContainer();
2744
+ if (sceneId === null) return;
2745
+ store.edit((doc) => restackLayer(doc, sceneId, id, to));
2746
+ },
2747
+ nudge(dx, dy) {
2748
+ const ids = state.selection;
2749
+ const sceneId = state.sceneId;
2750
+ if (ids.length === 0 || sceneId === null) return;
2751
+ // Through the SAME transform math a drag uses, so a nudge and a drag can
2752
+ // never disagree about what "move by 1px" means.
2753
+ let refusal: string | null = null;
2754
+ const ran = store.edit((doc) => {
2755
+ const scene = doc.scenesById[sceneId];
2756
+ if (scene === undefined) return doc;
2757
+ const layersById = { ...scene.layersById };
2758
+ let changed = false;
2759
+ for (const id of ids) {
2760
+ const layer = layersById[id];
2761
+ if (layer === undefined) continue;
2762
+ // NAMED, like every other locked skip in this lane. An arrow key is
2763
+ // the easiest gesture to repeat without looking, so a locked layer
2764
+ // that quietly stayed put while its siblings walked away is the one
2765
+ // most likely to be noticed only much later.
2766
+ if (layer.locked === true) {
2767
+ refusal ??= `"${layer.label}" is locked, so it did not move — unlock it to include it.`;
2768
+ continue;
2769
+ }
2770
+ const transform = applyToTransform(layer.transform, { kind: "move", dx, dy });
2771
+ if (transform === layer.transform) continue;
2772
+ layersById[id] = { ...layer, transform };
2773
+ changed = true;
2774
+ }
2775
+ return changed ? { ...doc, scenesById: { ...doc.scenesById, [sceneId]: { ...scene, layersById } } } : doc;
2776
+ });
2777
+ // THE SITE THE REVIEW NAMED: an arrow key pressed mid-drag reached
2778
+ // `historyBusy`, which wrote its message, and this `set` immediately
2779
+ // replaced it with the `null` the un-run body left behind.
2780
+ sayRefusal(ran, refusal);
2781
+ },
2782
+ setDockCollapsed(dockCollapsed) {
2783
+ // A manual toggle takes ownership from focus mode: leaving the working
2784
+ // view then keeps the author's choice instead of restoring over it.
2785
+ set({ dockCollapsed, focusRestoreDock: null });
2786
+ },
2787
+ setRailCollapsed(railCollapsed) {
2788
+ set({ railCollapsed });
2789
+ },
2790
+ setRailWidth(width) {
2791
+ set({ railWidth: clampSize(width, RAIL_WIDTH.min, RAIL_WIDTH.max) });
2792
+ },
2793
+ setDockHeight(height) {
2794
+ // The ceiling is the WINDOW's, resolved at the moment of the drag: a
2795
+ // dock taller than the viewport is a dock with no stage behind it, and
2796
+ // the same pixel height is fine on a desktop and absurd on a laptop.
2797
+ const ceiling = dockCeiling(
2798
+ typeof window === "undefined" ? DOCK_HEIGHT.max : window.innerHeight,
2799
+ );
2800
+ set({ dockHeight: clampSize(height, DOCK_HEIGHT.min, ceiling) });
2801
+ },
2802
+ setInspectorCollapsed(inspectorCollapsed) {
2803
+ // Ownership rule — see setDockCollapsed.
2804
+ set({ inspectorCollapsed, focusRestoreInspector: null });
2805
+ },
2806
+ /**
2807
+ * Every panel out of the way, or every panel back.
2808
+ *
2809
+ * Derived from the three flags rather than a fourth "focus mode" bit,
2810
+ * because a fourth bit has to answer "what does restoring mean when the
2811
+ * author collapsed one panel by hand first" and there is no good answer.
2812
+ * ANY panel open means the key hides them all; only when all three are
2813
+ * already hidden does it bring them back. So the first press always
2814
+ * reveals the stage, which is the whole point of the key.
2815
+ */
2816
+ togglePanels() {
2817
+ const s = store.getState();
2818
+ const anyOpen = !s.railCollapsed || !s.inspectorCollapsed || !s.dockCollapsed;
2819
+ set({
2820
+ railCollapsed: anyOpen,
2821
+ inspectorCollapsed: anyOpen,
2822
+ dockCollapsed: anyOpen,
2823
+ // The sweep is the loudest manual toggle of all — both slots pass to
2824
+ // the author (see setDockCollapsed).
2825
+ focusRestoreInspector: null,
2826
+ focusRestoreDock: null,
2827
+ });
2828
+ },
2829
+ setDockPpf(dockPpf) {
2830
+ set({ dockPpf: Math.max(0.2, Math.min(60, dockPpf)) });
2831
+ },
2832
+ attachDockFit(fit) {
2833
+ dockFit = fit;
2834
+ },
2835
+ fitDock(sceneId) {
2836
+ dockFit?.(sceneId);
2837
+ },
2838
+
2839
+ // ---- the audio lane ------------------------------------------------------
2840
+ /**
2841
+ * ONE undo entry for the asset AND the clip.
2842
+ *
2843
+ * The two writes are one `edit()` on purpose: an undo that removed the clip
2844
+ * and left an orphan asset behind, or removed the asset and left a clip
2845
+ * pointing at nothing, would be a document `validateShotDoc` refuses to
2846
+ * save — from the undo button.
2847
+ *
2848
+ * The DURATION comes from the STORED wav, never from the upload. The server
2849
+ * transcodes every audio import to canonical 48 kHz PCM, so the original
2850
+ * container's length and the stored one's differ by up to a partial frame,
2851
+ * and the clip has to be timed against the bytes the mixer will actually
2852
+ * read.
2853
+ */
2854
+ async importAudio(file, atFrame) {
2855
+ const doc = state.doc;
2856
+ if (doc === null) throw new Error("open a scene before importing audio");
2857
+ const gen = generation;
2858
+ const { uploaded } = await uploadOfKind(file, ["audio"], gen);
2859
+ const bytes = await api.fetchAssetBytes(uploaded.path);
2860
+ // The fence again, because there is a second await: `uploadOfKind` can
2861
+ // only speak for its own.
2862
+ if (gen !== generation) throw new Error(`${file.name} was imported into a scene that has closed`);
2863
+ const seconds = wavDurationSeconds(bytes);
2864
+ if (seconds === null || !(seconds > 0)) {
2865
+ throw new Error(`${file.name} stored, but its duration could not be read from the stored WAV`);
2866
+ }
2867
+ const at = Math.max(0, Math.round(atFrame ?? state.frame));
2868
+ store.edit((base) => {
2869
+ // Content-addressed: a re-import of identical bytes answers with a path
2870
+ // the document may already carry, and a second ShotAsset for one file
2871
+ // is a duplicate the mixer would fetch twice.
2872
+ const existing = Object.values(base.assets).find((a) => a.path === uploaded.path);
2873
+ const asset = existing ?? {
2874
+ id: freshId(usedIdsOf(base), ASSET_ID_PREFIX, slug(file.name.replace(/\.[^.]*$/, ""))),
2875
+ path: uploaded.path,
2876
+ hash: uploaded.hash,
2877
+ kind: "audio" as const,
2878
+ };
2879
+ const withAsset =
2880
+ existing === undefined ? { ...base, assets: { ...base.assets, [asset.id]: asset } } : base;
2881
+ return addAudioClip(withAsset, {
2882
+ asset: asset.id,
2883
+ frame: at,
2884
+ // Rounded UP: a clip one frame short of its source drops the tail,
2885
+ // and the mixer already emits silence past the end, so rounding the
2886
+ // other way costs nothing and losing audio costs the take.
2887
+ durationFrames: Math.max(1, Math.ceil((seconds * base.fps.num) / base.fps.den)),
2888
+ });
2889
+ });
2890
+ },
2891
+ dragAudio(clipId, mode, delta) {
2892
+ const active = gesture;
2893
+ if (active === null) return;
2894
+ active.scrub.capture((base) => writeAudioDrag(base, clipId, mode, delta));
2895
+ },
2896
+ dragAudioGain(clipId, gain) {
2897
+ const active = gesture;
2898
+ if (active === null) return;
2899
+ // MAX_AUDIO_GAIN is validate.ts's ceiling; clamping here rather than at
2900
+ // the handle keeps a live drag from ever building a refusable document.
2901
+ const clamped = Math.max(0, Math.min(MAX_AUDIO_GAIN, gain));
2902
+ active.scrub.capture((base) => updateAudioClip(base, clipId, { gain: clamped }));
2903
+ },
2904
+ toggleAudioMute(clipId) {
2905
+ const clip = state.doc?.audio[clipId];
2906
+ if (clip === undefined) return;
2907
+ store.edit((base) => updateAudioClip(base, clipId, { muted: !(clip.muted ?? false) }));
2908
+ },
2909
+ removeAudio(clipId) {
2910
+ store.edit((base) => removeAudioClip(base, clipId));
2911
+ },
2912
+ setPreviewMuted(previewMuted) {
2913
+ set({ previewMuted });
2914
+ },
2915
+ setPreviewVolume(volume) {
2916
+ set({ previewVolume: Math.max(0, Math.min(1, volume)) });
2917
+ },
2918
+
2919
+ selectMarker(selectedMarker) {
2920
+ // The BLOCK goes, for the reason a layer selection drops one: two
2921
+ // subjects live at once and `deleteSelection` takes the first, so a stale
2922
+ // block focus under a freshly clicked marker is a Delete whose target the
2923
+ // author cannot see.
2924
+ set(selectedMarker === null ? { selectedMarker } : { selectedMarker, selectedBlock: null });
2925
+ },
2926
+ addMarkerAtPlayhead() {
2927
+ const open = state.doc;
2928
+ if (open === null) return;
2929
+ const at = state.frame;
2930
+ const existing = (open.markers ?? []).find((m) => m.frame === at);
2931
+ if (existing !== undefined) {
2932
+ store.selectMarker(existing.id);
2933
+ return;
2934
+ }
2935
+ const before = new Set((open.markers ?? []).map((m) => m.id));
2936
+ store.edit((doc) => addMarker(doc, { frame: at }));
2937
+ // Read back rather than re-derived: `addMarker` mints the id from the
2938
+ // document's used ids, and a second guess at that rule is a second rule.
2939
+ const created = store.getState().doc?.markers?.find((m) => !before.has(m.id));
2940
+ if (created !== undefined) store.selectMarker(created.id);
2941
+ },
2942
+ dragMarker(markerId, frame) {
2943
+ const active = gesture;
2944
+ if (active === null) return;
2945
+ const at = Math.max(0, Math.round(frame));
2946
+ active.scrub.capture((base) => {
2947
+ // A move that lands where the mark already is returns the base BY
2948
+ // REFERENCE. `updateMarker` rebuilds the array unconditionally, so
2949
+ // without this a drag that travelled nowhere would still record an undo
2950
+ // entry and dirty the document.
2951
+ const marker = base.markers?.find((m) => m.id === markerId);
2952
+ return marker === undefined || marker.frame === at ? base : updateMarker(base, markerId, { frame: at });
2953
+ });
2954
+ },
2955
+ setMarker(markerId, patch) {
2956
+ store.edit((doc) => updateMarker(doc, markerId, patch));
2957
+ },
2958
+ removeMarker(markerId) {
2959
+ store.edit((doc) => removeMarker(doc, markerId));
2960
+ if (state.selectedMarker === markerId) set({ selectedMarker: null });
2961
+ },
2962
+ setAgentOpen(agentOpen) {
2963
+ set({ agentOpen });
2964
+ },
2965
+ setAgentModel(agentModel) {
2966
+ set({ agentModel });
2967
+ },
2968
+ setAgentCredentials(agentCredentials, agentCredentialsError) {
2969
+ set({ agentCredentials, agentCredentialsError: agentCredentialsError ?? null });
2970
+ },
2971
+ setAgentRunning(agentRunning) {
2972
+ set({ agentRunning });
2973
+ },
2974
+ setAgentError(agentError) {
2975
+ set({ agentError });
2976
+ },
2977
+ setAgentSession(agentSessionId) {
2978
+ set({ agentSessionId });
2979
+ },
2980
+ setAgentCost(agentCostUsd) {
2981
+ set({ agentCostUsd });
2982
+ },
2983
+ appendAgentItem(item) {
2984
+ set({ agentItems: [...state.agentItems, item] });
2985
+ },
2986
+ patchAgentProposal(id, patch) {
2987
+ // Through the SHARED reducer, so a card's status transitions mean the
2988
+ // same thing in every chrome.
2989
+ set({ agentItems: patchProposalIn(state.agentItems, id, patch) });
2990
+ },
2991
+ clearAgentTranscript() {
2992
+ // The COST goes with it. A stale "last turn ≈ $0.04" under an empty
2993
+ // transcript is a number about a conversation that is no longer there.
2994
+ set({ agentItems: [], agentError: null, agentSessionId: null, agentCostUsd: null });
2995
+ },
2996
+
2997
+ setExportOpen(exportOpen) {
2998
+ set({ exportOpen });
2999
+ },
3000
+ setRenderJob(renderJob) {
3001
+ // Late frames from a job that is already terminal are DROPPED. SSE has no
3002
+ // ordering guarantee across a reconnect, and a stale "frame 40/120"
3003
+ // arriving after "done" would put the progress bar back.
3004
+ const live = state.renderJob;
3005
+ if (
3006
+ renderJob !== null &&
3007
+ live !== null &&
3008
+ live.id === renderJob.id &&
3009
+ live.phase !== "running" &&
3010
+ renderJob.phase === "running"
3011
+ ) {
3012
+ return;
3013
+ }
3014
+ set({ renderJob });
3015
+ },
3016
+
3017
+ selectBlock(selectedBlock) {
3018
+ // …and the mirror of `selectMarker`: focusing a bar drops the mark, so
3019
+ // Delete addresses the thing the author just clicked.
3020
+ set(selectedBlock === null ? { selectedBlock } : { selectedBlock, selectedMarker: null });
3021
+ },
3022
+ beginBlockDrag() {
3023
+ if (docHistory === null) return;
3024
+ closeWheelFraming();
3025
+ // One scrub at a time, the camera lane included: a dock drag started
3026
+ // while the camera lane holds the history would throw out of engine.
3027
+ if (gesture !== null || framing !== null) return;
3028
+ // The SAME scrub machinery a canvas gesture uses, so a trim is one undo
3029
+ // entry and Escape restores the base exactly.
3030
+ gesture = { kind: "move", layerIds: [], scrub: docHistory.scrub(), screenScale: null, snap: null };
3031
+ },
3032
+ dragBlock(sceneId, layerId, blockId, edge, toFrame) {
3033
+ const active = gesture;
3034
+ if (active === null) return;
3035
+ // THE SCENE COMES FROM THE ROW, not from `state.sceneId`. The dock is one
3036
+ // master strip now, so the row under the pointer routinely belongs to a
3037
+ // scene the playhead is not in — and `state.sceneId` follows the playhead.
3038
+ active.scrub.capture((base) => {
3039
+ // The camera's row holds SHOTS, not blocks. Routing on the row id keeps
3040
+ // the dock's gesture identical for both while the document ops stay
3041
+ // separate — a shot list is sorted and non-overlapping and a block list
3042
+ // is neither, so one generic retimer would hide the re-sort.
3043
+ if (layerId === CAMERA_ROW_ID) {
3044
+ if (edge === "body") return moveShot(base, sceneId, blockId, toFrame);
3045
+ return trimShot(base, sceneId, blockId, edge, toFrame);
3046
+ }
3047
+ if (edge === "body") return moveBlock(base, sceneId, layerId, blockId, toFrame);
3048
+ return trimBlock(base, sceneId, layerId, blockId, edge, toFrame);
3049
+ });
3050
+ },
3051
+ dragLifespan(sceneId, layerId, mode, delta) {
3052
+ const active = gesture;
3053
+ if (active === null) return;
3054
+ // ONE capture for the bar AND its blocks — `writeLifespan` writes both, so
3055
+ // a body drag cannot leave the layer's existence and its animation half a
3056
+ // frame apart, and Escape restores them together.
3057
+ active.scrub.capture((base) => writeLifespan(base, sceneId, layerId, mode, delta));
3058
+ },
3059
+ endBlockDrag(commit) {
3060
+ const active = gesture;
3061
+ gesture = null;
3062
+ if (active === null) return;
3063
+ if (commit) active.scrub.commit();
3064
+ else active.scrub.discard();
3065
+ syncHistoryFlags();
3066
+ },
3067
+ trimScene(sceneId, durationFrames) {
3068
+ const active = gesture;
3069
+ if (active === null) {
3070
+ store.edit((doc) => trimScene(doc, sceneId, durationFrames));
3071
+ return;
3072
+ }
3073
+ active.scrub.capture((base) => trimScene(base, sceneId, durationFrames));
3074
+ },
3075
+ reorderScene(sceneId, toIndex) {
3076
+ store.edit((doc) => reorderScene(doc, sceneId, toIndex));
3077
+ },
3078
+ setSceneTransition(sceneId, next) {
3079
+ // A plain transaction: there is no drag gesture on a transition in v1, so
3080
+ // nothing to capture. The pure op deletes the key on null rather than
3081
+ // storing a "none", and is identity when there was nothing to clear — so
3082
+ // clearing a shot that has no transition adds no undo entry.
3083
+ store.edit((doc) => setSceneTransition(doc, sceneId, next));
3084
+ },
3085
+ addScene() {
3086
+ const open = state.doc;
3087
+ if (open === null) return;
3088
+ // ── THE ONE CREATE THAT CANNOT LAND AT THE PLAYHEAD ─────────────────
3089
+ //
3090
+ // Everything else this chrome creates goes where the playhead is. A SCENE
3091
+ // cannot: inserting one mid-document shifts every master frame after it,
3092
+ // and `doc.audio` and `doc.markers` are timed on MASTER frames (ops.ts) —
3093
+ // so the insert would slide every scene later while the score and the
3094
+ // beat marks stayed put. That is a retime of the whole video, and a
3095
+ // silent desync, dressed up as a create.
3096
+ //
3097
+ // So the rule inverts rather than bending: the scene is APPENDED, and the
3098
+ // PLAYHEAD goes to it. The author still ends up looking at the thing they
3099
+ // just made, which is the half of "at the playhead" that was ever about
3100
+ // them. Reordering it afterwards is a splice, and is what the strip's
3101
+ // drag is for.
3102
+ const before = new Set(open.scenes);
3103
+ store.edit((doc) => {
3104
+ const scene = createScene(usedIdsOf(doc), "scene", `Shot ${doc.scenes.length + 1}`);
3105
+ return {
3106
+ ...doc,
3107
+ scenes: [...doc.scenes, scene.id],
3108
+ scenesById: { ...doc.scenesById, [scene.id]: scene },
3109
+ };
3110
+ });
3111
+ // Read back rather than re-derived: `createScene` mints the id from the
3112
+ // document's used ids, and a second guess at that rule is a second rule.
3113
+ const created = store.getState().doc?.scenes.find((id) => !before.has(id));
3114
+ if (created !== undefined) store.focusScene(created);
3115
+ },
3116
+ splitAtPlayhead() {
3117
+ const sceneId = state.sceneId;
3118
+ const doc = state.doc;
3119
+ if (sceneId === null || doc === null) return;
3120
+ const sceneFrame = masterToSceneCached(doc, state.frame)?.sceneFrame ?? 0;
3121
+ const focused = state.selectedBlock;
3122
+ if (focused !== null) {
3123
+ // A block in ANOTHER scene has no frame under this playhead to split
3124
+ // at, so the author means the scene they are looking at rather than the
3125
+ // bar they last clicked three scenes back.
3126
+ if (focused.sceneId === sceneId) {
3127
+ store.edit((d) => splitBlock(d, sceneId, focused.layerId, focused.blockId, sceneFrame).doc);
3128
+ return;
3129
+ }
3130
+ }
3131
+ // No block focused: the author means the SCENE. Raylight's list gives
3132
+ // split one name and two subjects; the focused block is what picks.
3133
+ store.edit((d) => splitScene(d, sceneId, sceneFrame).doc);
3134
+ },
3135
+
3136
+ addBlock(slot, preset, durationFrames = 12) {
3137
+ const sceneId = state.sceneId;
3138
+ const ids = state.selection;
3139
+ if (sceneId === null || ids.length === 0 || state.doc === null) return;
3140
+ const sceneFrame = masterToSceneCached(state.doc, state.frame)?.sceneFrame ?? 0;
3141
+ let first: string | null = null;
3142
+ let refusal: string | null = null;
3143
+ const ran = store.edit((doc) => {
3144
+ const out = addPresetBlock(doc, sceneId, ids, slot, preset, durationFrames, sceneFrame);
3145
+ first = out.blockIds[0] ?? null;
3146
+ refusal = out.refusals[0] ?? null;
3147
+ return out.doc;
3148
+ });
3149
+ // A locked layer in the selection is skipped, and now says so.
3150
+ sayRefusal(ran, refusal);
3151
+ // Focus the new block, so the timing controls are about the thing that
3152
+ // just appeared rather than about nothing.
3153
+ if (first !== null) set({ selectedBlock: { sceneId, layerId: ids[0]!, blockId: first } });
3154
+ },
3155
+ setBlockPreset(layerId, blockId, preset) {
3156
+ const sceneId = state.sceneId;
3157
+ if (sceneId === null) return;
3158
+ // The PARAMS are dropped, deliberately. Params are preset-scoped: a
3159
+ // `move`'s dx means nothing to `roll`, and carrying it over would leave
3160
+ // a stale key that the new preset's spec ignores until someone switches
3161
+ // back and finds a number they never typed. `resolveParams` fills the
3162
+ // new preset's own defaults.
3163
+ store.edit((doc) => setBlockPreset(doc, sceneId, layerId, blockId, preset));
3164
+ },
3165
+ removeBlock(layerId, blockId) {
3166
+ const sceneId = state.sceneId;
3167
+ if (sceneId === null) return;
3168
+ store.edit((doc) => removeBlock(doc, sceneId, layerId, blockId));
3169
+ const focused = state.selectedBlock;
3170
+ if (focused?.blockId === blockId) set({ selectedBlock: null });
3171
+ },
3172
+ removeBlocksByPreset(slot, preset) {
3173
+ const sceneId = state.sceneId;
3174
+ const doc = state.doc;
3175
+ if (sceneId === null || doc === null) return;
3176
+ const scene = doc.scenesById[sceneId];
3177
+ if (scene === undefined) return;
3178
+ // ONE transaction over the whole selection: un-clicking "Fade" on four
3179
+ // layers is one thing the author did, so it is one undo entry.
3180
+ const ids = state.selection;
3181
+ store.edit((d) => {
3182
+ let next = d;
3183
+ for (const layerId of ids) {
3184
+ const layer = scene.layersById[layerId];
3185
+ if (layer === undefined) continue;
3186
+ for (const block of layer.blocks) {
3187
+ if (block.slot === slot && block.preset === preset) {
3188
+ next = removeBlock(next, sceneId, layerId, block.id);
3189
+ }
3190
+ }
3191
+ }
3192
+ return next;
3193
+ });
3194
+ },
3195
+ setBlockParams(layerId, blockId, params) {
3196
+ const sceneId = state.sceneId;
3197
+ if (sceneId === null) return;
3198
+ store.edit((doc) => setBlockParams(doc, sceneId, layerId, blockId, params));
3199
+ },
3200
+ setBlockEase(layerId, blockId, ease) {
3201
+ const sceneId = state.sceneId;
3202
+ if (sceneId === null) return;
3203
+ store.edit((doc) => setBlockEase(doc, sceneId, layerId, blockId, ease));
3204
+ },
3205
+ setBlockTiming(layerId, blockId, frame, durationFrames) {
3206
+ const sceneId = state.sceneId;
3207
+ if (sceneId === null) return;
3208
+ // Through the SAME two ops the dock's handles use, so typing a number and
3209
+ // dragging an edge can never disagree about what a trim means.
3210
+ store.edit((doc) => {
3211
+ const moved = moveBlock(doc, sceneId, layerId, blockId, frame);
3212
+ return trimBlock(moved, sceneId, layerId, blockId, "out", frame + durationFrames);
3213
+ });
3214
+ },
3215
+
3216
+ /**
3217
+ * ⌘A selects everything IN SCOPE, which is not the same as everything.
3218
+ *
3219
+ * ── TWO HOLES, ONE RULE ─────────────────────────────────────────────
3220
+ *
3221
+ * It took the whole scene, so ⌘A then Delete inside a card removed the
3222
+ * entire scene. Undo recovers it, which is the only reason this is a p1.
3223
+ *
3224
+ * And with a component DEFINITION open in the outline it selected the
3225
+ * scene's layers, whose ids live in another home — `select`'s
3226
+ * `leftDefinition` path then set `editingDef: null` and silently kicked the
3227
+ * author out of the definition they were editing.
3228
+ *
3229
+ * The scope the OUTLINE shows is the one an author means, so both come from
3230
+ * the same pair `FilePanel` reads: the open definition, else the scene
3231
+ * narrowed by isolation. Isolation does not apply inside a definition —
3232
+ * a definition's layers are in no scene and isolation is a spatial move.
3233
+ *
3234
+ * The isolated ROOT is excluded and its subtree included. `withDescendants`
3235
+ * includes its seed, so the set is narrowed by hand: selecting the card you
3236
+ * are inside, from inside it, is how a ⌘A then a drag moves the boundary
3237
+ * out from under the author.
3238
+ */
3239
+ selectAll() {
3240
+ const doc = state.doc;
3241
+ const home = doc === null ? null : homeOf(doc, state.editingDef ?? state.sceneId ?? "");
3242
+ if (home === null) return;
3243
+ const all = Object.keys(home.layersById);
3244
+ if (state.editingDef !== null || state.isolation === null) {
3245
+ select(all);
3246
+ return;
3247
+ }
3248
+ const inside = withDescendants(home, [state.isolation]);
3249
+ select(all.filter((id) => id !== state.isolation && inside.has(id)));
3250
+ },
3251
+
3252
+ // ---- the insertion lane ----------------------------------------------------
3253
+ async completeImageDraw(file) {
3254
+ const points = state.imagePick;
3255
+ // Cleared FIRST, and on every path. A pending box that outlived its pick
3256
+ // would be picked up by the next file the author chooses for something
3257
+ // else entirely.
3258
+ set({ imagePick: null });
3259
+ if (file === null || points === null) return;
3260
+ const gen = generation;
3261
+ // THE SERVER DECIDES WHICH KIND THE BOX BECOMES. One drawn box takes
3262
+ // either media file, and the fork is on the store's sniff rather than on
3263
+ // anything read here — a browser MIME guess that disagreed with the
3264
+ // extension map would mint a `video` layer over an image's bytes, which
3265
+ // the save gate accepts and the texture cache renders as a black plane.
3266
+ const { uploaded, kind } = await uploadOfKind(file, ["image", "video"], gen);
3267
+ insertAssetLayer(uploaded, file.name, kind, (asset, ctx) =>
3268
+ layerFromDraw("image", points, { ...ctx, asset, assetKind: kind }),
3269
+ );
3270
+ },
3271
+ async addMeshLayer(file) {
3272
+ // BEFORE THE UPLOAD. The moment a model is added is the only moment
3273
+ // refusing costs nothing — nothing is built on it yet, and the author is
3274
+ // still holding the file. It is measured against what has ALREADY
3275
+ // loaded, because a GLB's count is unknowable until it is parsed; the
3276
+ // effect is a cap on growth, never a lock on an existing document.
3277
+ if (sumTriangles(state.meshTriangles) >= TRIANGLE_HARD_CAP) {
3278
+ set({ error: TRIANGLE_CAP_REFUSAL });
3279
+ return;
3280
+ }
3281
+ const gen = generation;
3282
+ const { uploaded } = await uploadOfKind(file, ["model"], gen);
3283
+ insertAssetLayer(uploaded, file.name, "model", (asset, ctx) => meshLayer(asset, ctx));
3284
+ },
3285
+ addDeviceLayer(preset) {
3286
+ const at = insertContext();
3287
+ if (at === null) return;
3288
+ insertLayer(deviceLayer(preset, at.ctx));
3289
+ },
3290
+ addHtmlLayer() {
3291
+ const at = insertContext();
3292
+ if (at === null) return;
3293
+ insertLayer(htmlLayer(at.ctx));
3294
+ },
3295
+ addClonerLayer() {
3296
+ const sceneId = state.sceneId;
3297
+ const ids = state.selection;
3298
+ if (sceneId === null || ids.length === 0) return;
3299
+ let created: string | null = null;
3300
+ store.edit((doc) => {
3301
+ const out = clonerLayers(doc, sceneId, ids);
3302
+ created = out.id;
3303
+ return out.doc;
3304
+ });
3305
+ // The CLONER becomes the selection, not the layers it swallowed: they are
3306
+ // templates now, and the thing the author places next is the cloner.
3307
+ if (created !== null) select([created]);
3308
+ },
3309
+
3310
+ // ---- components (reusable subtrees) ----------------------------------------
3311
+ createComponentFromSelection(name) {
3312
+ const sceneId = state.sceneId;
3313
+ const ids = state.selection;
3314
+ if (sceneId === null || ids.length === 0) return;
3315
+ let instance: string | null = null;
3316
+ let refusal: string | null = null;
3317
+ const ran = store.edit((doc) => {
3318
+ const out = createComponent(doc, sceneId, ids, name);
3319
+ instance = out.instanceId;
3320
+ refusal = out.refusal ?? null;
3321
+ return out.doc;
3322
+ });
3323
+ sayRefusal(ran, refusal);
3324
+ // The INSTANCE becomes the selection, not the layers it swallowed: they
3325
+ // are a definition now, and the thing the author places next is a
3326
+ // placement of it. Same rule the cloner follows.
3327
+ if (instance !== null) select([instance]);
3328
+ },
3329
+ placeComponent(defId) {
3330
+ const sceneId = state.sceneId;
3331
+ if (sceneId === null) return;
3332
+ let instance: string | null = null;
3333
+ let refusal: string | null = null;
3334
+ const ran = store.edit((doc) => {
3335
+ const out = addComponentInstance(doc, sceneId, defId);
3336
+ instance = out.instanceId;
3337
+ refusal = out.refusal ?? null;
3338
+ return out.doc;
3339
+ });
3340
+ sayRefusal(ran, refusal);
3341
+ if (instance !== null) select([instance]);
3342
+ },
3343
+ deleteComponent(defId) {
3344
+ let refusal: string | null = null;
3345
+ const ran = store.edit((doc) => {
3346
+ const out = deleteComponent(doc, defId);
3347
+ refusal = out.refusal ?? null;
3348
+ return out.doc;
3349
+ });
3350
+ // Leaving a definition the author just deleted as the outline's subject
3351
+ // would scope it to nothing. `ran` guards it too: a refused edit deleted
3352
+ // nothing, so the outline must keep pointing at what is still there.
3353
+ if (ran && refusal === null && state.editingDef === defId) set({ editingDef: null });
3354
+ sayRefusal(ran, refusal);
3355
+ },
3356
+ renameComponent(defId, name) {
3357
+ store.edit((doc) => renameComponent(doc, defId, name));
3358
+ },
3359
+ setComponentOverride(layerId, key, value) {
3360
+ const sceneId = state.sceneId;
3361
+ if (sceneId === null) return;
3362
+ store.edit((doc) => {
3363
+ const home = homeOf(doc, sceneId);
3364
+ const layer = home?.layersById[layerId];
3365
+ if (home === null || layer === undefined || layer.kind !== "component") return doc;
3366
+ const overrides = { ...layer.component.overrides };
3367
+ // Writing the DEFAULT clears the override rather than storing it: an
3368
+ // instance that says nothing is an instance that follows its
3369
+ // definition, and a stored value that happens to equal the default
3370
+ // would stop following it the moment the definition changed.
3371
+ if (value === null) delete overrides[key];
3372
+ else overrides[key] = value;
3373
+ return patchHome(doc, sceneId, {
3374
+ layers: home.layers,
3375
+ layersById: {
3376
+ ...home.layersById,
3377
+ [layerId]: { ...layer, component: { ...layer.component, overrides } },
3378
+ },
3379
+ });
3380
+ });
3381
+ },
3382
+ editComponent(defId) {
3383
+ // The OUTLINE's subject, not the stage's: entering a definition is a
3384
+ // structural move (its layers live in no scene and have no camera), while
3385
+ // isolation is a spatial one. Keeping them separate is what stops "what
3386
+ // am I looking at" and "what am I editing" from having to agree.
3387
+ //
3388
+ // THE SELECTION GOES WITH IT. Changing the container while a selection
3389
+ // from the old one survives leaves every container-scoped verb pointed
3390
+ // at a home its own subject is not in — `toggleFlag` reads `undefined`
3391
+ // and writes into the wrong forest, so ⌥H and ⌥L become silent no-ops.
3392
+ // The usual way in is ComponentSection's "edit definition" with the
3393
+ // INSTANCE selected, which is exactly that state.
3394
+ if (defId === state.editingDef) return;
3395
+ set({ editingDef: defId, selection: [], selectedBlock: null });
3396
+ bridge?.setSelection([]);
3397
+ },
3398
+
3399
+ // ---- the component registry (instance layers) ------------------------------
3400
+ async refreshLibrary() {
3401
+ try {
3402
+ const library = await api.listComponents();
3403
+ set({ library, libraryLoaded: true, libraryError: null });
3404
+ } catch (e) {
3405
+ // NOT `error`: a registry that will not answer is a fact about this
3406
+ // panel, and putting it in the document-error strip would read as "your
3407
+ // shot failed to save".
3408
+ set({ libraryError: e instanceof Error ? e.message : String(e) });
3409
+ }
3410
+ },
3411
+
3412
+ async loadContract(componentId) {
3413
+ if (state.contracts[componentId] !== undefined) return;
3414
+ try {
3415
+ const detail = await api.componentDetail(componentId);
3416
+ set({ contracts: { ...store.getState().contracts, [componentId]: detail.contract } });
3417
+ } catch {
3418
+ // Silent: the inspector falls back to the snapshot the document carries,
3419
+ // which is exactly why a snapshot is taken. A component that has left
3420
+ // the project stays editable rather than going dead.
3421
+ }
3422
+ },
3423
+
3424
+ async addInstanceLayer(componentId) {
3425
+ if (!state.libraryLoaded) await store.refreshLibrary();
3426
+ const row = store.getState().library.find((r) => r.id === componentId);
3427
+ if (row === undefined) {
3428
+ store.reportError(`no component ${JSON.stringify(componentId)} in the registry`);
3429
+ return;
3430
+ }
3431
+ if (row.host !== "three") {
3432
+ // The same refusal the save gate makes, made EARLIER so the menu can
3433
+ // never produce a document the server would reject. One rule, two
3434
+ // places it is enforced, and the server's is the authority.
3435
+ store.reportError(
3436
+ `${row.name} is a "${row.host}" component — an instance layer mounts inside the scene's ` +
3437
+ `3D canvas, so only three-class components can be instanced.`,
3438
+ );
3439
+ return;
3440
+ }
3441
+ let detail: api.ComponentDetail;
3442
+ try {
3443
+ detail = await api.componentDetail(componentId);
3444
+ } catch (e) {
3445
+ store.reportError(e instanceof Error ? e.message : String(e));
3446
+ return;
3447
+ }
3448
+ set({ contracts: { ...store.getState().contracts, [componentId]: detail.contract } });
3449
+ const at = insertContext();
3450
+ if (at === null) return;
3451
+ insertLayer(instanceLayer(componentId, row.name, bindingFor(detail), at.ctx));
3452
+ },
3453
+
3454
+ setInstanceProp(layerId, key, value) {
3455
+ patchInstance(layerId, (inst) => ({ ...inst, props: { ...inst.props, [key]: value } }));
3456
+ },
3457
+
3458
+ clearInstanceProp(layerId, key) {
3459
+ patchInstance(layerId, (inst) => {
3460
+ if (!(key in inst.props)) return inst;
3461
+ const props = { ...inst.props };
3462
+ delete props[key];
3463
+ return { ...inst, props };
3464
+ });
3465
+ },
3466
+
3467
+ setInstanceClockOffset(layerId, frames) {
3468
+ const offset = Math.round(frames);
3469
+ patchInstance(layerId, (inst) => {
3470
+ // Absent IS zero everywhere in this lane, and codegen prints the block
3471
+ // by key order — so a present-but-zero key is a key in the emitted file
3472
+ // that says nothing.
3473
+ if (offset === 0) {
3474
+ const { clockOffsetFrames: _drop, ...rest } = inst;
3475
+ return rest;
3476
+ }
3477
+ return { ...inst, clockOffsetFrames: offset };
3478
+ });
3479
+ },
3480
+
3481
+ async rebindInstance(layerId) {
3482
+ const doc = state.doc;
3483
+ const sceneId = state.sceneId;
3484
+ if (doc === null || sceneId === null) return "no document";
3485
+ const layer = doc.scenesById[sceneId]?.layersById[layerId];
3486
+ if (layer === undefined || layer.kind !== "instance") return "not an instance layer";
3487
+ const componentId = layer.instance.component;
3488
+ let detail: api.ComponentDetail;
3489
+ try {
3490
+ detail = await api.componentDetail(componentId);
3491
+ } catch (e) {
3492
+ return e instanceof Error ? e.message : String(e);
3493
+ }
3494
+ set({ contracts: { ...store.getState().contracts, [componentId]: detail.contract } });
3495
+ // The plan is computed against the contract the props were AUTHORED
3496
+ // against (the frozen snapshot when the document carries one), because a
3497
+ // key's last-known KIND is what lets the planner refuse to rebind a curve
3498
+ // onto an asset just because both ride an engine "string" track.
3499
+ const ref = layer.instance.contract;
3500
+ const frozen = ref?.source === "snapshot" ? ref.contract : (store.getState().contracts[componentId] ?? null);
3501
+ const ops = instancePropPlan(layer.instance.props, frozen, detail.contract);
3502
+ patchInstance(layerId, (inst) => ({
3503
+ ...inst,
3504
+ props: applyInstanceRebinds(inst.props, ops),
3505
+ ...bindingFor(detail),
3506
+ }));
3507
+ return null;
3508
+ },
3509
+
3510
+ setScene(sceneId) {
3511
+ // Pinning another scene leaves the isolated subtree off screen exactly as
3512
+ // scrubbing into one does, so it drops through the same helper.
3513
+ const isolation = isolationForScene(sceneId);
3514
+ const wasIsolated = state.isolation;
3515
+ set({ sceneId, isolation, ...(isolation === null ? { isolationOrbit: false } : {}) });
3516
+ if (isolation !== wasIsolated) bridge?.setIsolation(isolation);
3517
+ },
3518
+ setView(view) {
3519
+ set({ view });
3520
+ bridge?.setView(view);
3521
+ },
3522
+ fitView() {
3523
+ const view = bridge?.fitView();
3524
+ if (view !== undefined) set({ view });
3525
+ else set({ view: INITIAL_VIEW });
3526
+ },
3527
+ zoomActivePane(direction) {
3528
+ if (state.activePane === "working") {
3529
+ // Distance is the RECIPROCAL of zoom, so zooming in is dollying in.
3530
+ // The same navigation band the stage's wheel obeys, so a button press
3531
+ // and a notch cannot walk to different places. No document means no fit
3532
+ // to derive it from, and the sanitisation clamp still holds.
3533
+ const bounds = state.doc === null ? undefined : dollyBounds(state.doc.size);
3534
+ store.setWorkingView(
3535
+ dollyBy(state.workingView, direction === 1 ? -WORKING_ZOOM_STEP_PX : WORKING_ZOOM_STEP_PX, bounds),
3536
+ );
3537
+ return;
3538
+ }
3539
+ store.setView({ ...state.view, zoom: clampZoom(state.view.zoom * (direction === 1 ? 1.25 : 0.8)) });
3540
+ },
3541
+ fitActivePane() {
3542
+ if (state.activePane === "working") store.fitWorkingView();
3543
+ else store.fitView();
3544
+ },
3545
+ setStageLayout(layout) {
3546
+ const stageLayout: StageLayout = {
3547
+ mode: layout.mode,
3548
+ splitRatio: clampSplitRatio(layout.splitRatio),
3549
+ };
3550
+ // THE ACTIVE PANE MUST EXIST. Collapsing to one pane while the zoom keys
3551
+ // are pointed at the other would leave ⇧Z fitting a pane nobody can see,
3552
+ // which reads as "the fit shortcut stopped working".
3553
+ const activePane: StagePane =
3554
+ stageLayout.mode === "split" ? state.activePane : stageLayout.mode;
3555
+ // ── FOCUS MODE: the working view minimises the chrome ───────────────
3556
+ //
3557
+ // Entering "working" snapshots the inspector and dock collapse bits and
3558
+ // collapses both — that view is for arranging in 3D, and the inspector
3559
+ // and timeline are about the shot. The RAIL STAYS: the layer tree is
3560
+ // the one panel that view needs. Leaving restores whatever was
3561
+ // snapshotted; a null slot means the author toggled that panel inside
3562
+ // the mode and took ownership (the collapse verbs null the slots), so
3563
+ // their choice survives the exit. ONE set() for the whole switch —
3564
+ // gestures.test.ts counts notifications.
3565
+ const entering = stageLayout.mode === "working" && state.stageLayout.mode !== "working";
3566
+ const leaving = stageLayout.mode !== "working" && state.stageLayout.mode === "working";
3567
+ set({
3568
+ stageLayout,
3569
+ activePane,
3570
+ ...(entering
3571
+ ? {
3572
+ focusRestoreInspector: state.inspectorCollapsed,
3573
+ focusRestoreDock: state.dockCollapsed,
3574
+ inspectorCollapsed: true,
3575
+ dockCollapsed: true,
3576
+ }
3577
+ : {}),
3578
+ ...(leaving
3579
+ ? {
3580
+ ...(state.focusRestoreInspector === null
3581
+ ? {}
3582
+ : { inspectorCollapsed: state.focusRestoreInspector }),
3583
+ ...(state.focusRestoreDock === null ? {} : { dockCollapsed: state.focusRestoreDock }),
3584
+ focusRestoreInspector: null,
3585
+ focusRestoreDock: null,
3586
+ }
3587
+ : {}),
3588
+ });
3589
+ bridge?.setStageLayout(stageLayout);
3590
+ },
3591
+ setWorkingView(workingView) {
3592
+ set({ workingView });
3593
+ bridge?.setWorkingView(workingView);
3594
+ // FOLLOW IS FOLLOW, whatever moved the view. The chrome's keyboard zoom
3595
+ // reaches the working camera without going through the stage's
3596
+ // `onWorkingViewChange`, so leaving it out here made the badge over the
3597
+ // pane say "camera follows this view" while it silently did not — and the
3598
+ // next mouse orbit crossed the whole accumulated gap in one jump.
3599
+ if (state.cameraFollowsView) writeFramingFromView(workingView);
3600
+ },
3601
+ fitWorkingView() {
3602
+ // HOME IS THE RESET, and inside a subtree that means back to the derived
3603
+ // head-on rig rather than to the canvas fit. It is the exact pose the
3604
+ // author entered on, because it goes back to the derivation instead of
3605
+ // trying to represent it in the orbit rig's five numbers.
3606
+ if (state.isolation !== null) {
3607
+ if (!state.isolationOrbit) return;
3608
+ set({ isolationOrbit: false });
3609
+ bridge?.setIsolationOrbit(false);
3610
+ return;
3611
+ }
3612
+ // The STAGE derives it, for the reason `fitView` does: the derivation
3613
+ // depends on the working camera's lens, and the stage owns that lens.
3614
+ const workingView = bridge?.fitWorkingView();
3615
+ if (workingView === undefined) return;
3616
+ set({ workingView });
3617
+ if (state.cameraFollowsView) writeFramingFromView(workingView);
3618
+ },
3619
+ fitWorkingViewToSelection() {
3620
+ const doc = state.doc;
3621
+ const sceneId = state.sceneId;
3622
+ const scene = doc === null || sceneId === null ? undefined : doc.scenesById[sceneId];
3623
+ if (doc === null || scene === undefined) return;
3624
+ // Numpad-dot inside a subtree is the same reset Home is: the derived rig
3625
+ // already frames exactly what the author is inside, so re-deriving a
3626
+ // perspective pose for it would answer a request to go back with a
3627
+ // different view.
3628
+ if (state.isolation !== null) {
3629
+ store.fitWorkingView();
3630
+ return;
3631
+ }
3632
+ // Frame all on an empty selection — the graceful half of the contract.
3633
+ if (state.selection.length === 0) {
3634
+ store.fitWorkingView();
3635
+ return;
3636
+ }
3637
+ // The same derivation `fitCameraToSelection` uses — playhead as input,
3638
+ // the stage's own FIT_MARGIN — pointed at the WORKING view instead of
3639
+ // the document: the box becomes a target and a distance.
3640
+ const sceneFrame = masterToSceneCached(doc, state.frame)?.sceneFrame ?? 0;
3641
+ const box = framingForLayers(scene, state.selection, sceneFrame, doc.fps, doc.size, FIT_MARGIN);
3642
+ if (box === null) {
3643
+ store.fitWorkingView();
3644
+ return;
3645
+ }
3646
+ // KEEPING the author's angles: numpad-dot moves the target and the
3647
+ // distance, never where they stand.
3648
+ // ponytail: the distance frames the box's HEIGHT head on, so it
3649
+ // under-fits slightly at oblique angles; upgrade path is the diagonal
3650
+ // derivation defaultWorkingView uses.
3651
+ const head = workingViewForFraming(box, doc.size);
3652
+ store.setWorkingView({
3653
+ ...head,
3654
+ theta: state.workingView.theta,
3655
+ phi: state.workingView.phi,
3656
+ // …AND THE LENS. `workingViewForFraming` rebuilds a view from scratch
3657
+ // and so carries no `ortho`, which made numpad-dot silently drop an
3658
+ // orthographic pane back to perspective — a fit is a change of DISTANCE
3659
+ // and TARGET, never of projection.
3660
+ ...(state.workingView.ortho === true ? { ortho: true } : {}),
3661
+ });
3662
+ },
3663
+ setActivePane(activePane) {
3664
+ set({ activePane });
3665
+ },
3666
+ setCameraFollowsView(cameraFollowsView) {
3667
+ set({ cameraFollowsView });
3668
+ // The stage needs it to bracket its own orbit and to draw the badge — see
3669
+ // bridgeTypes. Arming it does NOT write anything: the first orbit does,
3670
+ // and an author who armed it by accident can disarm it before moving.
3671
+ bridge?.setCameraFollowsView(cameraFollowsView);
3672
+ },
3673
+ setShadingMode(shadingMode) {
3674
+ // The mode BEFORE this one, so ⇧Z can come back to it. Only when the two
3675
+ // differ: pressing wireframe twice must not make wireframe its own
3676
+ // "previous" and strand the toggle.
3677
+ set({
3678
+ shadingMode,
3679
+ ...(shadingMode === state.shadingMode ? {} : { previousShading: state.shadingMode }),
3680
+ });
3681
+ bridge?.setShadingMode(shadingMode);
3682
+ },
3683
+
3684
+ openMaterialPicker(layerIds) {
3685
+ const sceneId = state.sceneId;
3686
+ const scene = state.doc === null || sceneId === null ? undefined : state.doc.scenesById[sceneId];
3687
+ if (scene === undefined) return;
3688
+ // MESH LAYERS ONLY. A material is a mesh's albedo; there is nothing to
3689
+ // paint on a group or a text plane, and opening a picker over a
3690
+ // selection that cannot take one would be a dialog with no effect.
3691
+ const meshes = layerIds.filter((id) => scene.layersById[id]?.kind === "mesh");
3692
+ if (meshes.length === 0) {
3693
+ set({ error: "A material paints a model's surface, so it needs a mesh layer selected." });
3694
+ return;
3695
+ }
3696
+ set({ materialPicker: { layerIds: meshes }, stageMenu: null });
3697
+ },
3698
+ closeMaterialPicker() {
3699
+ if (state.materialPicker !== null) set({ materialPicker: null });
3700
+ },
3701
+ applyMeshMaterial(layerIds, generatorId) {
3702
+ const sceneId = state.sceneId;
3703
+ if (sceneId === null) return;
3704
+ const ran = store.edit((doc) => {
3705
+ const scene = doc.scenesById[sceneId];
3706
+ if (scene === undefined) return doc;
3707
+ const layersById = { ...scene.layersById };
3708
+ let changed = false;
3709
+ for (const id of layerIds) {
3710
+ const layer = layersById[id];
3711
+ if (layer === undefined || layer.kind !== "mesh") continue;
3712
+ // PARAMS RESET. A new generator publishes a new vocabulary of keys,
3713
+ // and carrying the old ones forward would leave values under names
3714
+ // the new contract does not have — invisible in the inspector and
3715
+ // written to disk forever.
3716
+ layersById[id] = { ...layer, mesh: { ...layer.mesh, material: { generator: generatorId } } };
3717
+ changed = true;
3718
+ }
3719
+ if (!changed) return doc;
3720
+ return { ...doc, scenesById: { ...doc.scenesById, [sceneId]: { ...scene, layersById } } };
3721
+ });
3722
+ // `edit` has already said HISTORY_BUSY if a drag is holding the scrub;
3723
+ // this clears any stale message once the write actually landed.
3724
+ sayRefusal(ran, null);
3725
+ if (!ran) return;
3726
+ // ── AND SHOW IT ────────────────────────────────────────────────────
3727
+ //
3728
+ // The working pane paints `scene.overrideMaterial = clay` in solid and
3729
+ // wireframe mode, so a material applied there is invisible and the
3730
+ // author's honest reading is "nothing happened". The camera pane already
3731
+ // shows it (it is the export), so this is a working-pane courtesy: one
3732
+ // switch, only from the two modes that hide it, and only when a working
3733
+ // pane is on screen. Workspace state, never a document write.
3734
+ if (
3735
+ state.stageLayout.mode !== "camera" &&
3736
+ (state.shadingMode === "solid" || state.shadingMode === "wireframe")
3737
+ ) {
3738
+ store.setShadingMode("material");
3739
+ }
3740
+ set({ materialPicker: null });
3741
+ },
3742
+ removeMeshMaterial(layerIds) {
3743
+ const sceneId = state.sceneId;
3744
+ if (sceneId === null) return;
3745
+ const ran = store.edit((doc) => {
3746
+ const scene = doc.scenesById[sceneId];
3747
+ if (scene === undefined) return doc;
3748
+ const layersById = { ...scene.layersById };
3749
+ let changed = false;
3750
+ for (const id of layerIds) {
3751
+ const layer = layersById[id];
3752
+ if (layer === undefined || layer.kind !== "mesh" || layer.mesh.material === undefined) continue;
3753
+ // DELETE THE KEY, never write `undefined`: the document is JSON, and
3754
+ // `material: undefined` survives a spread but not a round trip, so
3755
+ // the two halves would disagree about whether it is set.
3756
+ const { material: _dropped, ...mesh } = layer.mesh;
3757
+ layersById[id] = { ...layer, mesh };
3758
+ changed = true;
3759
+ }
3760
+ if (!changed) return doc;
3761
+ return { ...doc, scenesById: { ...doc.scenesById, [sceneId]: { ...scene, layersById } } };
3762
+ });
3763
+ sayRefusal(ran, null);
3764
+ },
3765
+
3766
+ alignCameraToWorkingView() {
3767
+ writeFramingFromView(state.workingView);
3768
+ },
3769
+ fitCameraToSelection() {
3770
+ const doc = state.doc;
3771
+ const sceneId = state.sceneId;
3772
+ const scene = doc === null || sceneId === null ? undefined : doc.scenesById[sceneId];
3773
+ if (doc === null || scene === undefined) return;
3774
+ if (state.selection.length === 0) {
3775
+ set({ error: "Select the layers you want in shot, then fit the camera to them." });
3776
+ return;
3777
+ }
3778
+ // The PLAYHEAD is an input, exactly as it is for align: bounds are
3779
+ // measured on what is rendered at this frame, so fitting at frame 0 and
3780
+ // at frame 40 are different edits on a layer a block is moving.
3781
+ const sceneFrame = masterToSceneCached(doc, state.frame)?.sceneFrame ?? 0;
3782
+ // The stage's own FIT_MARGIN, so "fit" means one thing in this lane
3783
+ // whether the author fits the view or the camera.
3784
+ const box = framingForLayers(scene, state.selection, sceneFrame, doc.fps, doc.size, FIT_MARGIN);
3785
+ if (box === null) {
3786
+ set({
3787
+ error:
3788
+ "That selection has no width or height at this frame, so there is nothing for the " +
3789
+ "camera to frame.",
3790
+ });
3791
+ return;
3792
+ }
3793
+ writeCamera((current) => ({ ...current, ...box }));
3794
+ },
3795
+ setSceneCamera(patch) {
3796
+ // THROUGH `writeCamera`, which is the whole point: the inspector is a
3797
+ // keyboard frustum. It gets the refusal-by-name under a shot and the
3798
+ // animate fork for free, and there is no second answer to "what does
3799
+ // writing the camera at this frame mean".
3800
+ //
3801
+ // The read side is coherent for free too. `shotOwningCamera` is null
3802
+ // exactly when `cameraAt` returns `scene.camera`, so whenever the base
3803
+ // write is ALLOWED the effective value the fields show IS the base, so a
3804
+ // patch of one field can never carry a stale sibling into the document.
3805
+ writeCamera((current) => ({ ...current, ...patch }));
3806
+ },
3807
+ setShotFraming(shotId, end, patch) {
3808
+ const sceneId = state.sceneId;
3809
+ if (sceneId === null) return;
3810
+ store.edit((doc) =>
3811
+ patchShotIn(doc, sceneId, shotId, (shot) => {
3812
+ // A path move has no `from` or `to` — its poses are its waypoints,
3813
+ // and there is no waypoint this writer could pick that the author
3814
+ // meant. The inspector does not offer these fields for one, so this
3815
+ // is a no-op rather than a refusal with nowhere to show.
3816
+ if (shot.kind === "path") return shot;
3817
+ const next = clampFraming({ ...shot[end], ...patch });
3818
+ if (sameFraming(next, shot[end])) return shot;
3819
+ return end === "from" ? { ...shot, from: next } : { ...shot, to: next };
3820
+ }),
3821
+ );
3822
+ },
3823
+ setShotKind(shotId, kind) {
3824
+ const sceneId = state.sceneId;
3825
+ if (sceneId === null) return;
3826
+ store.edit((doc) =>
3827
+ patchShotIn(doc, sceneId, shotId, (shot) =>
3828
+ shot.kind === kind || shot.kind === "path" ? shot : { ...shot, kind },
3829
+ ),
3830
+ );
3831
+ },
3832
+ setShotEase(shotId, ease) {
3833
+ const sceneId = state.sceneId;
3834
+ if (sceneId === null) return;
3835
+ store.edit((doc) => patchShotIn(doc, sceneId, shotId, (shot) => ({ ...shot, ease })));
3836
+ },
3837
+ addShotAtPlayhead(askedFor) {
3838
+ const doc = state.doc;
3839
+ const sceneId = state.sceneId;
3840
+ const scene = doc === null || sceneId === null ? undefined : doc.scenesById[sceneId];
3841
+ if (doc === null || sceneId === null || scene === undefined) return;
3842
+ // EVERY scene's camera row has a "+ shot", and only one of them is under
3843
+ // the playhead. Refused BY NAME rather than quietly retargeted or
3844
+ // silently seeking: "at the playhead" is the whole rule for where a new
3845
+ // thing lands, and a button that moved the playhead to make its own rule
3846
+ // true would be a create with a hidden navigation inside it.
3847
+ if (askedFor !== undefined && askedFor !== sceneId) {
3848
+ set({
3849
+ error:
3850
+ `The playhead is in "${scene.name}", so there is nowhere in ` +
3851
+ `"${doc.scenesById[askedFor]?.name ?? askedFor}" to put a camera move. ` +
3852
+ `Move the playhead into that shot first.`,
3853
+ });
3854
+ return;
3855
+ }
3856
+ // ⇧C is a KEY, so it can arrive mid-drag with no pointer of its own,
3857
+ // which is the hole ⌥C had. `edit` closes a wheel's scrub itself; a
3858
+ // pointer's it cannot, and `transaction()` throws on it.
3859
+ if (gesture !== null || (framing !== null && wheelFraming === null)) {
3860
+ set({ error: HISTORY_BUSY });
3861
+ return;
3862
+ }
3863
+ // AT THE PLAYHEAD, running forward. Deliberately not `writeFraming`'s
3864
+ // "ends at the playhead": that rule belongs to a DRAG, where the pose
3865
+ // under the pointer is the destination the author just chose. Here there
3866
+ // is no pose yet: the author is asking for somewhere to author one, and a
3867
+ // new thing landing anywhere but the playhead is the complaint this whole
3868
+ // lane's timeline was written against.
3869
+ const at = masterToSceneCached(doc, state.frame)?.sceneFrame ?? 0;
3870
+ const covering = scene.shots.find((s) => at >= s.frame && at < s.frame + shotDuration(s));
3871
+ if (covering !== undefined) {
3872
+ set({
3873
+ error:
3874
+ `The camera is already moving here: "${covering.label}" covers frame ${at}. ` +
3875
+ `Edit that move, or put the playhead after it and add another.`,
3876
+ });
3877
+ return;
3878
+ }
3879
+ // Shots are validated non-overlapping and a move past the scene's end
3880
+ // never plays, so the room to the NEXT of those two is the length a move
3881
+ // may have. Clamped rather than refused, because a shorter move is what
3882
+ // the author asked for; only no room at all has no answer.
3883
+ const next = scene.shots.find((s) => s.frame > at)?.frame ?? Infinity;
3884
+ const room = Math.min(next, Math.max(1, Math.trunc(scene.durationFrames))) - at;
3885
+ // Unreachable while `MIN_BLOCK_FRAMES` is 1: the playhead is clamped
3886
+ // inside the scene, so there is always at least one frame of room. It is
3887
+ // here because raising that constant would otherwise turn this clamp into
3888
+ // a silent overlap, which is a document that will not save.
3889
+ if (room < MIN_BLOCK_FRAMES) {
3890
+ set({
3891
+ error:
3892
+ `There is no room for a camera move at frame ${at}: the next move, or the end of the ` +
3893
+ `shot, is too close. Move the playhead earlier, or make the shot longer.`,
3894
+ });
3895
+ return;
3896
+ }
3897
+ const durationFrames = Math.max(
3898
+ MIN_BLOCK_FRAMES,
3899
+ Math.min(room, Math.round((doc.fps.num / doc.fps.den) * NEW_SHOT_SECONDS)),
3900
+ );
3901
+ // FROM EQUALS TO, and equals what is on screen right now. The move is a
3902
+ // no-op until the author drags the frustum or types a field, which is the
3903
+ // correct starting state: creating one must not move the camera.
3904
+ const framingNow = cameraAt(scene, at, doc.fps);
3905
+ const before = new Set(scene.shots.map((s) => s.id));
3906
+ store.edit((d) =>
3907
+ addShot(
3908
+ d,
3909
+ sceneId,
3910
+ {
3911
+ label: "Camera move",
3912
+ // DOLLY, for `writeFraming`'s reason: its distance follows the live
3913
+ // zoom, so a frustum drag on the new shot keeps tracking the value
3914
+ // it writes. `zoom` pins the distance at the shot's start.
3915
+ kind: "dolly",
3916
+ from: framingNow,
3917
+ to: framingNow,
3918
+ frame: at,
3919
+ durationFrames,
3920
+ },
3921
+ "camera-move",
3922
+ ),
3923
+ );
3924
+ // The id is read back rather than re-derived: `addShot` mints it from the
3925
+ // document's used ids, and a second guess at that rule is a second rule.
3926
+ const created = store
3927
+ .getState()
3928
+ .doc?.scenesById[sceneId]?.shots.find((s) => !before.has(s.id));
3929
+ if (created === undefined) return;
3930
+ // Focused AND shown. The inspector's TAB is a view choice (types.ts), so
3931
+ // moving it is safe; `animateMode` is the behaviour bit and stays where
3932
+ // the author left it.
3933
+ set({
3934
+ selectedBlock: { sceneId, layerId: CAMERA_ROW_ID, blockId: created.id },
3935
+ inspectorMode: "animate",
3936
+ // A refusal from the LAST press must not still be on screen over a
3937
+ // press that worked; the strip would read as a second failure.
3938
+ error: null,
3939
+ });
3940
+ },
3941
+ dismissError() {
3942
+ set({ error: null });
3943
+ },
3944
+ reportError(message) {
3945
+ set({ error: message });
3946
+ },
3947
+ };
3948
+
3949
+ return store;
3950
+ }
3951
+
3952
+ /**
3953
+ * The bits that belong to the AUTHOR'S WORKSPACE rather than to the document.
3954
+ *
3955
+ * They survive a close and a switch, because a zoom level or a collapsed dock
3956
+ * that reset every time you opened a shot would be a setting nobody could keep.
3957
+ * Everything NOT listed here is document state and is reset — which is the half
3958
+ * that matters: leaking a selection or a playhead across documents would point
3959
+ * the editor at layers that no longer exist.
3960
+ */
3961
+ function workspacePrefs(state: ShotEditorState): Partial<ShotEditorState> {
3962
+ return {
3963
+ view: state.view,
3964
+ // The split, the orbit and which pane is active are all "where the author
3965
+ // is standing" rather than anything about the shot, so they cross a
3966
+ // document switch with the zoom. They also never dirty the document: no
3967
+ // action here goes through `edit`.
3968
+ stageLayout: state.stageLayout,
3969
+ workingView: state.workingView,
3970
+ activePane: state.activePane,
3971
+ // Follow crosses a document switch with them, and deliberately so: it is a
3972
+ // way of working, not a property of the shot. It writes nothing on its own
3973
+ // — only an orbit does — so carrying it cannot carry an edit with it.
3974
+ cameraFollowsView: state.cameraFollowsView,
3975
+ // How the author looks at the working pane, not anything about the shot.
3976
+ shadingMode: state.shadingMode,
3977
+ tool: state.tool,
3978
+ inspectorMode: state.inspectorMode,
3979
+ animateMode: state.animateMode,
3980
+ dockCollapsed: state.dockCollapsed,
3981
+ railCollapsed: state.railCollapsed,
3982
+ inspectorCollapsed: state.inspectorCollapsed,
3983
+ // The focus-mode restore slots ride with `stageLayout.mode`: the mode
3984
+ // survives a document switch, so the pending restore must survive with
3985
+ // it or "leave working restores the panels" silently breaks after one.
3986
+ focusRestoreInspector: state.focusRestoreInspector,
3987
+ focusRestoreDock: state.focusRestoreDock,
3988
+ railWidth: state.railWidth,
3989
+ dockHeight: state.dockHeight,
3990
+ dockPpf: state.dockPpf,
3991
+ // Monitoring level, not mix level — see ShotEditorState. It survives a
3992
+ // document switch for the same reason the zoom does.
3993
+ previewMuted: state.previewMuted,
3994
+ previewVolume: state.previewVolume,
3995
+ };
3996
+ }
3997
+
3998
+ function sameIds(a: readonly string[], b: readonly string[]): boolean {
3999
+ return a.length === b.length && a.every((id, i) => id === b[i]);
4000
+ }
4001
+
4002
+ function shotDuration(shot: Shot): number {
4003
+ return Math.max(1, Math.trunc(shot.durationFrames));
4004
+ }
4005
+
4006
+ /**
4007
+ * Patch one shot's NON-TIMING fields.
4008
+ *
4009
+ * timelineOps' own `patchShot` is not reusable here and the reason is worth
4010
+ * stating: it returns the document unchanged unless `frame` or
4011
+ * `durationFrames` moved, because its two callers are the retimers and that
4012
+ * short-circuit is what makes a zero-travel drag cost nothing. A kind, an ease
4013
+ * or a framing edit would be silently dropped by it.
4014
+ *
4015
+ * No re-sort, for the mirror-image reason: nothing here touches `frame`, so the
4016
+ * order `cameraAt` walks cannot change. A caller that ever does move a shot
4017
+ * must use `moveShot`, which re-sorts.
4018
+ */
4019
+ function patchShotIn(
4020
+ doc: Doc3D,
4021
+ sceneId: string,
4022
+ shotId: string,
4023
+ next: (shot: Shot) => Shot,
4024
+ ): Doc3D {
4025
+ const scene = doc.scenesById[sceneId];
4026
+ if (scene === undefined) return doc;
4027
+ const at = scene.shots.findIndex((s) => s.id === shotId);
4028
+ if (at < 0) return doc;
4029
+ const shot = scene.shots[at]!;
4030
+ const updated = next(shot);
4031
+ // Identity when nothing changed, so a field that re-committed the value it
4032
+ // was showing costs no history entry and no save.
4033
+ if (updated === shot) return doc;
4034
+ const shots = [...scene.shots];
4035
+ shots[at] = updated;
4036
+ return { ...doc, scenesById: { ...doc.scenesById, [sceneId]: { ...scene, shots } } };
4037
+ }
4038
+
4039
+ /** The clamp camera.ts's `sanitise` applies, on the one field whose bad values
4040
+ * an author cannot type their way out of: a zoom of 0 is an infinite distance,
4041
+ * and validation would refuse the whole document at save rather than the field
4042
+ * at the point of the edit. */
4043
+ function clampFraming(framing: Framing): Framing {
4044
+ return {
4045
+ ...framing,
4046
+ zoom: Math.max(MIN_FRAMING_ZOOM, Math.min(MAX_FRAMING_ZOOM, framing.zoom)),
4047
+ };
4048
+ }
4049
+
4050
+ function sameFraming(a: Framing, b: Framing): boolean {
4051
+ return (
4052
+ a.x === b.x && a.y === b.y && a.zoom === b.zoom && a.rollDeg === b.rollDeg && a.focusZ === b.focusZ
4053
+ );
4054
+ }
4055
+
4056
+ /** "hero shot" -> "HeroShot". The generated component's name, which must be a
4057
+ * valid identifier; the STORE name keeps whatever the author typed. */
4058
+ export function pascalCase(input: string): string {
4059
+ const parts = input.split(/[^A-Za-z0-9]+/).filter((p) => p.length > 0);
4060
+ const joined = parts.map((p) => p[0]!.toUpperCase() + p.slice(1)).join("");
4061
+ return /^[A-Za-z]/.test(joined) ? joined : `Shot${joined}`;
4062
+ }
4063
+
4064
+ /** An asset kind as it reads INSIDE the refusal sentence "<file> is not …".
4065
+ * Total over the union, so a kind added to the schema cannot reach a picker
4066
+ * without an answer here. */
4067
+ const KIND_NAMES: Record<ShotAssetKind, string> = {
4068
+ image: "an image",
4069
+ video: "a video",
4070
+ audio: "audio",
4071
+ model: "a model (.glb / .gltf)",
4072
+ font: "a font",
4073
+ };
4074
+
4075
+ /** …and the list, joined so a two-kind picker reads as a sentence rather than
4076
+ * as an array. */
4077
+ function kindsPhrase(kinds: readonly ShotAssetKind[]): string {
4078
+ const names = kinds.map((k) => KIND_NAMES[k]);
4079
+ const last = names.pop();
4080
+ if (last === undefined) return "a supported asset";
4081
+ return names.length === 0 ? last : `${names.join(", ")} or ${last}`;
4082
+ }
4083
+
4084
+ /** "Hero Shot" -> "hero-shot", for the document id. */
4085
+ export function slug(input: string): string {
4086
+ return (
4087
+ input
4088
+ .toLowerCase()
4089
+ .replace(/[^a-z0-9]+/g, "-")
4090
+ .replace(/^-+|-+$/g, "") || "shot"
4091
+ );
4092
+ }
4093
+
4094
+