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