@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,341 @@
1
+ // The focused block's params, timing and easing.
2
+ //
3
+ // ── WHY THIS IS NOT `KeyframeEditor` ───────────────────────────────────
4
+ //
5
+ // `@dailies-studio/timeline`'s ease editor is a fine component and its INPUTS
6
+ // are keyframe-shaped: `refs: KeyframeAddr[]` and a `TimelineGesture` whose
7
+ // capture emits `{kind:"setEase", refs}`. There are no keyframes in this lane,
8
+ // so every one of those would be a fabricated address. What DOES transfer is
9
+ // everything pure: `EASE_PRESETS`, `matchPreset`, `SPRING_DEFAULT`, and the pad
10
+ // geometry `padToPx`/`pxToPad`. Those are imported, not re-derived, so the two
11
+ // chromes cannot disagree about what "Ease Out" means.
12
+ //
13
+ // ── THE SPRING WART, AND THE TAIL THAT ADMITS IT ───────────────────────
14
+ //
15
+ // A spring has an INTRINSIC duration in seconds. So dragging a spring block's
16
+ // right edge changes where its window CLIPS, not how fast it settles — which is
17
+ // correct physics and matches springs everywhere else in the engine, but is
18
+ // invisible unless the UI says so. It says so twice: the duration field is
19
+ // disabled with the reason attached, and the preview draws the part of the
20
+ // settle that falls OUTSIDE the block window as a dashed tail. If the tail is
21
+ // long, the block is cutting the spring off mid-flight, and the author can see
22
+ // exactly that.
23
+ import { useRef } from "react";
24
+ import {
25
+ blockProgressAt,
26
+ motionPresets,
27
+ presetsForSlot,
28
+ MIN_ANIMATE_FRAMES,
29
+ } from "@dailies-studio/shot/doc";
30
+ import type { AnimBlock } from "@dailies-studio/shot/doc";
31
+ import { EASE_PRESETS, SPRING_DEFAULT, easeSelection, padToPx, pxToPad } from "@dailies-studio/timeline";
32
+ import type { PadGeom } from "@dailies-studio/timeline";
33
+ import { InspectorRow, InspectorSection, NumberField, polylinePoints } from "@dailies-studio/ui";
34
+ import type { Ease, SpringEase, Value } from "@dailies-studio/engine";
35
+ import type { PointerEvent as ReactPointerEvent } from "react";
36
+ import { useShotEditor, useStore } from "../../../state/context.js";
37
+ import { ParamRow } from "../ParamRow.js";
38
+ import { useFocusedBlock } from "./useBlocks.js";
39
+
40
+ const PAD_GEOM: PadGeom = { width: 216, height: 132, padY: 30 };
41
+ const PREVIEW_W = 216;
42
+ const PREVIEW_H = 72;
43
+ const PREVIEW_PAD = 8;
44
+ const PREVIEW_SAMPLES = 96;
45
+ /** How far past the block's own window the settle tail is drawn, as a multiple
46
+ * of the window. Two windows is enough to show a spring that has not settled
47
+ * and short enough that a settled one draws a flat stub. */
48
+ const TAIL_FACTOR = 2;
49
+
50
+ export function BlockTiming() {
51
+ const store = useStore();
52
+ // A STATE SLICE OR NULL, never a fresh fallback object: a selector that
53
+ // mints `{num: 30, den: 1}` per call gives useSyncExternalStore a snapshot
54
+ // that is never Object.is-stable, which is an infinite re-render the moment
55
+ // this mounts with no document. Unreachable today only because the
56
+ // inspector early-returns on a null doc — this keeps it unreachable.
57
+ const fps = useShotEditor((s) => s.doc?.fps ?? null) ?? { num: 30, den: 1 };
58
+ const ref = useFocusedBlock();
59
+ if (ref === null) return null;
60
+ const { layer, block } = ref;
61
+ const preset = motionPresets[block.preset];
62
+ const duration = Math.max(1, Math.trunc(block.durationFrames));
63
+ const spring = block.ease?.type === "spring";
64
+
65
+ return (
66
+ <InspectorSection title="Timing" persistKey="shot:animate:timing">
67
+ <InspectorRow>
68
+ <NumberField
69
+ value={block.frame}
70
+ label="Start"
71
+ ariaLabel="Block start"
72
+ min={0}
73
+ step={1}
74
+ onCommit={(next) => store.setBlockTiming(layer.id, block.id, Math.max(0, next), duration)}
75
+ />
76
+ <NumberField
77
+ value={duration}
78
+ label="Frames"
79
+ ariaLabel="Block frames"
80
+ min={MIN_ANIMATE_FRAMES}
81
+ step={1}
82
+ // A spring's duration is set by its physics. The field still SHOWS the
83
+ // window (it is real: it is where the block stops contributing), it
84
+ // just refuses to pretend that changing it retimes the motion.
85
+ disabled={spring}
86
+ {...(spring ? { title: "A spring settles in its own time — edit stiffness and damping." } : {})}
87
+ onCommit={(next) =>
88
+ store.setBlockTiming(layer.id, block.id, block.frame, Math.max(MIN_ANIMATE_FRAMES, next))
89
+ }
90
+ />
91
+ </InspectorRow>
92
+
93
+ {preset !== undefined &&
94
+ preset.params.map((spec) => (
95
+ <ParamRow
96
+ key={spec.name}
97
+ spec={spec}
98
+ value={paramValue(block, spec.name, spec.default)}
99
+ onCommit={(next) => store.setBlockParams(layer.id, block.id, { [spec.name]: next })}
100
+ />
101
+ ))}
102
+
103
+ <EaseControl
104
+ {...(block.ease === undefined ? {} : { ease: block.ease })}
105
+ onEase={(ease) => store.setBlockEase(layer.id, block.id, ease)}
106
+ onScrub={(ease) => store.setBlockEase(layer.id, block.id, ease)}
107
+ />
108
+ <SettlePreview block={block} fps={fps} />
109
+
110
+ {/* The preset itself is swappable in place, so "make that a slide instead"
111
+ keeps the block's timing and easing rather than deleting and redoing. */}
112
+ <InspectorRow label="Preset">
113
+ <select
114
+ className="select"
115
+ aria-label="Block preset"
116
+ value={block.preset}
117
+ onChange={(e) => store.setBlockPreset(layer.id, block.id, e.target.value)}
118
+ >
119
+ {presetsForSlot(block.slot).map((p) => (
120
+ <option key={p.id} value={p.id}>
121
+ {p.label}
122
+ </option>
123
+ ))}
124
+ </select>
125
+ </InspectorRow>
126
+ </InspectorSection>
127
+ );
128
+ }
129
+
130
+ function paramValue(block: AnimBlock, name: string, fallback: Value): Value {
131
+ const v = block.params[name];
132
+ return v === undefined ? fallback : v;
133
+ }
134
+
135
+ // ---- easing ------------------------------------------------------------------
136
+
137
+ /**
138
+ * The ease row, plus whichever editor the chosen ease needs.
139
+ *
140
+ * Takes an `Ease` and not a block, because the CAMERA's shots carry one too and
141
+ * `clipProgress01` reads it from both the same way. Two ease editors would be
142
+ * two places for "Ease Out" to mean something slightly different, which is the
143
+ * whole reason `EASE_PRESETS` is imported rather than re-declared.
144
+ */
145
+ export function EaseControl(props: {
146
+ ease?: Ease;
147
+ /** What the select is called. The subject differs between the two callers and
148
+ * the accessible name has to say which one the author is looking at. */
149
+ ariaLabel?: string;
150
+ onEase(ease: Ease): void;
151
+ onScrub(ease: Ease): void;
152
+ }) {
153
+ const ease = props.ease;
154
+ // `easeSelection` takes a LIST because the shared editor is multi-select. One
155
+ // block is a list of one, and reusing it keeps "Custom" meaning exactly what
156
+ // it means in the other two chromes.
157
+ const sel = easeSelection(ease === undefined ? [] : [ease]);
158
+
159
+ return (
160
+ <>
161
+ <InspectorRow label="Ease">
162
+ <select
163
+ className="select"
164
+ aria-label={props.ariaLabel ?? "Block ease"}
165
+ value={sel.selectValue}
166
+ onChange={(e) => {
167
+ const preset = EASE_PRESETS.find((p) => p.id === e.target.value);
168
+ if (preset !== undefined) props.onEase(preset.ease);
169
+ }}
170
+ >
171
+ {EASE_PRESETS.map((p) => (
172
+ <option key={p.id} value={p.id}>
173
+ {p.label}
174
+ </option>
175
+ ))}
176
+ {sel.selectValue === "custom" && (
177
+ <option value="custom" disabled>
178
+ Custom
179
+ </option>
180
+ )}
181
+ {sel.selectValue === "mixed" && (
182
+ <option value="mixed" disabled>
183
+ —
184
+ </option>
185
+ )}
186
+ </select>
187
+ </InspectorRow>
188
+ {sel.ease?.type === "bezier" && <BezierPad handles={sel.ease.handles} onEase={props.onScrub} />}
189
+ {sel.ease?.type === "spring" && <SpringFields spring={sel.ease} onEase={props.onEase} />}
190
+ </>
191
+ );
192
+ }
193
+
194
+ function BezierPad(props: {
195
+ handles: readonly [number, number, number, number];
196
+ onEase(ease: Ease): void;
197
+ }) {
198
+ const svgRef = useRef<SVGSVGElement | null>(null);
199
+ const drag = useRef<{ index: 0 | 1; base: [number, number, number, number] } | null>(null);
200
+
201
+ const down =
202
+ (index: 0 | 1) =>
203
+ (e: ReactPointerEvent<SVGCircleElement>): void => {
204
+ if (e.button !== 0) return;
205
+ e.currentTarget.setPointerCapture(e.pointerId);
206
+ const h = props.handles;
207
+ drag.current = { index, base: [h[0], h[1], h[2], h[3]] };
208
+ };
209
+
210
+ const move = (e: ReactPointerEvent<SVGCircleElement>): void => {
211
+ const d = drag.current;
212
+ const svg = svgRef.current;
213
+ if (d === null || svg === null) return;
214
+ const rect = svg.getBoundingClientRect();
215
+ const [x, y] = pxToPad(PAD_GEOM, e.clientX - rect.left, e.clientY - rect.top);
216
+ // Rebuilt from the DRAG-START handles, so the untouched one cannot creep as
217
+ // the live value it is being compared against changes underneath.
218
+ const handles: [number, number, number, number] =
219
+ d.index === 0 ? [x, y, d.base[2], d.base[3]] : [d.base[0], d.base[1], x, y];
220
+ props.onEase({ type: "bezier", handles });
221
+ };
222
+
223
+ const up = (e: ReactPointerEvent<SVGCircleElement>): void => {
224
+ if (drag.current === null) return;
225
+ drag.current = null;
226
+ e.currentTarget.releasePointerCapture(e.pointerId);
227
+ };
228
+
229
+ const h = props.handles;
230
+ const [ax, ay] = padToPx(PAD_GEOM, 0, 0);
231
+ const [bx, by] = padToPx(PAD_GEOM, 1, 1);
232
+ const [x1, y1] = padToPx(PAD_GEOM, h[0], h[1]);
233
+ const [x2, y2] = padToPx(PAD_GEOM, h[2], h[3]);
234
+ const handle = (index: 0 | 1) => ({
235
+ className: "insp-pad-handle",
236
+ r: 4.5,
237
+ onPointerDown: down(index),
238
+ onPointerMove: move,
239
+ onPointerUp: up,
240
+ onPointerCancel: () => {
241
+ drag.current = null;
242
+ },
243
+ });
244
+
245
+ return (
246
+ <svg ref={svgRef} className="insp-pad" width={PAD_GEOM.width} height={PAD_GEOM.height}>
247
+ <rect
248
+ className="insp-pad-box"
249
+ x={0.5}
250
+ y={PAD_GEOM.padY + 0.5}
251
+ width={PAD_GEOM.width - 1}
252
+ height={PAD_GEOM.height - 2 * PAD_GEOM.padY - 1}
253
+ rx={3}
254
+ />
255
+ <path className="insp-pad-curve" d={`M ${ax} ${ay} C ${x1} ${y1} ${x2} ${y2} ${bx} ${by}`} />
256
+ <line className="insp-pad-arm" x1={ax} y1={ay} x2={x1} y2={y1} />
257
+ <line className="insp-pad-arm" x1={bx} y1={by} x2={x2} y2={y2} />
258
+ <circle {...handle(0)} cx={x1} cy={y1} />
259
+ <circle {...handle(1)} cx={x2} cy={y2} />
260
+ </svg>
261
+ );
262
+ }
263
+
264
+ const SPRING_FIELDS = [
265
+ { key: "stiffness", label: "Stiffness", min: 1, max: 1000, step: 1 },
266
+ { key: "damping", label: "Damping", min: 0, max: 200, step: 0.5 },
267
+ { key: "mass", label: "Mass", min: 0.05, max: 20, step: 0.05 },
268
+ ] as const;
269
+
270
+ function SpringFields(props: { spring: SpringEase; onEase(ease: Ease): void }) {
271
+ return (
272
+ <InspectorRow>
273
+ {SPRING_FIELDS.map((f) => (
274
+ <NumberField
275
+ key={f.key}
276
+ value={props.spring[f.key]}
277
+ label={f.label}
278
+ ariaLabel={`Spring ${f.label.toLowerCase()}`}
279
+ min={f.min}
280
+ max={f.max}
281
+ step={f.step}
282
+ onCommit={(v) => props.onEase({ ...SPRING_DEFAULT, ...props.spring, [f.key]: v })}
283
+ />
284
+ ))}
285
+ </InspectorRow>
286
+ );
287
+ }
288
+
289
+ // ---- the preview, and the tail --------------------------------------------------
290
+
291
+ function SettlePreview(props: { block: AnimBlock; fps: { num: number; den: number } }) {
292
+ const { block, fps } = props;
293
+ const duration = Math.max(1, Math.trunc(block.durationFrames));
294
+ const spring = block.ease?.type === "spring";
295
+ // Sampled through `blockProgressAt` — the SAME function the renderer folds
296
+ // with. A preview drawn from a local re-implementation of the ease would
297
+ // eventually disagree with the pixels, which is the one thing a preview must
298
+ // never do.
299
+ const to = block.frame + (spring ? duration * (1 + TAIL_FACTOR) : duration);
300
+ const samples: Array<{ frame: number; value: number }> = [];
301
+ for (let i = 0; i < PREVIEW_SAMPLES; i += 1) {
302
+ const frame = block.frame + ((to - block.frame) * i) / (PREVIEW_SAMPLES - 1);
303
+ samples.push({ frame, value: blockProgressAt(block, frame, fps) ?? 0 });
304
+ }
305
+ const line = polylinePoints(samples, PREVIEW_W, PREVIEW_H, PREVIEW_PAD);
306
+ if (line === null) return null;
307
+ // The window's right edge in preview px, so the tail is drawn from exactly
308
+ // where the block stops contributing.
309
+ const edgeX = spring ? (duration / (to - block.frame)) * PREVIEW_W : PREVIEW_W;
310
+
311
+ return (
312
+ <div className="insp-preview animate-preview" data-testid="block-preview">
313
+ <svg width={PREVIEW_W} height={PREVIEW_H}>
314
+ {/* One polyline, clipped twice: solid inside the window, dashed after.
315
+ Two <polyline>s over the same points with opposite clips beats
316
+ splitting the sample array, because the split point then cannot land
317
+ between two samples and jitter as the duration changes. */}
318
+ <defs>
319
+ <clipPath id="blk-in">
320
+ <rect x={0} y={0} width={edgeX} height={PREVIEW_H} />
321
+ </clipPath>
322
+ <clipPath id="blk-out">
323
+ <rect x={edgeX} y={0} width={PREVIEW_W - edgeX} height={PREVIEW_H} />
324
+ </clipPath>
325
+ </defs>
326
+ <polyline className={spring ? "is-spring" : ""} points={line.points} clipPath="url(#blk-in)" />
327
+ {spring && (
328
+ <>
329
+ <polyline className="is-tail" points={line.points} clipPath="url(#blk-out)" />
330
+ <line className="animate-preview-edge" x1={edgeX} y1={0} x2={edgeX} y2={PREVIEW_H} />
331
+ </>
332
+ )}
333
+ </svg>
334
+ {spring && (
335
+ <span className="insp-dim animate-preview-note">
336
+ dashed = after the block ends; the spring holds at its clipped value
337
+ </span>
338
+ )}
339
+ </div>
340
+ );
341
+ }
@@ -0,0 +1,85 @@
1
+ // The During list: the blocks the Animate gesture writes, plus a palette to add
2
+ // one without a gesture.
3
+ //
4
+ // Each row FOCUSES rather than expanding inline. `state.selectedBlock` is the
5
+ // same bit the dock's bars write, so clicking a row here highlights the bar
6
+ // there and puts its timing in one editor below — instead of N accordion
7
+ // editors that each claim to be "the" timing controls.
8
+ import { motionPresets, presetsForSlot } from "@dailies-studio/shot/doc";
9
+ import { InspectorSection } from "@dailies-studio/ui";
10
+ import { useShotEditor, useStore } from "../../../state/context.js";
11
+ import { useSelectedLayers } from "../useDocWrite.js";
12
+ import { useBlocksInSlot } from "./useBlocks.js";
13
+
14
+ const DEFAULT_FRAMES = 24;
15
+
16
+ export function DuringBlocks() {
17
+ const store = useStore();
18
+ const layers = useSelectedLayers();
19
+ const blocks = useBlocksInSlot("during");
20
+ const focused = useShotEditor((s) => s.selectedBlock);
21
+ // The inspector's subject scene. Its layers are the selection's, so this is
22
+ // the scene every row here belongs to by construction.
23
+ const sceneId = useShotEditor((s) => s.sceneId);
24
+ const multi = layers.length > 1;
25
+ if (layers.length === 0 || sceneId === null) return null;
26
+
27
+ return (
28
+ <InspectorSection title="During" persistKey="shot:animate:during">
29
+ {blocks.length === 0 ? (
30
+ <p className="inspector-empty">
31
+ Press ⇧A, then drag on the canvas. The edit becomes a block ending at the playhead.
32
+ </p>
33
+ ) : (
34
+ <ul className="animate-blocks" aria-label="During blocks">
35
+ {blocks.map(({ layer, block }) => {
36
+ const on = focused?.layerId === layer.id && focused.blockId === block.id;
37
+ const label = motionPresets[block.preset]?.label ?? block.preset;
38
+ return (
39
+ <li key={`${layer.id}:${block.id}`}>
40
+ <button
41
+ type="button"
42
+ className="animate-block-row"
43
+ aria-pressed={on}
44
+ aria-label={`Block ${label} on ${layer.label}`}
45
+ onClick={() => store.selectBlock({ sceneId, layerId: layer.id, blockId: block.id })}
46
+ >
47
+ <span className="animate-block-name">
48
+ {label}
49
+ {/* Which layer, but only when there IS more than one — on a
50
+ single selection the suffix would be noise on every row. */}
51
+ {multi && <em className="animate-block-owner"> · {layer.label}</em>}
52
+ </span>
53
+ <span className="animate-block-span">
54
+ f{block.frame}–{block.frame + Math.max(1, Math.trunc(block.durationFrames))}
55
+ </span>
56
+ </button>
57
+ <button
58
+ type="button"
59
+ className="animate-block-remove"
60
+ aria-label={`Remove ${label} on ${layer.label}`}
61
+ onClick={() => store.removeBlock(layer.id, block.id)}
62
+ >
63
+ ✕
64
+ </button>
65
+ </li>
66
+ );
67
+ })}
68
+ </ul>
69
+ )}
70
+ <div className="animate-palette" role="group" aria-label="During presets">
71
+ {presetsForSlot("during").map((preset) => (
72
+ <button
73
+ key={preset.id}
74
+ type="button"
75
+ className="animate-preset"
76
+ aria-label={`Add ${preset.label}`}
77
+ onClick={() => store.addBlock("during", preset.id, DEFAULT_FRAMES)}
78
+ >
79
+ + {preset.label}
80
+ </button>
81
+ ))}
82
+ </div>
83
+ </InspectorSection>
84
+ );
85
+ }
@@ -0,0 +1,57 @@
1
+ // The In and Out palettes: click a preset, every selected layer gets that block.
2
+ //
3
+ // ── WHY THEY STACK RATHER THAN REPLACE ─────────────────────────────────
4
+ //
5
+ // Clicking Fade then Slide gives a layer that fades AND slides, because the
6
+ // channel combinators commute — the fold has no ordering rule to get wrong. So
7
+ // the palette is a set of buttons, not a radio group, and a preset already on
8
+ // the layer shows as active and REMOVES on a second click. A replace-only
9
+ // palette would make "fade and slide in" unauthorable, which is the single most
10
+ // common entrance in the entire category.
11
+ import { presetsForSlot } from "@dailies-studio/shot/doc";
12
+ import type { BlockSlot } from "@dailies-studio/shot/doc";
13
+ import { InspectorSection } from "@dailies-studio/ui";
14
+ import { useStore } from "../../../state/context.js";
15
+ import { useSelectedLayers } from "../useDocWrite.js";
16
+ import { useBlocksInSlot } from "./useBlocks.js";
17
+
18
+ /** The default window for a palette-added entrance or exit. */
19
+ const DEFAULT_FRAMES = 12;
20
+
21
+ export function InOutPresets(props: { slot: BlockSlot; title: string }) {
22
+ const store = useStore();
23
+ const layers = useSelectedLayers();
24
+ const present = useBlocksInSlot(props.slot);
25
+ if (layers.length === 0) return null;
26
+
27
+ // A preset counts as "on" when EVERY selected layer carries it. Partial
28
+ // coverage reads as off, so one more click completes the set rather than
29
+ // clearing the layers that already had it.
30
+ const coverage = new Map<string, number>();
31
+ for (const { block } of present) coverage.set(block.preset, (coverage.get(block.preset) ?? 0) + 1);
32
+
33
+ return (
34
+ <InspectorSection title={props.title} persistKey={`shot:animate:${props.slot}`}>
35
+ <div className="animate-palette" role="group" aria-label={`${props.title} presets`}>
36
+ {presetsForSlot(props.slot).map((preset) => {
37
+ const on = (coverage.get(preset.id) ?? 0) >= layers.length;
38
+ return (
39
+ <button
40
+ key={preset.id}
41
+ type="button"
42
+ className="animate-preset"
43
+ aria-label={`${props.title} ${preset.label}`}
44
+ aria-pressed={on}
45
+ onClick={() => {
46
+ if (on) store.removeBlocksByPreset(props.slot, preset.id);
47
+ else store.addBlock(props.slot, preset.id, DEFAULT_FRAMES);
48
+ }}
49
+ >
50
+ {preset.label}
51
+ </button>
52
+ );
53
+ })}
54
+ </div>
55
+ </InspectorSection>
56
+ );
57
+ }
@@ -0,0 +1,150 @@
1
+ // What the block stack ADDS UP TO on this layer, at this frame.
2
+ //
3
+ // ── WHY A READOUT AND NOT SEVEN MORE FIELDS ─────────────────────────────
4
+ //
5
+ // Blocks are DELTA writers and they compose (resolve.ts). So "what is this
6
+ // layer's opacity right now" has no single author: it is the base times every
7
+ // active block's contribution, and there is nothing for a field to write into
8
+ // that would not have to pick one of the contributors and hide the rest. The
9
+ // Design tab already solves the writable half — `setEffectiveField` compensates
10
+ // a typed value back through the fold — and this is the other half: a stacked
11
+ // delta model where the author can see the sum is legible, and one where they
12
+ // cannot is a pile of numbers that do not match the screen.
13
+ //
14
+ // ── IT CANNOT DISAGREE WITH THE STAGE, BY CONSTRUCTION ─────────────────
15
+ //
16
+ // Every number here comes out of `resolveLayerAt`, which is the same function
17
+ // the runtime writes onto the mesh and the same one the renderer samples. There
18
+ // is no second fold in this file and there must never be one: a readout that
19
+ // re-derived the composition would be a second answer to a question that has
20
+ // exactly one, and it would drift the first time a channel gained a combinator.
21
+ //
22
+ // ── "WRITING" MEANS DECLARED, NOT DIFFERENT ────────────────────────────
23
+ //
24
+ // A channel is marked when a live block's delta CONTAINS it, not when the
25
+ // folded value happens to differ from identity. The two come apart constantly:
26
+ // an out-fade at its first frame writes opacity 1, and an in-fade that finished
27
+ // forty frames ago still writes opacity 1 and still owns the channel. Marking
28
+ // on "differs from identity" would say "nothing is animating this" one frame
29
+ // before a fade visibly starts, and would need a second copy of resolve.ts's
30
+ // identity table to say it. Declared is both cheaper and truer: the mark means
31
+ // "a block governs this channel", which is exactly what an author about to type
32
+ // in the Design tab needs to know.
33
+ import { InspectorSection, formatValue } from "@dailies-studio/ui";
34
+ import {
35
+ blockProgressAt,
36
+ deltaFor,
37
+ layerDeltaAt,
38
+ motionPresets,
39
+ resolveLayerAt,
40
+ } from "@dailies-studio/shot/doc";
41
+ import type { Layer3D, LayerDelta } from "@dailies-studio/shot/doc";
42
+ import type { Rational } from "@dailies-studio/engine";
43
+ import { CHANNELS } from "../../../channels.js";
44
+ import type { Channel } from "../../../channels.js";
45
+ import { useShotEditor } from "../../../state/context.js";
46
+ import { useInspectorFrame, useSelectedLayers } from "../useDocWrite.js";
47
+
48
+ export function ResolvedChannels() {
49
+ const layers = useSelectedLayers();
50
+ const { sceneFrame } = useInspectorFrame();
51
+ const fps = useShotEditor((s) => s.doc?.fps ?? null);
52
+ // THE FIRST selected layer. A fan-out would have to answer "what is the
53
+ // resolved opacity of four layers", which is either four readouts or a
54
+ // "Mixed" that tells the author nothing about the composition they came here
55
+ // to understand. One subject, named in the header when there is a choice.
56
+ const layer = layers[0];
57
+ if (layer === undefined || fps === null) return null;
58
+
59
+ const state = resolveLayerAt(layer, sceneFrame, fps);
60
+ const delta = layerDeltaAt(layer, sceneFrame, fps);
61
+ const writers = writersAt(layer, sceneFrame, fps);
62
+
63
+ return (
64
+ <InspectorSection
65
+ title="Resolved"
66
+ persistKey="shot:animate:resolved"
67
+ action={
68
+ <span className="insp-dim">
69
+ {layers.length > 1 ? `${layer.label} · ` : ""}f{sceneFrame}
70
+ </span>
71
+ }
72
+ >
73
+ <dl className="resolved-list" aria-label="Resolved channels">
74
+ {CHANNELS.map((channel) => {
75
+ const by = writers.get(channel.key);
76
+ return (
77
+ <div
78
+ key={channel.key}
79
+ className="resolved-row"
80
+ data-channel={channel.key}
81
+ data-live={by !== undefined}
82
+ >
83
+ <dt className="resolved-label">{channel.label}</dt>
84
+ <dd className="resolved-value">{channel.read(state)}</dd>
85
+ <dd
86
+ className="resolved-by"
87
+ title={by === undefined ? undefined : `${by.join(", ")} → ${deltaText(channel.key, delta)}`}
88
+ >
89
+ {by?.join(", ") ?? ""}
90
+ </dd>
91
+ </div>
92
+ );
93
+ })}
94
+ </dl>
95
+ </InspectorSection>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Which live blocks declare each channel, by preset label.
101
+ *
102
+ * `blockProgressAt` + `deltaFor` is the same pair `layerDeltaAt` folds with, so
103
+ * a block counted here is exactly a block that contributed to the numbers
104
+ * beside it — including the outside-the-window holds, which are the cases a
105
+ * naive "is the playhead inside the bar" test gets wrong in both directions.
106
+ */
107
+ function writersAt(layer: Layer3D, sceneFrame: number, fps: Rational): Map<Channel, string[]> {
108
+ const out = new Map<Channel, string[]>();
109
+ for (const block of layer.blocks) {
110
+ const p = blockProgressAt(block, sceneFrame, fps);
111
+ if (p === null) continue;
112
+ const label = motionPresets[block.preset]?.label ?? block.preset;
113
+ for (const key of Object.keys(deltaFor(block.preset, block.slot, block.params, p)) as Channel[]) {
114
+ const at = out.get(key);
115
+ if (at === undefined) out.set(key, [label]);
116
+ // Two Fades on one channel are one name: the row says WHAT governs the
117
+ // channel, and the count is the dock's job.
118
+ else if (!at.includes(label)) at.push(label);
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** The fold's own contribution to one channel, in that channel's combinator's
125
+ * terms — a multiply reads "×", an add reads signed. It is the answer to "how
126
+ * much of this number is the blocks", which the resolved value alone hides. */
127
+ function deltaText(key: Channel, d: Required<LayerDelta>): string {
128
+ switch (key) {
129
+ case "opacity":
130
+ return `×${formatValue(d.opacity, 0.01)}`;
131
+ case "offset":
132
+ return d.offset.map(signed).join(", ");
133
+ case "scale":
134
+ return d.scale.map((v) => `×${formatValue(v, 0.01)}`).join(", ");
135
+ case "tilt":
136
+ return d.tilt.map((v) => `${signed(v)}°`).join(", ");
137
+ case "roll":
138
+ return `${signed(d.roll)}°`;
139
+ case "blurPx":
140
+ return `${formatValue(d.blurPx, 0.1)} px`;
141
+ case "reveal":
142
+ return `${formatValue(d.reveal * 100, 1)}%`;
143
+ case "textPhase":
144
+ return `${formatValue(d.textPhase * 100, 1)}%`;
145
+ }
146
+ }
147
+
148
+ function signed(v: number): string {
149
+ return `${v >= 0 ? "+" : ""}${formatValue(v, 0.1)}`;
150
+ }