@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,488 @@
1
+ // The timeline dock: ONE CONTINUOUS MASTER STRIP.
2
+ //
3
+ // ── WHAT DIED HERE, AND WHY ─────────────────────────────────────────────
4
+ //
5
+ // This used to be two levels. A scene strip on top, and underneath it the block
6
+ // tracks of WHICHEVER SCENE THE PLAYHEAD WAS IN — one bit, `state.sceneId`,
7
+ // deciding what half the dock showed. It is a coherent model and it is the wrong
8
+ // one for a video: the author's question is almost never "what is in scene 2",
9
+ // it is "what happens between the logo landing and the price appearing", and
10
+ // those two things are in different scenes. A dock that can only show one scene
11
+ // answers that question by making the author flip between two pictures and hold
12
+ // the join in their head.
13
+ //
14
+ // So every scene's rows are on screen at once, at their master positions,
15
+ // grouped in master order, with the scene boundaries drawn through the tracks.
16
+ // `state.sceneId` survives as the INSPECTOR'S SUBJECT and the URL's, and nothing
17
+ // about this file's layout reads it any more. The scene strip survives too, slim
18
+ // and secondary: it is the only place scenes are created, reordered and trimmed,
19
+ // which is exactly the set of verbs that are about scenes rather than about
20
+ // time.
21
+ //
22
+ // ── WHERE THE SCENE COMES FROM NOW ──────────────────────────────────────
23
+ //
24
+ // Every row carries its own `sceneId` and every gesture passes it to the store.
25
+ // That is not tidiness: `state.sceneId` follows the playhead, so a drag on a row
26
+ // three scenes away would have written to whichever scene the playhead happened
27
+ // to be sitting in.
28
+ //
29
+ // Everything geometric is @dailies-studio/timeline's, unchanged: `Ruler`,
30
+ // `PlayheadLine`, `frameToPx`, `pxToFrame` and `useAnchoredZoom` are about
31
+ // frames and pixels, which the keyframe lane and the block lane agree about.
32
+ // What is NOT reused is `TimelineModel` — see model.ts for why.
33
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
34
+ import { PlayheadLine, Ruler, frameToPx, useAnchoredZoom } from "@dailies-studio/timeline";
35
+ import type { Rational } from "@dailies-studio/engine";
36
+ import { useShotEditor, useStore } from "../../state/context.js";
37
+ import { ResizeHandle } from "../ResizeHandle.js";
38
+ import { DOCK_HEIGHT } from "../../state/panelSize.js";
39
+ import { AudioLane } from "./AudioLane.js";
40
+ import { BlockRow } from "./BlockRow.js";
41
+ import { ChannelRows } from "./ChannelRow.js";
42
+ import { LifespanRow } from "./LifespanRow.js";
43
+ import { MarkerLane } from "./MarkerLane.js";
44
+ import { SceneStrip } from "./SceneStrip.js";
45
+ import { CAMERA_ROW_ID, masterContentFrames, masterDuration, masterRows, sceneBars, timecode } from "./model.js";
46
+ import { collectSnapTargets, snapFrame, snapThresholdFrames } from "./snap.js";
47
+ import type { SnapPress, SnapScope } from "./snap.js";
48
+
49
+ /** Where the track labels end and the time area begins, in px. */
50
+ export const LABEL_WIDTH = 150;
51
+
52
+ // ── THE FRAME STAYS OUT OF THIS COMPONENT ─────────────────────────────────
53
+ //
54
+ // During playback the frame changes ~30 times a second, and this component
55
+ // renders the ENTIRE dock — every scene group, camera bar, track row and block
56
+ // bar. When it subscribed to `frame`, all of that reconciled per playback
57
+ // frame. The three things that actually show the frame are leaves below
58
+ // (DockTime, DockReadout, DockPlayhead), each subscribing to `frame` alone, so
59
+ // a playhead tick re-renders three spans and nothing else. If a future reader
60
+ // adds `useShotEditor((s) => s.frame)` back here, playback perf regresses to
61
+ // where it was — don't.
62
+
63
+ /** The current-time readout, isolated so its per-frame tick stays its own. */
64
+ function DockTime({ fps }: { fps: Rational }) {
65
+ const frame = useShotEditor((s) => s.frame);
66
+ return (
67
+ <span className="dock-time" data-testid="dock-time">
68
+ {timecode(frame, fps)}
69
+ </span>
70
+ );
71
+ }
72
+
73
+ /** The frame-counter readout, same isolation. */
74
+ function DockReadout({ total }: { total: number }) {
75
+ const frame = useShotEditor((s) => s.frame);
76
+ return (
77
+ <span className="dock-readout" data-testid="dock-readout">
78
+ {frame} / {Math.max(0, total - 1)}
79
+ </span>
80
+ );
81
+ }
82
+
83
+ /** The playhead, subscribing to the frame itself so its two mount sites ride
84
+ * a per-frame tick without dragging the dock along. */
85
+ function DockPlayhead({ ppf }: { ppf: number }) {
86
+ const frame = useShotEditor((s) => s.frame);
87
+ return <PlayheadLine frame={frame} ppf={ppf} offset={0} className="dock-playhead" />;
88
+ }
89
+
90
+ export function TimelineDock() {
91
+ const store = useStore();
92
+ const doc = useShotEditor((s) => s.doc);
93
+ const isolation = useShotEditor((s) => s.isolation);
94
+ const playing = useShotEditor((s) => s.playing);
95
+ const collapsed = useShotEditor((s) => s.dockCollapsed);
96
+ const ppf = useShotEditor((s) => s.dockPpf);
97
+ const previewMuted = useShotEditor((s) => s.previewMuted);
98
+ const previewVolume = useShotEditor((s) => s.previewVolume);
99
+ const scrollRef = useRef<HTMLDivElement | null>(null);
100
+ /**
101
+ * Which layers show their block lanes. LOCAL, not store state: it is about
102
+ * this panel's shape and nothing else reads it, and the body is hidden rather
103
+ * than unmounted on collapse (see below) so it survives the one event that
104
+ * would otherwise lose it.
105
+ */
106
+ const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set<string>());
107
+ /** The frame a live drag has snapped onto, for the guide. Null between drags. */
108
+ const [guide, setGuide] = useState<number | null>(null);
109
+
110
+ // Imperative reads, so the wheel listener never closes over a stale zoom.
111
+ const read = useCallback(() => store.getState().dockPpf, [store]);
112
+ const setPxPerFrame = useCallback((next: number) => store.setDockPpf(next), [store]);
113
+ useAnchoredZoom({
114
+ scrollRef,
115
+ labelWidth: LABEL_WIDTH,
116
+ ppf,
117
+ min: 0.2,
118
+ max: 60,
119
+ read,
120
+ setPxPerFrame,
121
+ enabled: !collapsed && doc !== null,
122
+ });
123
+
124
+ // SHIFT+WHEEL PANS. Native and non-passive for `useAnchoredZoom`'s reason: a
125
+ // React wheel handler is registered passive and cannot preventDefault, so the
126
+ // page would scroll underneath the pan.
127
+ useEffect(() => {
128
+ const el = scrollRef.current;
129
+ if (el === null) return;
130
+ const onWheel = (e: WheelEvent): void => {
131
+ if (!e.shiftKey || e.ctrlKey || e.metaKey) return;
132
+ e.preventDefault();
133
+ // A trackpad sends its own horizontal axis; a wheel with one axis sends
134
+ // deltaY and means the same thing with shift held.
135
+ el.scrollLeft += e.deltaX !== 0 ? e.deltaX : e.deltaY;
136
+ };
137
+ el.addEventListener("wheel", onWheel, { passive: false });
138
+ return () => el.removeEventListener("wheel", onWheel);
139
+ // Keyed on whether there IS a document, not on which one: the container
140
+ // does not exist until one opens, and re-binding a native listener on every
141
+ // keystroke of an edit would be work for nothing.
142
+ }, [collapsed, doc === null]);
143
+
144
+ /**
145
+ * A scroll position that cannot be written until the NEXT layout.
146
+ *
147
+ * The lane's scrollable width is React-rendered from `ppf` (`timeW` below
148
+ * feeds every `minWidth`), and `setDockPpf` does not flush a commit — so a
149
+ * `scrollLeft` written in the same handler is clamped by the browser against
150
+ * the width the lane still HAS, not the one it is about to have. Zooming
151
+ * into a late shot from a fitted document would clamp to ~0 and show the
152
+ * first shot at the last shot's zoom.
153
+ *
154
+ * `useAnchoredZoom` solved this for the wheel and its comment names the
155
+ * reason; this is the same pending-ref + layout-effect pair for the one
156
+ * scroll target the wheel never produces.
157
+ */
158
+ const pendingScroll = useRef<number | null>(null);
159
+ useLayoutEffect(() => {
160
+ const el = scrollRef.current;
161
+ if (el === null || pendingScroll.current === null) return;
162
+ el.scrollLeft = pendingScroll.current;
163
+ pendingScroll.current = null;
164
+ }, [ppf]);
165
+
166
+ /**
167
+ * Fit the whole document across the lane, or ONE SHOT if given its id.
168
+ *
169
+ * Registered with the store rather than bound to a key here, because the
170
+ * keymap is the chrome's ONE key-binding site and only this component knows
171
+ * how wide its lane is — the same division of labour `fitView` uses against
172
+ * the stage.
173
+ */
174
+ const fit = useCallback(
175
+ (sceneId?: string) => {
176
+ const el = scrollRef.current;
177
+ const current = store.getState().doc;
178
+ if (el === null || current === null) return;
179
+ const width = el.clientWidth - LABEL_WIDTH;
180
+ if (width <= 0) return;
181
+
182
+ // Whole document: the target is 0, which is in range at every zoom, so
183
+ // it needs none of the deferral below.
184
+ if (sceneId === undefined) {
185
+ const total = masterDuration(current);
186
+ if (total <= 0) return;
187
+ store.setDockPpf(width / total);
188
+ el.scrollLeft = 0;
189
+ return;
190
+ }
191
+
192
+ const bar = sceneBars(current).find((b) => b.id === sceneId);
193
+ if (bar === undefined) return;
194
+ const span = bar.end - bar.start;
195
+ if (span <= 0) return;
196
+ const before = store.getState().dockPpf;
197
+ store.setDockPpf(width / span);
198
+ // READ THE CLAMP BACK: setDockPpf pins ppf to [0.2, 60], so a very short
199
+ // shot asks for more zoom than it gets and the scroll must be computed
200
+ // against what actually landed.
201
+ const actual = store.getState().dockPpf;
202
+ const target = frameToPx(bar.start, actual);
203
+ // An unchanged ppf renders nothing, so the layout effect above would
204
+ // never fire and the scroll would strand. Layout is already correct
205
+ // there, so write it now.
206
+ if (actual === before) el.scrollLeft = target;
207
+ else pendingScroll.current = target;
208
+ },
209
+ [store],
210
+ );
211
+ useEffect(() => {
212
+ store.attachDockFit(fit);
213
+ return () => store.attachDockFit(null);
214
+ }, [store, fit]);
215
+
216
+ /**
217
+ * Freeze the snap targets at the press and hand a row back the one function a
218
+ * drag needs.
219
+ *
220
+ * Frozen, for the reason every other value in this lane is read at press: the
221
+ * document changes under a live drag, and a list re-derived per move would
222
+ * start including the thing being dragged.
223
+ */
224
+ const beginSnap = useCallback<SnapPress>(
225
+ (exclude: SnapScope["exclude"], suppressed: boolean) => {
226
+ const s = store.getState();
227
+ const targets =
228
+ suppressed || s.doc === null
229
+ ? []
230
+ : collectSnapTargets(s.doc, exclude === undefined ? { playhead: s.frame } : { playhead: s.frame, exclude });
231
+ const threshold = snapThresholdFrames(s.dockPpf);
232
+ return (candidate: number): number => {
233
+ const hit = snapFrame(candidate, targets, threshold);
234
+ setGuide(hit.target === null ? null : hit.frame);
235
+ return hit.frame;
236
+ };
237
+ },
238
+ [store],
239
+ );
240
+ // The guide is cleared by the END of a drag, and a drag has three ends:
241
+ // release, cancel, and the Escape that `startDrag` turns into a discard. Held
242
+ // here rather than threaded back through every row, because "is a drag
243
+ // running" is a property of the dock and not of any one bar.
244
+ useEffect(() => {
245
+ const clear = (): void => setGuide(null);
246
+ const onKey = (e: KeyboardEvent): void => {
247
+ if (e.key === "Escape") clear();
248
+ };
249
+ window.addEventListener("pointerup", clear);
250
+ window.addEventListener("pointercancel", clear);
251
+ window.addEventListener("keydown", onKey);
252
+ return () => {
253
+ window.removeEventListener("pointerup", clear);
254
+ window.removeEventListener("pointercancel", clear);
255
+ window.removeEventListener("keydown", onKey);
256
+ };
257
+ }, []);
258
+
259
+ // Memoised on the document identity: the rows change exactly when the doc
260
+ // does, and this component still re-renders for ppf/guide/collapse changes
261
+ // that should not pay a full row rebuild. Above the null return — hooks.
262
+ // Keyed on the ISOLATION too: going inside a card narrows the outline and the
263
+ // stage, and a dock still listing every layer in the scene is the one panel
264
+ // an author uses to find a block showing rows they cannot click.
265
+ const groups = useMemo(() => (doc === null ? [] : masterRows(doc, isolation)), [doc, isolation]);
266
+ const contentF = useMemo(
267
+ // The RULER ends at the document; the CONTENT may run past it, because a
268
+ // bar dragged off the end has to stay reachable.
269
+ () => (doc === null ? 0 : masterContentFrames(doc, groups)),
270
+ [doc, groups],
271
+ );
272
+
273
+ if (doc === null) return null;
274
+ const total = masterDuration(doc);
275
+ const timeW = frameToPx(contentF, ppf);
276
+
277
+ return (
278
+ <section
279
+ className={`timeline-dock${collapsed ? " is-collapsed" : ""}`}
280
+ data-testid="timeline-dock"
281
+ aria-label="Timeline"
282
+ >
283
+ {/* A child, not a sibling: the dock's height is content-driven up to its
284
+ max, so only the dock itself knows where its top edge actually is.
285
+ Absolute inside a `position: fixed` box, above the head it overlays. */}
286
+ {collapsed ? null : (
287
+ <ResizeHandle
288
+ axis="y"
289
+ getValue={() => store.getState().dockHeight}
290
+ // Dragging the TOP edge upward is a DECREASING clientY and a TALLER
291
+ // dock, so the pointer's travel is negated.
292
+ sign={-1}
293
+ onChange={(h) => store.setDockHeight(h)}
294
+ onReset={() => store.setDockHeight(DOCK_HEIGHT.default)}
295
+ className="dock-resize"
296
+ label="Resize the timeline"
297
+ />
298
+ )}
299
+ <header className="dock-head">
300
+ <button
301
+ type="button"
302
+ className="dock-collapse"
303
+ aria-expanded={!collapsed}
304
+ aria-label={collapsed ? "Expand timeline" : "Collapse timeline"}
305
+ onClick={() => store.setDockCollapsed(!collapsed)}
306
+ >
307
+ {collapsed ? "▴" : "▾"}
308
+ </button>
309
+ {/* The transport, on the strip it drives. The header has one too and
310
+ both call the same action — there is one `playing` bit, so they
311
+ cannot disagree, and a timeline you have to look away from to start
312
+ is a timeline you scrub with the keyboard instead. */}
313
+ <button
314
+ type="button"
315
+ className="dock-play"
316
+ aria-label={playing ? "Pause" : "Play"}
317
+ title={playing ? "Pause (Space)" : "Play (Space)"}
318
+ onClick={() => store.setPlaying(!playing)}
319
+ >
320
+ {playing ? "❚❚" : "▶"}
321
+ </button>
322
+ <DockTime fps={doc.fps} />
323
+ <span className="dock-span" data-testid="dock-span">
324
+ Start {timecode(0, doc.fps)} / End {timecode(total, doc.fps)}
325
+ </span>
326
+ {/* Wrapped, not passed by reference: `fit` now takes an optional
327
+ sceneId, and a bare handler would hand it the MouseEvent. */}
328
+ <button type="button" className="dock-fit" title="Fit the timeline (\)" onClick={() => fit()}>
329
+ Fit
330
+ </button>
331
+ {/* MONITORING, not the mix. Neither control touches the document, so
332
+ neither can change what an export sounds like — see
333
+ ShotEditorState.previewMuted. */}
334
+ <button
335
+ type="button"
336
+ className="dock-mute"
337
+ aria-label={previewMuted ? "Unmute preview" : "Mute preview"}
338
+ aria-pressed={previewMuted}
339
+ title={previewMuted ? "Preview muted" : "Mute the preview"}
340
+ onClick={() => store.setPreviewMuted(!previewMuted)}
341
+ >
342
+ {previewMuted ? "🔇" : "🔊"}
343
+ </button>
344
+ <input
345
+ className="dock-volume"
346
+ type="range"
347
+ min={0}
348
+ max={1}
349
+ step={0.01}
350
+ value={previewVolume}
351
+ aria-label="Preview volume"
352
+ onChange={(e) => store.setPreviewVolume(Number(e.currentTarget.value))}
353
+ />
354
+ <DockReadout total={total} />
355
+ </header>
356
+
357
+ {/* The body is HIDDEN, not unmounted, when collapsed. Unmounting would
358
+ throw away the scroll position and every row's state, and the author
359
+ would come back to a dock that had forgotten where they were —
360
+ dockCollapse.test.tsx is the gate. */}
361
+ <div className="dock-body" hidden={collapsed} data-testid="dock-body">
362
+ <div className="dock-scroll" ref={scrollRef} data-testid="dock-scroll">
363
+ {/* INSIDE the scroller, with the same content width as the tracks.
364
+ It used to sit outside, and that was correct while it was a macro
365
+ lane over a scene-local micro one — the two never shared an x. They
366
+ share one now, so a strip that did not scroll with the tracks would
367
+ draw every scene bar somewhere its own boundary line is not. */}
368
+ <div style={{ minWidth: LABEL_WIDTH + timeW }}>
369
+ <SceneStrip ppf={ppf} labelWidth={LABEL_WIDTH} />
370
+ </div>
371
+ <div className="dock-ruler" style={{ marginLeft: LABEL_WIDTH, minWidth: timeW }}>
372
+ {/* MASTER frames end to end. The seek is the identity now — the two
373
+ clocks used to meet here, and that conversion was the one place a
374
+ sign error put the playhead in another scene. */}
375
+ <Ruler
376
+ durationFrames={total}
377
+ contentF={contentF}
378
+ ppf={ppf}
379
+ timeW={timeW}
380
+ formatTick={(f) => timecode(f, doc.fps)}
381
+ onSeek={(f) => store.seek(f)}
382
+ >
383
+ <DockPlayhead ppf={ppf} />
384
+ </Ruler>
385
+ </div>
386
+
387
+ <div className="dock-tracks" data-testid="dock-tracks" style={{ minWidth: LABEL_WIDTH + timeW }}>
388
+ {/* The scene boundaries, drawn THROUGH the tracks. Derived from the
389
+ same prefix sum the strip above uses, so the line and the bar it
390
+ divides can never be a frame apart. */}
391
+ <div className="dock-overlay" style={{ left: LABEL_WIDTH }} aria-hidden="true">
392
+ {groups.map((group) =>
393
+ group.start === 0 ? null : (
394
+ <span
395
+ key={group.id}
396
+ className="dock-boundary"
397
+ data-boundary={group.id}
398
+ style={{ left: frameToPx(group.start, ppf) }}
399
+ />
400
+ ),
401
+ )}
402
+ {guide === null ? null : (
403
+ <span className="dock-snap-guide" data-testid="snap-guide" style={{ left: frameToPx(guide, ppf) }} />
404
+ )}
405
+ <DockPlayhead ppf={ppf} />
406
+ </div>
407
+
408
+ {groups.map((group) => (
409
+ <div className="dock-scene-group" key={group.id} data-scene-group={group.id}>
410
+ {/* The camera is a ROW like any other, one per scene at its own
411
+ master position, so a camera move trims and retimes with the
412
+ same gestures a block does. */}
413
+ <BlockRow
414
+ label="Camera"
415
+ sceneId={group.id}
416
+ layerId={CAMERA_ROW_ID}
417
+ depth={0}
418
+ bars={group.camera}
419
+ ppf={ppf}
420
+ labelWidth={LABEL_WIDTH}
421
+ offsetFrames={group.start}
422
+ beginSnap={beginSnap}
423
+ // The same action ⇧C runs. A camera move has no preset to
424
+ // pick, so unlike a layer's blocks it can be created from the
425
+ // row; the scene is passed so a row that is not under the
426
+ // playhead is refused by name rather than writing somewhere else.
427
+ onAdd={() => store.addShotAtPlayhead(group.id)}
428
+ addLabel="+ move"
429
+ />
430
+ {group.rows.map((row) => (
431
+ <Fragment key={row.layerId}>
432
+ <LifespanRow
433
+ row={row}
434
+ sceneId={group.id}
435
+ sceneStart={group.start}
436
+ ppf={ppf}
437
+ labelWidth={LABEL_WIDTH}
438
+ expanded={open.has(row.layerId)}
439
+ onToggle={() =>
440
+ setOpen((was) => {
441
+ const next = new Set(was);
442
+ if (!next.delete(row.layerId)) next.add(row.layerId);
443
+ return next;
444
+ })
445
+ }
446
+ beginSnap={beginSnap}
447
+ />
448
+ {/* The Figma/Lottie read: one derived row per channel the
449
+ blocks write, diamonds at each block's ends. The
450
+ preset-named block lane is gone from the expansion —
451
+ the preset lives in the span tooltip and the Animate
452
+ tab. See ChannelRow.tsx. */}
453
+ {open.has(row.layerId) &&
454
+ (() => {
455
+ const layer = doc.scenesById[group.id]?.layersById[row.layerId];
456
+ return layer === undefined ? null : (
457
+ <ChannelRows
458
+ layer={layer}
459
+ sceneId={group.id}
460
+ depth={row.depth + 1}
461
+ ppf={ppf}
462
+ labelWidth={LABEL_WIDTH}
463
+ offsetFrames={group.start}
464
+ fps={doc.fps}
465
+ lifespan={row.lifespan}
466
+ beginSnap={beginSnap}
467
+ />
468
+ );
469
+ })()}
470
+ </Fragment>
471
+ ))}
472
+ </div>
473
+ ))}
474
+
475
+ {/* UNDER the video and OVER the audio, which is where every NLE
476
+ puts the two. Both are outside the scene groups, and
477
+ deliberately: `doc.audio` and `doc.markers` are MASTER-timed and
478
+ belong to no scene, so a lane nested in one would be drawing a
479
+ lie. The marks sit directly over the waveform because they are
480
+ almost always beats in it. */}
481
+ <MarkerLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
482
+ <AudioLane ppf={ppf} labelWidth={LABEL_WIDTH} beginSnap={beginSnap} />
483
+ </div>
484
+ </div>
485
+ </div>
486
+ </section>
487
+ );
488
+ }