@dailies-studio/shot-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/assets/index-B7notge7.css +1 -0
  2. package/dist/assets/index-BBE6Ta9L.js +52 -0
  3. package/dist/index.html +13 -0
  4. package/package.json +45 -0
  5. package/src/api.ts +289 -0
  6. package/src/app/App.tsx +394 -0
  7. package/src/audio/index.tsx +74 -0
  8. package/src/audio/preview.ts +364 -0
  9. package/src/bridge.ts +45 -0
  10. package/src/bridgeTypes.ts +582 -0
  11. package/src/channels.ts +40 -0
  12. package/src/main.tsx +15 -0
  13. package/src/panels/ResizeHandle.tsx +113 -0
  14. package/src/panels/agent/AgentPanel.tsx +478 -0
  15. package/src/panels/agent/agent.css +322 -0
  16. package/src/panels/export/ExportDialog.tsx +284 -0
  17. package/src/panels/files/FilePanel.tsx +564 -0
  18. package/src/panels/files/McpSection.tsx +66 -0
  19. package/src/panels/files/icons.tsx +118 -0
  20. package/src/panels/header/DocSwitcher.tsx +85 -0
  21. package/src/panels/header/Header.tsx +132 -0
  22. package/src/panels/header/HistoryControls.tsx +20 -0
  23. package/src/panels/header/StatusBadges.tsx +49 -0
  24. package/src/panels/header/TransportBar.tsx +86 -0
  25. package/src/panels/header/ZoomControls.tsx +38 -0
  26. package/src/panels/help/HelpSection.tsx +190 -0
  27. package/src/panels/inspector/FramingRows.tsx +69 -0
  28. package/src/panels/inspector/InspectorPanel.tsx +178 -0
  29. package/src/panels/inspector/MarkerSection.tsx +63 -0
  30. package/src/panels/inspector/ParamRow.tsx +71 -0
  31. package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
  32. package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
  33. package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
  34. package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
  35. package/src/panels/inspector/animate/ShotSection.tsx +126 -0
  36. package/src/panels/inspector/animate/useBlocks.ts +43 -0
  37. package/src/panels/inspector/design/AlignRow.tsx +63 -0
  38. package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
  39. package/src/panels/inspector/design/ClonerSection.tsx +142 -0
  40. package/src/panels/inspector/design/ComponentSection.tsx +94 -0
  41. package/src/panels/inspector/design/CornerSection.tsx +52 -0
  42. package/src/panels/inspector/design/FillSection.tsx +89 -0
  43. package/src/panels/inspector/design/HtmlSection.tsx +50 -0
  44. package/src/panels/inspector/design/InstanceSection.tsx +302 -0
  45. package/src/panels/inspector/design/MediaSection.tsx +251 -0
  46. package/src/panels/inspector/design/MeshSection.tsx +172 -0
  47. package/src/panels/inspector/design/ShadowSection.tsx +73 -0
  48. package/src/panels/inspector/design/StrokeSection.tsx +85 -0
  49. package/src/panels/inspector/design/TransformSection.tsx +91 -0
  50. package/src/panels/inspector/design/TypographySection.tsx +170 -0
  51. package/src/panels/inspector/scene/CameraSection.tsx +59 -0
  52. package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
  53. package/src/panels/inspector/scene/SceneSection.tsx +71 -0
  54. package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
  55. package/src/panels/inspector/useDocWrite.ts +268 -0
  56. package/src/panels/material/MaterialPicker.tsx +172 -0
  57. package/src/panels/stage/StageMenu.tsx +38 -0
  58. package/src/panels/stage/StagePanel.tsx +131 -0
  59. package/src/panels/timeline/AudioLane.tsx +340 -0
  60. package/src/panels/timeline/BlockRow.tsx +242 -0
  61. package/src/panels/timeline/ChannelRow.tsx +266 -0
  62. package/src/panels/timeline/LifespanRow.tsx +165 -0
  63. package/src/panels/timeline/MarkerLane.tsx +146 -0
  64. package/src/panels/timeline/SceneStrip.tsx +145 -0
  65. package/src/panels/timeline/TimelineDock.tsx +488 -0
  66. package/src/panels/timeline/model.ts +641 -0
  67. package/src/panels/timeline/snap.ts +191 -0
  68. package/src/panels/toolbar/AddMenu.tsx +230 -0
  69. package/src/panels/toolbar/CameraViewBar.tsx +70 -0
  70. package/src/panels/toolbar/ShadingBar.tsx +42 -0
  71. package/src/panels/toolbar/ToolDockBar.tsx +110 -0
  72. package/src/panels/toolbar/ViewModeBar.tsx +49 -0
  73. package/src/shotTokens.css +131 -0
  74. package/src/stage/FrustumGizmo.tsx +184 -0
  75. package/src/stage/GridPlane.tsx +274 -0
  76. package/src/stage/MoveGizmo.tsx +411 -0
  77. package/src/stage/PerfHud.tsx +206 -0
  78. package/src/stage/SplitRenderer.tsx +511 -0
  79. package/src/stage/StageApp.tsx +3423 -0
  80. package/src/stage/StageTools.tsx +581 -0
  81. package/src/stage/bridgeClient.ts +278 -0
  82. package/src/stage/feed.ts +243 -0
  83. package/src/stage/frustumGeometry.ts +172 -0
  84. package/src/stage/gizmoMath.ts +494 -0
  85. package/src/stage/handles.ts +151 -0
  86. package/src/stage/instanceModules.ts +152 -0
  87. package/src/stage/interactions/move.ts +172 -0
  88. package/src/stage/interactions/panZoom.ts +60 -0
  89. package/src/stage/isolation.ts +155 -0
  90. package/src/stage/main.tsx +110 -0
  91. package/src/stage/modalState.ts +427 -0
  92. package/src/stage/overlays/Overlays.tsx +1046 -0
  93. package/src/stage/panes.ts +121 -0
  94. package/src/stage/penState.ts +112 -0
  95. package/src/stage/project.ts +402 -0
  96. package/src/stage/shading.ts +55 -0
  97. package/src/stage/tokenColor.ts +26 -0
  98. package/src/stage/view.ts +182 -0
  99. package/src/stage/workingView.ts +636 -0
  100. package/src/state/agentActions.ts +203 -0
  101. package/src/state/clientId.ts +25 -0
  102. package/src/state/context.tsx +33 -0
  103. package/src/state/insert.ts +507 -0
  104. package/src/state/keymap.ts +560 -0
  105. package/src/state/menu.ts +141 -0
  106. package/src/state/panelSize.ts +26 -0
  107. package/src/state/renderEvents.ts +100 -0
  108. package/src/state/store.ts +4094 -0
  109. package/src/state/triangles.ts +70 -0
  110. package/src/state/types.ts +384 -0
  111. package/src/styles.css +2948 -0
  112. package/src/tools.ts +181 -0
@@ -0,0 +1,511 @@
1
+ // The stage's render controller: two views of one scene, into one canvas.
2
+ //
3
+ // Mounted INSIDE `ShotRuntime`'s canvas, and only while a second pane is on
4
+ // screen. `renderControl: "external"` is what makes that legal — see the
5
+ // contract in packages/shot/src/ShotRuntime.tsx. `useFrame(…, 1)` is what hands
6
+ // the present over: r3f skips its own render whenever any subscriber has a
7
+ // priority above zero, which is the identical mechanism `PostPipeline` uses.
8
+ //
9
+ // ── WHY THE CAMERA PANE'S POST GOES THROUGH A RENDER TARGET ──────────────
10
+ //
11
+ // It is not for the resolution and it is not for the colour. It is because a
12
+ // `RenderPipeline` rendered STRAIGHT TO A SCISSORED CANVAS wipes its neighbour
13
+ // on the WebGPU backend.
14
+ //
15
+ // The mechanism, measured against three 0.185.1: `RenderPipeline.render()`
16
+ // forces `outputColorSpace = workingColorSpace` for the duration of the chain,
17
+ // which makes `needsFrameBufferTarget` false and leaves the pass at the
18
+ // canvas's real sample count (4, because the stage asks for `antialias: true`).
19
+ // A plain render's `currentSamples` is 0, because it DOES take the framebuffer
20
+ // target. At 4 samples `WebGPUBackend.beginRender` attaches the swapchain
21
+ // texture as the MSAA RESOLVE TARGET — and a multisample resolve is a
22
+ // whole-attachment operation that `setScissorRect` does not reach. So the post
23
+ // pane's resolve stamps the entire canvas from a buffer that never held the
24
+ // other pane.
25
+ //
26
+ // Three things fix it and only one of them is honest. `antialias: false` costs
27
+ // the whole canvas its AA. "Always draw the post pane first" is unwritten
28
+ // ordering magic the next person will break. Rendering post into its OWN target
29
+ // and blitting that into the pane is backend-agnostic, AA-agnostic and
30
+ // order-agnostic, and it was measured identical on WebGPU and on the WebGL2
31
+ // fallback. It is also the only one CI can protect: the pinned Chromium in this
32
+ // repo cannot create a WebGPU device at all (dxil.dll), so every automated run
33
+ // of this lane takes the fallback and would never see the bug.
34
+ //
35
+ // THE RULE THAT FALLS OUT OF THAT, for whoever adds a third pane: never mix a
36
+ // plain render and a direct `pipeline.render()` across one scissored canvas.
37
+ // The failure is silent, it only appears on WebGPU with AA on, and it passes CI.
38
+ //
39
+ // ── …AND WHY IT IS setRenderTarget AND NOT setOutputRenderTarget ─────────
40
+ //
41
+ // Both bind the same texture. Only one of them tells the SHADER how big it is.
42
+ //
43
+ // `setOutputRenderTarget` leaves `renderer._renderTarget` null, and on that
44
+ // path `_renderScene` never calls `setRenderTarget()` (Renderer.js: the
45
+ // `else` branch just reads the output target). So `renderer.getRenderTarget()`
46
+ // stays null, and `ScreenNode.update` falls through to `getDrawingBufferSize()`
47
+ // — the WHOLE canvas — while `gl_FragCoord` only spans the pane-sized target.
48
+ // In a 50/50 split `screenUV` therefore runs 0..0.5 across the camera pane:
49
+ // `vignette` puts its bright centre on the pane's outer edge, `edgeFade` fades
50
+ // the wrong two sides, and `ghost` samples half the frame. The preview then
51
+ // disagrees with the export for every scene that uses them, from one toggle.
52
+ //
53
+ // `setRenderTarget` makes `_renderTarget` non-null, so `screenSize` is the pane
54
+ // and `screenUV` is 0..1 across it — matching the blit quad's own uv, which is
55
+ // what the un-suffixed `sceneTexture` samples with. The colour argument above
56
+ // survives unchanged: with `_renderTarget !== _outputRenderTarget` the renderer
57
+ // reports `NoToneMapping` + working space, so `needsFrameBufferTarget` is still
58
+ // false and there is still exactly one encode, at the blit. It additionally
59
+ // drops `currentSamples` to the target's own 0, which removes the MSAA resolve
60
+ // this header's first section is about rather than merely routing around it.
61
+ //
62
+ // NAMED CEILING: the scene PASS is still sized from the drawing buffer.
63
+ // `PassNode.updateBefore` honours an output target's size only when it is an XR
64
+ // target, so the pass renders the pane's frustum into a canvas-wide texture.
65
+ // The stretch cancels at the blit (quad uv 0..1 both ways) so the IMAGE is
66
+ // right, but bloom and chromatic aberration compute their kernels in that
67
+ // stretched space, so a round bloom is slightly elliptical in a split. Fixing
68
+ // it needs a pass whose target aspect can differ from the canvas's, which three
69
+ // does not expose — not another call here.
70
+ //
71
+ // ── COLOUR ───────────────────────────────────────────────────────────────
72
+ //
73
+ // `outputColorTransform = false` on the pipeline is load-bearing. The target
74
+ // stays in WORKING space, `texture()` decodes nothing out of it, and the blit's
75
+ // own render applies `renderOutput()` exactly once. Leaving it true encodes at
76
+ // the pipeline and again at the blit: a linear 0.5 lands at 223 instead of 188,
77
+ // which looks plausible enough to ship. Same single-encode gate post.ts states.
78
+ //
79
+ // ── BINS ─────────────────────────────────────────────────────────────────
80
+ //
81
+ // A scene with a `mesh` layer draws in several passes (packages/shot/bins.ts).
82
+ // EVERY render here goes through `renderBins` or a `SceneSource` rather than
83
+ // `gl.render` / `pass()` directly, so both panes agree with each other and with
84
+ // the export about what covers what. `renderBins` degenerates to exactly one
85
+ // `gl.render` when there is nothing to split, so a scene with no model takes
86
+ // the identical call this file always made.
87
+ //
88
+ // ── NOTHING IS ALLOCATED PER FRAME ───────────────────────────────────────
89
+ //
90
+ // The rig, the clay, the blit quad and the scratch colour are all hoisted. The
91
+ // hot path is scissor rects and one uniform write.
92
+ import { useEffect, useMemo } from "react";
93
+ import { useFrame, useThree } from "@react-three/fiber";
94
+ import { Color, ColorManagement, HalfFloatType, Layers, RenderTarget } from "three";
95
+ import { MeshBasicNodeMaterial, QuadMesh, RenderPipeline } from "three/webgpu";
96
+ import type { ColorSpace } from "three";
97
+ import type { WebGPURenderer } from "three/webgpu";
98
+ import { texture, uv } from "three/tsl";
99
+ import {
100
+ binSceneSource,
101
+ buildPostChain,
102
+ hasPostChain,
103
+ passSceneSource,
104
+ postSignature,
105
+ renderBins,
106
+ transitionTap,
107
+ } from "@dailies-studio/shot";
108
+ import type { RenderBin, SceneEffects } from "@dailies-studio/shot/doc";
109
+ import type { StageFeed } from "./feed.js";
110
+ import type { PaneRect } from "./panes.js";
111
+ import { workingDrawPlan } from "./shading.js";
112
+ import type { ShadingMode } from "./shading.js";
113
+ import { EDITOR_LAYER } from "./StageTools.js";
114
+
115
+ /**
116
+ * The working pane's backdrop and its clay.
117
+ *
118
+ * NOT tokens. These are a GL clear colour and a material colour, so they cannot
119
+ * read the CSS custom properties the overlays use; they are picked to sit just
120
+ * off `--panel` so the pane reads as a workspace rather than as the shot.
121
+ */
122
+ export const CLAY_BACKDROP = new Color(0x141417);
123
+ const CLAY_FILL = new Color(0x8a8a93);
124
+ /** The wireframe's line colour. Brighter than the clay so edges read as lines
125
+ * on the same backdrop rather than as a dimmer clay. */
126
+ const WIRE_FILL = new Color(0xb8b8c2);
127
+
128
+ /** Scratch for saving the shot's own clear colour across the working pane's. */
129
+ const _prevClear = new Color();
130
+
131
+ export interface SplitRendererProps {
132
+ feed: StageFeed;
133
+ panes: { working: PaneRect | null; camera: PaneRect | null };
134
+ /** The active scene's effects, or null when nothing is on screen. */
135
+ effects: SceneEffects | null;
136
+ /** The active scene's render bins at this frame. Empty or single = one draw,
137
+ * which is every scene without a live mesh layer. */
138
+ bins: readonly RenderBin[];
139
+ /** Whether the scene contains a mesh layer AT ALL. Static (lifetimes
140
+ * ignored), because it picks the post chain's SHAPE — see ShotRuntime. */
141
+ hasMesh: boolean;
142
+ /** MASTER frame — the grain's seed, and the only clock the chain sees. */
143
+ frame: number;
144
+ /** How the WORKING pane draws. The camera pane is not addressable: it is
145
+ * always the graded render, whatever this says. */
146
+ shading: ShadingMode;
147
+ }
148
+
149
+ export function SplitRenderer({ feed, panes, effects, bins, hasMesh, frame, shading }: SplitRendererProps): null {
150
+ // The runtime class IS `WebGPURenderer` (createStageGl builds one, WebGL2 is
151
+ // a BACKEND of it, not a different class); r3f types `gl` as the classic
152
+ // renderer, so the cast happens once here rather than at five call sites.
153
+ const gl = useThree((s) => s.gl) as unknown as WebGPURenderer;
154
+ const scene = useThree((s) => s.scene);
155
+ const shotCamera = useThree((s) => s.camera);
156
+ const size = useThree((s) => s.size);
157
+
158
+ /**
159
+ * The clay override, ONE dedicated instance and never shared.
160
+ *
161
+ * `depthTest`/`depthWrite` stay OFF, matching every layer material. It is
162
+ * tempting to turn them on "because the working pane is the 3D one", and it
163
+ * would be a second truth: this lane's paint order is `renderOrder =
164
+ * layer.order` and nothing else (see ShotRuntime's header), so a depth-tested
165
+ * working pane would disagree with the camera pane about what covers what —
166
+ * and, because layers default to z = 0, would z-fight the common case.
167
+ *
168
+ * `transparent` is left false. three copies the SOURCE material's
169
+ * `transparent` onto the override per object anyway, which is what keeps the
170
+ * render-list assignment consistent; setting it here as well would only add a
171
+ * second opinion.
172
+ */
173
+ const overrides = useMemo(() => {
174
+ const clay = new MeshBasicNodeMaterial();
175
+ clay.color.copy(CLAY_FILL);
176
+ clay.depthTest = false;
177
+ clay.depthWrite = false;
178
+ clay.toneMapped = false;
179
+ // The wireframe is the same material with the fill swapped for edges: the
180
+ // depth/tone rationale above holds unchanged, because a wireframe pane's
181
+ // paint order is still `renderOrder` and nothing else.
182
+ const wire = new MeshBasicNodeMaterial();
183
+ wire.color.copy(WIRE_FILL);
184
+ wire.wireframe = true;
185
+ wire.depthTest = false;
186
+ wire.depthWrite = false;
187
+ wire.toneMapped = false;
188
+ return { clay, wire };
189
+ }, []);
190
+ useEffect(
191
+ () => () => {
192
+ overrides.clay.dispose();
193
+ overrides.wire.dispose();
194
+ },
195
+ [overrides],
196
+ );
197
+
198
+ const signature = effects === null ? "" : `${postSignature(effects)}::${hasMesh}`;
199
+ const post = useMemo(() => {
200
+ if (effects === null || !hasPostChain(effects)) return null;
201
+ // Layer 0 only. This source is what feeds the graded image, so pinning it is
202
+ // what makes "an editor-only object can never appear in the camera pane"
203
+ // true by construction rather than by everyone remembering to set a mask.
204
+ // Both sources honour it identically.
205
+ const source = hasMesh
206
+ ? binSceneSource(scene, shotCamera, new Layers())
207
+ : passSceneSource(scene, shotCamera, new Layers());
208
+ const chain = buildPostChain(source.textureNode, effects);
209
+ const pipeline = new RenderPipeline(
210
+ gl as unknown as ConstructorParameters<typeof RenderPipeline>[0],
211
+ );
212
+ pipeline.outputNode = chain.outputNode;
213
+ pipeline.outputColorTransform = false; // see the colour note in the header
214
+ // Half float, working space: the chain's output is scene-referred light and
215
+ // an 8-bit sRGB hop here would band every gradient the bloom touches.
216
+ const target = new RenderTarget(1, 1, {
217
+ type: HalfFloatType,
218
+ // Read off ColorManagement rather than written as `LinearSRGBColorSpace`:
219
+ // the pipeline forces the WORKING space for the chain, so this target has
220
+ // to be whatever that is, not whatever it is today.
221
+ colorSpace: ColorManagement.workingColorSpace as ColorSpace,
222
+ });
223
+ const material = new MeshBasicNodeMaterial();
224
+ material.colorNode = texture(target.texture, uv());
225
+ material.depthTest = false;
226
+ material.depthWrite = false;
227
+ material.toneMapped = false;
228
+ // THREE'S OWN full-screen primitive, not a hand-rolled plane. Its geometry
229
+ // carries the v-FLIPPED uv three uses everywhere it blits a render target
230
+ // (`QuadGeometry`: the bottom-left vertex is uv 0,1), and its camera covers
231
+ // NDC exactly — so uv 0..1 lands on the current VIEWPORT, whatever pane
232
+ // that is, and the image comes out the same way up on both backends. A
233
+ // `PlaneGeometry` looks equivalent and is upside down.
234
+ const blit = new QuadMesh(material);
235
+ return { source, chain, pipeline, target, material, blit };
236
+ // `effects` is deliberately not a dependency: its VALUES are uniforms
237
+ // written every frame, and only the SIGNATURE changes the graph's shape.
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [gl, scene, shotCamera, signature]);
240
+
241
+ useEffect(
242
+ () => () => {
243
+ if (post === null) return;
244
+ post.chain.dispose();
245
+ post.pipeline.dispose();
246
+ post.source.dispose();
247
+ post.target.dispose();
248
+ post.material.dispose();
249
+ },
250
+ [post],
251
+ );
252
+
253
+ /**
254
+ * The WORKING pane's own post rig, for "rendered" shading. Mirrors `post`
255
+ * with two deliberate differences:
256
+ *
257
+ * 1. Its OWN source target, drawn by `renderBins` at FRAME time with
258
+ * `feed.workingCamera` — never `pass()`/`binSceneSource`, which bake a
259
+ * camera at build time. The working slot's camera OBJECT is swapped by
260
+ * isolation (StageTools puts an orthographic head-on rig in it), so the
261
+ * camera has to be read at draw time for the swap to cost nothing.
262
+ * 2. The source is sized to the PANE, so the scene is drawn at the pane's
263
+ * own resolution and `screenUV` spans it (the header's second section).
264
+ *
265
+ * NAMED CEILING, and it is the header's own, NOT escaped: `BloomNode`
266
+ * sizes its whole mip chain from `renderer.getDrawingBufferSize()` in
267
+ * `updateBefore` (three 0.185.1, BloomNode.js:347) rather than from the
268
+ * bound target, so the blur kernel is still computed in canvas space and a
269
+ * round bloom is as elliptical in this pane as in the camera one. Sizing
270
+ * the source to the pane does not reach that, and no call here can — it
271
+ * needs a pass whose target aspect may differ from the canvas's.
272
+ *
273
+ * Keyed on the mode so the rig exists only while rendered shading is on:
274
+ * solid users pay nothing, and leaving the mode disposes the pipeline. It
275
+ * is keyed on the EFFECTS signature alone and not on `signature`, which
276
+ * carries `hasMesh`: that bit picks the camera pane's SOURCE KIND (bin vs
277
+ * pass) and this rig has neither — it always draws its own target with
278
+ * `renderBins` — so folding it in would tear down and recompile the whole
279
+ * working pipeline every time a mesh layer entered or left the scene.
280
+ */
281
+ const wantWorkingGrade = shading === "rendered";
282
+ const workingSignature = effects === null ? "" : postSignature(effects);
283
+ const wpost = useMemo(() => {
284
+ if (!wantWorkingGrade || effects === null || !hasPostChain(effects)) return null;
285
+ const src = new RenderTarget(1, 1, { type: HalfFloatType });
286
+ const chain = buildPostChain(texture(src.texture, uv()), effects);
287
+ const pipeline = new RenderPipeline(
288
+ gl as unknown as ConstructorParameters<typeof RenderPipeline>[0],
289
+ );
290
+ pipeline.outputNode = chain.outputNode;
291
+ pipeline.outputColorTransform = false; // one encode, at the blit — header
292
+ const target = new RenderTarget(1, 1, {
293
+ type: HalfFloatType,
294
+ colorSpace: ColorManagement.workingColorSpace as ColorSpace,
295
+ });
296
+ const material = new MeshBasicNodeMaterial();
297
+ material.colorNode = texture(target.texture, uv());
298
+ material.depthTest = false;
299
+ material.depthWrite = false;
300
+ material.toneMapped = false;
301
+ const blit = new QuadMesh(material);
302
+ return { src, chain, pipeline, target, material, blit };
303
+ // Same rule as `post`: `effects` values are uniforms written per frame,
304
+ // only the SIGNATURE changes the graph's shape.
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [gl, workingSignature, wantWorkingGrade]);
307
+
308
+ useEffect(
309
+ () => () => {
310
+ if (wpost === null) return;
311
+ wpost.chain.dispose();
312
+ wpost.pipeline.dispose();
313
+ wpost.src.dispose();
314
+ wpost.target.dispose();
315
+ wpost.material.dispose();
316
+ },
317
+ [wpost],
318
+ );
319
+
320
+ // The props are read straight out of the closure. r3f re-points `useFrame`'s
321
+ // callback ref in a layout effect on every render, and `ClockDriver` — the
322
+ // LAST child of the scene — is what calls `advance`, so the callback that
323
+ // draws is always the one this render produced.
324
+ useFrame(() => {
325
+ const working = panes.working;
326
+ const camera = panes.camera;
327
+ const fx = effects;
328
+ const at = frame;
329
+ const workingCamera = feed.workingCamera;
330
+
331
+ // The shot's own clear colour is ShotBackground's, written on a layout
332
+ // effect. Borrowing it for one pane and handing it back is cheaper than
333
+ // re-deriving the background here, and cannot drift from it.
334
+ gl.getClearColor(_prevClear);
335
+ const prevAlpha = gl.getClearAlpha();
336
+
337
+ // 0. A transition window owns the camera pane outright: the compositor
338
+ // below in @dailies-studio/shot has captured nothing yet, because under
339
+ // `renderControl: "external"` it deliberately does not present. Its
340
+ // capture goes here, in the same unscissored slot as our own post and
341
+ // for the same reason, at the PANE's size so the blend is not stretched
342
+ // across a frustum it was not rendered for. Without this the editor
343
+ // would show hard cuts in split view where the export dissolves.
344
+ const blend = camera === null ? null : transitionTap();
345
+ if (blend !== null && camera !== null) {
346
+ gl.setScissorTest(false);
347
+ blend.capture(camera.width, camera.height);
348
+ }
349
+
350
+ // 1. The camera pane's post chain, OFFSCREEN and UNSCISSORED. See the
351
+ // header for why this cannot go straight to the canvas. Skipped inside a
352
+ // window: the compositor rendered this scene into its own operand target
353
+ // already, with the same chain.
354
+ if (blend === null && camera !== null && post !== null && fx !== null) {
355
+ // The target is PANE sized, in device px: the pane gets its own
356
+ // resolution for free, and `setSize` already no-ops when it matches.
357
+ const dpr = gl.getPixelRatio();
358
+ post.target.setSize(
359
+ Math.max(1, Math.round(camera.width * dpr)),
360
+ Math.max(1, Math.round(camera.height * dpr)),
361
+ );
362
+ post.chain.write(fx, at);
363
+ gl.setScissorTest(false);
364
+ // The bins first, into whatever the source owns. A `pass()`-backed source
365
+ // no-ops here and renders itself from inside `pipeline.render()` below,
366
+ // which is the untouched single-render path.
367
+ post.source.draw(gl, bins);
368
+ gl.setRenderTarget(post.target);
369
+ post.pipeline.render();
370
+ gl.setRenderTarget(null);
371
+ }
372
+
373
+ // 1b. The WORKING pane's grade, offscreen and unscissored — rendered
374
+ // shading with a chain only. The same offscreen-then-blit construction
375
+ // as the camera pane, so the WebGPU MSAA resolve can never reach the
376
+ // neighbour. The source draw masks the working camera to LAYER 0: the
377
+ // gizmo and any future editor-layer object must never be graded — the
378
+ // same pin the camera source takes via `Layers`, done inline because
379
+ // this camera's mask is per-draw state we restore. The clear colour is
380
+ // still the shot's own (`_prevClear`): a graded working pane previews
381
+ // the export's image, so it sits on the export's background.
382
+ if (working !== null && workingCamera !== null && wpost !== null && fx !== null) {
383
+ const dpr = gl.getPixelRatio();
384
+ const w = Math.max(1, Math.round(working.width * dpr));
385
+ const h = Math.max(1, Math.round(working.height * dpr));
386
+ // Mirrors binSceneSource's target discipline: the renderer's own sample
387
+ // count and output buffer type, so the image is scene-referred with MSAA
388
+ // on the model silhouettes.
389
+ wpost.src.samples = gl.samples;
390
+ wpost.src.texture.type = gl.getOutputBufferType();
391
+ wpost.src.setSize(w, h);
392
+ wpost.target.setSize(w, h);
393
+ wpost.chain.write(fx, at);
394
+ gl.setScissorTest(false);
395
+ const prevMask = workingCamera.layers.mask;
396
+ workingCamera.layers.mask = 1; // bit 0 only
397
+ gl.setRenderTarget(wpost.src);
398
+ renderBins(gl, scene, workingCamera, bins);
399
+ gl.setRenderTarget(null);
400
+ workingCamera.layers.mask = prevMask;
401
+ gl.setRenderTarget(wpost.target);
402
+ wpost.pipeline.render();
403
+ gl.setRenderTarget(null);
404
+ }
405
+
406
+ gl.setScissorTest(true);
407
+
408
+ // 2. The working pane. `autoClear` stays on, so each pane clears its own
409
+ // scissor rect with its own colour — measured to hold on both backends,
410
+ // because every render here takes the intermediate framebuffer target
411
+ // (the canvas's output colour space is sRGB and the working space is
412
+ // linear, so `needsFrameBufferTarget` is true whatever the tone mapping).
413
+ //
414
+ // What it draws is the shading mode's plan (shading.ts):
415
+ // solid / wireframe — the override path. The override reaches a model
416
+ // too (`allowOverride` is left at its default on the mesh materials,
417
+ // see mesh.tsx) and the gizmo escapes via its own
418
+ // `allowOverride = false`. Ceiling: three copies only
419
+ // `transparent`/`alphaTest`/`alphaMap` onto an override, not
420
+ // `depthTest`, so an overridden model does not self-occlude here.
421
+ // material — NO override: real layer materials, textures, opacity and
422
+ // alpha stencils, drawn ungraded. Two documented consequences, both
423
+ // correct for a materials preview and neither to be "fixed": the
424
+ // DOF mip-LOD (`layerLod`) is written from the SHOT camera's rig,
425
+ // so per-layer blur reflects the shot's focus and not this pane's
426
+ // viewpoint; and isolation's dimming becomes visible here, because
427
+ // dim rides the opacity uniform the clay used to strip.
428
+ // rendered — handled as `graded` when the scene has a chain (the
429
+ // `wpost` rig below); an effect-less scene degrades to the material
430
+ // plan, which is what `workingDrawPlan` returns for it.
431
+ // The camera pane is always the one that matches the export.
432
+ if (working !== null && workingCamera !== null) {
433
+ const plan = workingDrawPlan(shading, wpost !== null);
434
+ applyRect(gl, working);
435
+ if (plan.graded && wpost !== null) {
436
+ // The graded image already carries the shot's own background, so the
437
+ // pane clears with it rather than the clay backdrop.
438
+ gl.setClearColor(_prevClear, prevAlpha);
439
+ wpost.blit.render(gl);
440
+ // The EDITOR-LAYER overpaint: the gizmo draws crisp OVER the graded
441
+ // image, ungraded. Mask the camera to the editor bit alone and draw
442
+ // with `autoClear` off so the blit is kept; both are renderer/camera
443
+ // state we restore inline, on the same line of sight.
444
+ const prevMask = workingCamera.layers.mask;
445
+ const prevAutoClear = gl.autoClear;
446
+ workingCamera.layers.mask = 1 << EDITOR_LAYER;
447
+ gl.autoClear = false;
448
+ gl.render(scene, workingCamera);
449
+ gl.autoClear = prevAutoClear;
450
+ workingCamera.layers.mask = prevMask;
451
+ } else if (plan.override !== null) {
452
+ gl.setClearColor(CLAY_BACKDROP, 1);
453
+ scene.overrideMaterial = plan.override === "wire" ? overrides.wire : overrides.clay;
454
+ renderBins(gl, scene, workingCamera, bins);
455
+ scene.overrideMaterial = null;
456
+ } else {
457
+ // RENDERED WITHOUT A CHAIN keeps the SHOT's backdrop, not the clay
458
+ // one. It degrades to the material plan here (nothing to grade), but
459
+ // its job is still to preview the export — clearing to the workspace
460
+ // grey would disagree with the camera pane and the MP4, and would
461
+ // then snap to the shot's background the moment any effect was
462
+ // enabled, from a toggle that says nothing about the backdrop.
463
+ // Material mode keeps the clay backdrop on purpose: it previews the
464
+ // MATERIALS against the workspace, not the shot.
465
+ if (shading === "rendered") gl.setClearColor(_prevClear, prevAlpha);
466
+ else gl.setClearColor(CLAY_BACKDROP, 1);
467
+ renderBins(gl, scene, workingCamera, bins);
468
+ }
469
+ }
470
+
471
+ // 3. The camera pane: the blit, or a plain render when the scene has no
472
+ // post at all (no pass, no target, no cost — the same call ShotRuntime
473
+ // would have made).
474
+ if (camera !== null) {
475
+ applyRect(gl, camera);
476
+ gl.setClearColor(_prevClear, prevAlpha);
477
+ // `QuadMesh.render` is `renderer.render(quad, itsOwnCamera)` — the same
478
+ // call, through the object that owns the right camera for it.
479
+ if (blend !== null) blend.quad.render(gl);
480
+ else if (post !== null && fx !== null) post.blit.render(gl);
481
+ else renderBins(gl, scene, shotCamera, bins);
482
+ }
483
+
484
+ // 4. Hand the renderer back exactly as it was found. Every one of these is
485
+ // global state on a renderer this component does not own; leaking one
486
+ // surfaces on the NEXT frame, in a component that never touched it.
487
+ scene.overrideMaterial = null;
488
+ gl.setScissorTest(false);
489
+ gl.setViewport(0, 0, size.width, size.height);
490
+ gl.setScissor(0, 0, size.width, size.height);
491
+ gl.setClearColor(_prevClear, prevAlpha);
492
+ }, 1);
493
+
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * Point the renderer at one pane.
499
+ *
500
+ * CSS px, not device px: three multiplies both rects by the pixel ratio itself
501
+ * (`renderContext.viewportValue.copy(viewport).multiplyScalar(pixelRatio)`), so
502
+ * a caller that pre-scaled would scale twice.
503
+ *
504
+ * The panes are full-height columns, so y is 0 in both and the backends' y
505
+ * origins cannot disagree here. Check that before building a top/bottom split
506
+ * or an inset: WebGPU's `setViewport` is top-left native and WebGL's is not.
507
+ */
508
+ function applyRect(gl: WebGPURenderer, rect: PaneRect): void {
509
+ gl.setViewport(rect.x, rect.y, rect.width, rect.height);
510
+ gl.setScissor(rect.x, rect.y, rect.width, rect.height);
511
+ }