@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,126 @@
1
+ // One camera move: what kind it is, how it eases, and where it starts and ends.
2
+ // The Animate tab's subject whenever the focused bar is on the CAMERA row.
3
+ //
4
+ // ── WHY THIS PANEL IS PLAIN FIELDS AND THE SCENE'S CAMERA IS NOT ────────
5
+ //
6
+ // `scene.camera` is invisible from the first shot's start onwards, which is why
7
+ // CameraSection has to read effective, write base and refuse. A SHOT has no
8
+ // such hazard: `from` and `to` are exactly what `cameraAt` interpolates between
9
+ // at this shot's own frames, so writing either is visible the moment the
10
+ // playhead is inside the move. Two truths, one of them safe, and the difference
11
+ // is worth stating because the two panels look alike and are not.
12
+ //
13
+ // The "set from current framing" buttons are the bridge back. They take what is
14
+ // on screen at the playhead (`cameraAt`, the same read every other surface
15
+ // makes) and write it into one end, so an author who framed a pose by dragging
16
+ // the frustum keeps it without retyping five numbers.
17
+ import { InspectorRow, InspectorSection, SegmentedControl } from "@dailies-studio/ui";
18
+ import { cameraAt } from "@dailies-studio/shot/doc";
19
+ import type { Framing, MoveShot } from "@dailies-studio/shot/doc";
20
+ import { useShotEditor, useStore } from "../../../state/context.js";
21
+ import { CAMERA_ROW_ID } from "../../timeline/model.js";
22
+ import { FramingRows } from "../FramingRows.js";
23
+ import { sceneLocalFrame, useInspectorFrame } from "../useDocWrite.js";
24
+ import { EaseControl } from "./BlockTiming.js";
25
+
26
+ const KINDS = [
27
+ { value: "dolly", label: "Dolly" },
28
+ { value: "zoom", label: "Zoom" },
29
+ ];
30
+
31
+ export function ShotSection() {
32
+ const store = useStore();
33
+ const doc = useShotEditor((s) => s.doc);
34
+ const focused = useShotEditor((s) => s.selectedBlock);
35
+ const { sceneId, sceneFrame } = useInspectorFrame();
36
+ if (doc === null || sceneId === null || focused === null || focused.layerId !== CAMERA_ROW_ID) {
37
+ return null;
38
+ }
39
+ const scene = doc.scenesById[sceneId];
40
+ const shot = scene?.shots.find((s) => s.id === focused.blockId);
41
+ if (scene === undefined || shot === undefined) return null;
42
+
43
+ // LIVE state at the click, never the hook's coalesced frame: the hook holds
44
+ // during playback and trails ~100ms after a scrub, and "set from current
45
+ // framing" means the framing on SCREEN — writing the held frame's pose puts
46
+ // a camera the author never saw into the document. Same rule as
47
+ // useTransformWrite.
48
+ const onScreen = (): Framing => {
49
+ const s = store.getState();
50
+ if (s.doc === null || s.sceneId === null) return cameraAt(scene, sceneFrame, doc.fps);
51
+ const liveScene = s.doc.scenesById[s.sceneId] ?? scene;
52
+ return cameraAt(liveScene, sceneLocalFrame(s.doc, s.sceneId, s.frame), s.doc.fps);
53
+ };
54
+
55
+ return (
56
+ <InspectorSection title="Camera move" persistKey="shot:animate:shot" collapsible={false}>
57
+ {shot.kind === "path" ? (
58
+ // A path is authored by an agent, through `waypoints`. Offering it in
59
+ // the segmented control would mean converting a two-pose move into one
60
+ // with nothing to edit it with, so this chrome READS a path and refuses
61
+ // to half-edit it. The count is the one number that says which move
62
+ // this is.
63
+ <span className="insp-dim">
64
+ {`A camera path through ${shot.waypoints.length} waypoints, curved with a centripetal spline. ` +
65
+ `Its ends are the first and last waypoint. Edit it through the agent.`}
66
+ </span>
67
+ ) : (
68
+ <>
69
+ <InspectorRow label="Kind">
70
+ <SegmentedControl
71
+ ariaLabel="Move kind"
72
+ value={shot.kind}
73
+ options={KINDS}
74
+ onChange={(next) => store.setShotKind(shot.id, next as MoveShot["kind"])}
75
+ />
76
+ </InspectorRow>
77
+ {/* The one line that makes the choice a choice. Both read as "get
78
+ closer"; only one of them moves the camera through the scene, and
79
+ a layer at a depth is the only place the difference shows. */}
80
+ <span className="insp-dim">
81
+ {shot.kind === "dolly"
82
+ ? "Dolly derives the distance from the live zoom, so the camera moves and layers at different depths shift against each other."
83
+ : "Zoom pins the distance at the move's start and narrows the lens instead, so nothing shifts."}
84
+ </span>
85
+ </>
86
+ )}
87
+
88
+ <EaseControl
89
+ {...(shot.ease === undefined ? {} : { ease: shot.ease })}
90
+ ariaLabel="Move ease"
91
+ onEase={(ease) => store.setShotEase(shot.id, ease)}
92
+ onScrub={(ease) => store.setShotEase(shot.id, ease)}
93
+ />
94
+
95
+ {shot.kind === "path" ? null : (
96
+ <>
97
+ <FramingGroup shot={shot} end="from" title="From" read={onScreen} />
98
+ <FramingGroup shot={shot} end="to" title="To" read={onScreen} />
99
+ </>
100
+ )}
101
+ </InspectorSection>
102
+ );
103
+ }
104
+
105
+ function FramingGroup(props: { shot: MoveShot; end: "from" | "to"; title: string; read(): Framing }) {
106
+ const store = useStore();
107
+ const { shot, end, title } = props;
108
+ return (
109
+ <>
110
+ <InspectorRow label={title}>
111
+ <button
112
+ type="button"
113
+ className="btn"
114
+ onClick={() => store.setShotFraming(shot.id, end, props.read())}
115
+ >
116
+ Set from current framing
117
+ </button>
118
+ </InspectorRow>
119
+ <FramingRows
120
+ framing={shot[end]}
121
+ name={title}
122
+ onCommit={(patch) => store.setShotFraming(shot.id, end, patch)}
123
+ />
124
+ </>
125
+ );
126
+ }
@@ -0,0 +1,43 @@
1
+ // What the Animate panel reads: the selection's blocks, and the focused one.
2
+ //
3
+ // The focused block is `state.selectedBlock`, which the timeline dock ALSO
4
+ // writes when a bar is clicked. One bit, two surfaces: clicking a bar in the
5
+ // dock puts its timing and easing in the inspector, and adding a block from the
6
+ // inspector focuses its bar. Two independent "which block" states would drift
7
+ // apart within one session.
8
+ import { useShotEditor } from "../../../state/context.js";
9
+ import { useSelectedLayers } from "../useDocWrite.js";
10
+ import type { AnimBlock, BlockSlot, Layer3D } from "@dailies-studio/shot/doc";
11
+
12
+ export interface BlockRef {
13
+ layer: Layer3D;
14
+ block: AnimBlock;
15
+ }
16
+
17
+ /** Every block on the selection in one slot, layer-major then frame-major.
18
+ * Sorted, because a fan-out across four layers otherwise lists in whatever
19
+ * order each layer happened to accumulate its blocks. */
20
+ export function useBlocksInSlot(slot: BlockSlot): BlockRef[] {
21
+ const layers = useSelectedLayers();
22
+ const out: BlockRef[] = [];
23
+ for (const layer of layers) {
24
+ for (const block of layer.blocks) {
25
+ if (block.slot === slot) out.push({ layer, block });
26
+ }
27
+ }
28
+ out.sort((a, b) => a.block.frame - b.block.frame || a.block.id.localeCompare(b.block.id));
29
+ return out;
30
+ }
31
+
32
+ /** The block the timing and easing controls are about, or null. */
33
+ export function useFocusedBlock(): BlockRef | null {
34
+ const focused = useShotEditor((s) => s.selectedBlock);
35
+ const layers = useSelectedLayers();
36
+ if (focused === null) return null;
37
+ const layer = layers.find((l) => l.id === focused.layerId);
38
+ const block = layer?.blocks.find((b) => b.id === focused.blockId);
39
+ // A block whose layer left the selection is NOT shown: the panel's subject is
40
+ // the selection, and showing a stray block would let the author edit
41
+ // something the canvas is not highlighting.
42
+ return layer !== undefined && block !== undefined ? { layer, block } : null;
43
+ }
@@ -0,0 +1,63 @@
1
+ // The align/distribute grid, the first thing in Raylight's Position section.
2
+ //
3
+ // Six alignments then two distributions, in two rows of four — the same order
4
+ // and grouping every design tool uses, so the muscle memory transfers.
5
+ //
6
+ // The icons are inline SVG rather than glyphs: the whole point of this control
7
+ // is that you recognise it without reading it, and no font ships a
8
+ // distribute-horizontally character.
9
+ import { ALIGN_MODES } from "@dailies-studio/shot/doc";
10
+ import type { AlignMode } from "@dailies-studio/shot/doc";
11
+ import { useShotEditor, useStore } from "../../../state/context.js";
12
+ import { useSelectedLayers } from "../useDocWrite.js";
13
+
14
+ /** Label and icon geometry per mode. `satisfies` so a new ALIGN_MODES entry
15
+ * without a row here is a compile error rather than a blank button. */
16
+ const SPEC = {
17
+ left: { label: "Align left", bar: [2, 2, 1.6, 12], marks: [[4, 3.5, 8, 3], [4, 9.5, 5, 3]] },
18
+ hcenter: { label: "Align horizontal centres", bar: [7.2, 2, 1.6, 12], marks: [[3, 3.5, 10, 3], [4.5, 9.5, 7, 3]] },
19
+ right: { label: "Align right", bar: [12.4, 2, 1.6, 12], marks: [[4, 3.5, 8, 3], [7, 9.5, 5, 3]] },
20
+ top: { label: "Align top", bar: [2, 2, 12, 1.6], marks: [[3.5, 4, 3, 8], [9.5, 4, 3, 5]] },
21
+ vcenter: { label: "Align vertical centres", bar: [2, 7.2, 12, 1.6], marks: [[3.5, 3, 3, 10], [9.5, 4.5, 3, 7]] },
22
+ bottom: { label: "Align bottom", bar: [2, 12.4, 12, 1.6], marks: [[3.5, 4, 3, 8], [9.5, 7, 3, 5]] },
23
+ hdistribute: { label: "Distribute horizontally", bar: null, marks: [[2, 4, 3, 8], [6.5, 4, 3, 8], [11, 4, 3, 8]] },
24
+ vdistribute: { label: "Distribute vertically", bar: null, marks: [[4, 2, 8, 3], [4, 6.5, 8, 3], [4, 11, 8, 3]] },
25
+ } as const satisfies Record<AlignMode, { label: string; bar: readonly number[] | null; marks: ReadonlyArray<readonly number[]> }>;
26
+
27
+ export function AlignRow() {
28
+ const store = useStore();
29
+ const layers = useSelectedLayers();
30
+ const doc = useShotEditor((s) => s.doc);
31
+ if (doc === null || layers.length === 0) return null;
32
+
33
+ return (
34
+ <div className="align-grid" role="group" aria-label="Align and distribute" data-testid="align-row">
35
+ {ALIGN_MODES.map((mode) => {
36
+ const spec = SPEC[mode];
37
+ // Distribute needs three. Disabled rather than hidden: a control that
38
+ // vanishes teaches nothing, and the tooltip says what is missing.
39
+ const needsThree = spec.bar === null && layers.length < 3;
40
+ return (
41
+ <button
42
+ key={mode}
43
+ type="button"
44
+ className="align-button"
45
+ aria-label={spec.label}
46
+ title={needsThree ? `${spec.label} — needs 3 or more layers` : spec.label}
47
+ disabled={needsThree}
48
+ onClick={() => store.alignSelection(mode)}
49
+ >
50
+ <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
51
+ {spec.bar !== null && (
52
+ <rect x={spec.bar[0]} y={spec.bar[1]} width={spec.bar[2]} height={spec.bar[3]} className="align-icon-rule" />
53
+ )}
54
+ {spec.marks.map((m, i) => (
55
+ <rect key={i} x={m[0]} y={m[1]} width={m[2]} height={m[3]} rx="1" className="align-icon-box" />
56
+ ))}
57
+ </svg>
58
+ </button>
59
+ );
60
+ })}
61
+ </div>
62
+ );
63
+ }
@@ -0,0 +1,49 @@
1
+ // Opacity, visibility and lock.
2
+ //
3
+ // Opacity is a MULTIPLICATIVE channel, so typing into it divides the block's
4
+ // delta out — and at a delta of zero there is no base that renders at the value
5
+ // asked for, which `setEffectiveField` refuses by name rather than guessing.
6
+ import { InspectorRow, InspectorSection, NumberField, Toggle } from "@dailies-studio/ui";
7
+ import { useSelectionValue, useSelectionWrite, useTransformWrite } from "../useDocWrite.js";
8
+
9
+ export function AppearanceSection() {
10
+ const write = useSelectionWrite();
11
+ const writeTransform = useTransformWrite();
12
+ const opacity = useSelectionValue((_l, effective) => Math.round(effective.opacity * 100));
13
+ const hidden = useSelectionValue((layer) => layer.hidden === true);
14
+ const locked = useSelectionValue((layer) => layer.locked === true);
15
+
16
+ return (
17
+ <InspectorSection title="Appearance" persistKey="shot:appearance">
18
+ <InspectorRow>
19
+ <NumberField
20
+ value={opacity.value ?? 100}
21
+ mixed={opacity.mixed}
22
+ label="Opacity"
23
+ ariaLabel="Opacity"
24
+ unit="%"
25
+ min={0}
26
+ max={100}
27
+ step={1}
28
+ onCommit={(next) => writeTransform("opacity", next / 100)}
29
+ />
30
+ </InspectorRow>
31
+ <InspectorRow label="Visible">
32
+ <Toggle
33
+ checked={hidden.value !== true}
34
+ ariaLabel="Visible"
35
+ onChange={(next) => write((layer) => ({ ...layer, hidden: !next }))}
36
+ />
37
+ </InspectorRow>
38
+ <InspectorRow label="Locked">
39
+ {/* Written through `useSelectionWrite`, which SKIPS locked layers — so
40
+ unlocking has to bypass it or a lock would be one-way. */}
41
+ <Toggle
42
+ checked={locked.value === true}
43
+ ariaLabel="Locked"
44
+ onChange={(next) => write((layer) => ({ ...layer, locked: next }), { includeLocked: true })}
45
+ />
46
+ </InspectorRow>
47
+ </InspectorSection>
48
+ );
49
+ }
@@ -0,0 +1,142 @@
1
+ // Only for CLONER layers, and absent otherwise — the same rule MeshSection and
2
+ // TypographySection follow, for the same reason.
3
+ //
4
+ // Deliberately the SHORT list: how many, in what arrangement, and how they are
5
+ // staggered in time. The step and jitter channels, the per-clone override map
6
+ // and `from`'s six orderings all exist in the schema and are reachable from the
7
+ // agent lane; a first section that shows every field is a section nobody can
8
+ // find the count in.
9
+ //
10
+ // ── THE COUNT WARNING IS NOT A VALIDATION PROBLEM ───────────────────────
11
+ //
12
+ // `cloneCountWarning` lives here rather than in `validateShotDoc` because a
13
+ // `ShotProblem` REFUSES the save. "Two hundred clones will cost you two hundred
14
+ // draw calls" must never stop an author saving their document — it is a fact
15
+ // about the frame budget, not a broken document. The hard cap IS a refusal, and
16
+ // that one is the validator's.
17
+ //
18
+ // ponytail: no override editor. Per-clone edits are agent-authored today; build
19
+ // the row when someone is actually art-directing one clone out of forty.
20
+ import { InspectorRow, InspectorSection, NumberField, SegmentedControl } from "@dailies-studio/ui";
21
+ import { CLONER_MODES, CLONE_FROMS, MAX_CLONES, cloneCountWarning } from "@dailies-studio/shot/doc";
22
+ import type {
23
+ CloneFrom,
24
+ ClonerConfig,
25
+ ClonerLayer3D,
26
+ ClonerMode,
27
+ ClonerModeKind,
28
+ Layer3D,
29
+ } from "@dailies-studio/shot/doc";
30
+ import { useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
31
+
32
+ type ClonerBlock = ClonerLayer3D["cloner"];
33
+
34
+ /** The mode a kind switch lands on, with its own required fields filled in.
35
+ * Switching arrangement must produce a VALID mode: a `{kind:"grid"}` with no
36
+ * columns is refused on save, so a segmented control that could write one is a
37
+ * control that only breaks documents. The numbers are the same defaults
38
+ * `add_layer` uses. */
39
+ function defaultMode(kind: ClonerModeKind): ClonerMode {
40
+ switch (kind) {
41
+ case "grid":
42
+ return { kind, columns: 3, spacing: [240, 240] };
43
+ case "radial":
44
+ return { kind, radiusPx: 320 };
45
+ case "scatter":
46
+ return { kind, extent: [320, 320, 0] };
47
+ default:
48
+ return { kind: "linear", step: [240, 0, 0] };
49
+ }
50
+ }
51
+
52
+ export function ClonerSection() {
53
+ const write = useSelectionWrite();
54
+ const layers = useSelectedLayers();
55
+ const cloner = useSelectionValue((layer) => (layer.kind === "cloner" ? layer.cloner : undefined));
56
+ if (!layers.some((l) => l.kind === "cloner") || cloner.value === undefined) return null;
57
+
58
+ const c = cloner.value;
59
+ const patch = (next: (block: ClonerBlock) => ClonerBlock): void =>
60
+ write((layer) => (layer.kind === "cloner" ? ({ ...layer, cloner: next(layer.cloner) } as Layer3D) : layer));
61
+
62
+ const set = <K extends keyof ClonerBlock>(key: K, value: ClonerBlock[K]): void =>
63
+ patch((block) => ({ ...block, [key]: value }));
64
+
65
+ const stagger = c.stagger;
66
+ const warning = cloneCountWarning(c.count);
67
+
68
+ return (
69
+ <InspectorSection title="Cloner" persistKey="shot:cloner">
70
+ <InspectorRow label="Count">
71
+ <NumberField
72
+ value={c.count}
73
+ mixed={cloner.mixed}
74
+ ariaLabel="Clone count"
75
+ step={1}
76
+ min={1}
77
+ // CLAMPED at the field, so the cap is a thing the control cannot
78
+ // exceed rather than a save that fails after the fact. The validator
79
+ // still refuses it, for every other way in.
80
+ max={MAX_CLONES}
81
+ onCommit={(next) => set("count", Math.min(MAX_CLONES, Math.max(1, Math.round(next))))}
82
+ />
83
+ </InspectorRow>
84
+ {warning === null ? null : (
85
+ /* `inspector-empty` is the panel's muted-paragraph style and this is a
86
+ muted paragraph. A `.inspector-warning` would be the same three
87
+ declarations under a name that promised a colour it does not have. */
88
+ <p className="inspector-empty" data-testid="cloner-count-warning">
89
+ {warning}
90
+ </p>
91
+ )}
92
+
93
+ <InspectorRow label="Arrange">
94
+ <SegmentedControl
95
+ ariaLabel="Cloner mode"
96
+ value={c.mode?.kind ?? "linear"}
97
+ options={CLONER_MODES.map((kind) => ({ value: kind, label: kind }))}
98
+ onChange={(next) => set("mode", defaultMode(next as ClonerModeKind))}
99
+ />
100
+ </InspectorRow>
101
+
102
+ {/* The stagger's SPACING, as `each` — the gap between clones. `span`
103
+ expresses the same thing as a total and the two contradict each other
104
+ the moment the count changes, so the panel offers exactly one of them
105
+ and writing it CLEARS the other rather than saving a document the
106
+ validator refuses. */}
107
+ <InspectorRow label="Stagger">
108
+ <NumberField
109
+ value={stagger?.each ?? 0}
110
+ mixed={cloner.mixed}
111
+ ariaLabel="Stagger each"
112
+ unit="f"
113
+ step={1}
114
+ onCommit={(next) =>
115
+ patch((block) => {
116
+ const each = Math.round(next);
117
+ if (each === 0) {
118
+ // Absent is how "no stagger" is spelled everywhere in this
119
+ // lane. Writing `each: 0` would be a second way to say it.
120
+ const { stagger: _drop, ...rest } = block;
121
+ return rest as ClonerConfig;
122
+ }
123
+ const { span: _dropSpan, ...keep } = block.stagger ?? {};
124
+ return { ...block, stagger: { ...keep, each } };
125
+ })
126
+ }
127
+ />
128
+ </InspectorRow>
129
+
130
+ <InspectorRow label="From">
131
+ <SegmentedControl
132
+ ariaLabel="Stagger from"
133
+ value={stagger?.from ?? "start"}
134
+ options={CLONE_FROMS.map((from) => ({ value: from, label: from }))}
135
+ onChange={(next) =>
136
+ patch((block) => ({ ...block, stagger: { ...block.stagger, from: next as CloneFrom } }))
137
+ }
138
+ />
139
+ </InspectorRow>
140
+ </InspectorSection>
141
+ );
142
+ }
@@ -0,0 +1,94 @@
1
+ // A component instance: which definition it places, and what this one changes.
2
+ //
3
+ // The panel is DEFINITION-DRIVEN. Every row comes from an `ExposedProp3D`, so
4
+ // an instance can only change what its definition offered — which is what makes
5
+ // a component a contract rather than a copy. A definition that exposes nothing
6
+ // gets a panel that says so, because "no controls" and "this component has no
7
+ // controls" read very differently to the author who just made one.
8
+ import { InspectorRow, InspectorSection, PropControl } from "@dailies-studio/ui";
9
+ import { componentDefsOf, instancesOf } from "@dailies-studio/shot/doc";
10
+ import type { ComponentLayer3D } from "@dailies-studio/shot/doc";
11
+ import type { ControlSpec } from "@dailies-studio/contract";
12
+ import type { Value } from "@dailies-studio/engine";
13
+ import { useShotEditor, useStore } from "../../../state/context.js";
14
+ import { NO_SCRUB, useSelectedLayers } from "../useDocWrite.js";
15
+
16
+ export function ComponentSection() {
17
+ const store = useStore();
18
+ const doc = useShotEditor((s) => s.doc);
19
+ const layers = useSelectedLayers();
20
+ // ONE instance, not a mixed selection: overrides are per definition, and two
21
+ // instances of two different definitions have no shared vocabulary at all.
22
+ const instance = layers.length === 1 && layers[0]?.kind === "component" ? layers[0] : null;
23
+ if (doc === null || instance === null) return null;
24
+ const def = componentDefsOf(doc)[(instance as ComponentLayer3D).component.def];
25
+
26
+ if (def === undefined) {
27
+ return (
28
+ <InspectorSection title="Component" persistKey="shot:component">
29
+ <p className="inspector-note">
30
+ This places a component that is not in this document. Its definition
31
+ was probably deleted — undo, or delete this layer.
32
+ </p>
33
+ </InspectorSection>
34
+ );
35
+ }
36
+
37
+ const overrides = (instance as ComponentLayer3D).component.overrides;
38
+ const count = instancesOf(doc, def.id).length;
39
+
40
+ return (
41
+ <InspectorSection title="Component" persistKey="shot:component">
42
+ <InspectorRow label="Definition">
43
+ <button
44
+ type="button"
45
+ className="link-button"
46
+ aria-label={`Edit ${def.name}`}
47
+ // Editing the DEFINITION reaches every instance at once, which is the
48
+ // whole point — so the button says which and how many.
49
+ onClick={() => store.editComponent(def.id)}
50
+ >
51
+ {def.name}
52
+ </button>
53
+ </InspectorRow>
54
+ <p className="inspector-note">
55
+ {count === 1 ? "The only one placed." : `${count} placed — editing it changes all of them.`}
56
+ </p>
57
+
58
+ {def.exposed.length === 0 ? (
59
+ <p className="inspector-note">
60
+ Nothing is exposed yet. Open the definition and expose a field to make
61
+ it overridable here.
62
+ </p>
63
+ ) : (
64
+ def.exposed.map((prop) => {
65
+ const value = overrides[prop.key];
66
+ return (
67
+ <InspectorRow key={prop.key} label={prop.label}>
68
+ <PropControl
69
+ spec={{ key: prop.key, label: prop.label, kind: prop.kind, default: prop.default } as ControlSpec}
70
+ value={value ?? prop.default}
71
+ commit={(next) => store.setComponentOverride(instance.id, prop.key, next as Value)}
72
+ scrub={NO_SCRUB}
73
+ />
74
+ {/* The override's own reset. An instance that says nothing FOLLOWS
75
+ its definition, so clearing is not "set it back to today's
76
+ default" — it is "stop having an opinion", and the two differ
77
+ the moment the definition changes. */}
78
+ <button
79
+ type="button"
80
+ className="layer-flag"
81
+ aria-label={`Reset ${prop.label}`}
82
+ title="Follow the component again"
83
+ disabled={value === undefined}
84
+ onClick={() => store.setComponentOverride(instance.id, prop.key, null)}
85
+ >
86
+ ↺
87
+ </button>
88
+ </InspectorRow>
89
+ );
90
+ })
91
+ )}
92
+ </InspectorSection>
93
+ );
94
+ }
@@ -0,0 +1,52 @@
1
+ // Corner radius. ONE field drives all four corners, because a shot's layers are
2
+ // screenshots and cards — per-corner radii exist in the schema (`CornerRadius`)
3
+ // and are what an ejected component keeps, but a four-field row for a feature
4
+ // nobody has asked for is four fields to mis-set.
5
+ //
6
+ // ponytail: uniform radius only; add the per-corner expander when a template
7
+ // needs one.
8
+ import { InspectorRow, InspectorSection, NumberField } from "@dailies-studio/ui";
9
+ import type { CornerRadius } from "@dailies-studio/shot/doc";
10
+ import { useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
11
+
12
+ export function CornerSection() {
13
+ const write = useSelectionWrite();
14
+ const radius = useSelectionValue((layer) => uniformOf(layer.paint.radius));
15
+
16
+ return (
17
+ <InspectorSection title="Corner" persistKey="shot:corner">
18
+ <InspectorRow>
19
+ <NumberField
20
+ value={radius.value ?? 0}
21
+ mixed={radius.mixed}
22
+ label="Radius"
23
+ ariaLabel="Corner radius"
24
+ unit="px"
25
+ min={0}
26
+ step={1}
27
+ onCommit={(next) =>
28
+ write((layer) => ({
29
+ ...layer,
30
+ paint:
31
+ next <= 0
32
+ ? withoutRadius(layer.paint)
33
+ : { ...layer.paint, radius: { tl: next, tr: next, br: next, bl: next } },
34
+ }))
35
+ }
36
+ />
37
+ </InspectorRow>
38
+ </InspectorSection>
39
+ );
40
+ }
41
+
42
+ /** The largest corner, so a document with mixed corners reads as the shape the
43
+ * author sees rather than as zero. */
44
+ function uniformOf(radius: CornerRadius | undefined): number {
45
+ if (radius === undefined) return 0;
46
+ return Math.max(radius.tl, radius.tr, radius.br, radius.bl);
47
+ }
48
+
49
+ function withoutRadius(paint: { radius?: CornerRadius }): typeof paint {
50
+ const { radius: _drop, ...rest } = paint;
51
+ return rest;
52
+ }
@@ -0,0 +1,89 @@
1
+ // The fill. Solid only in the inspector for now: a gradient needs the stop
2
+ // editor, which @dailies-studio/ui has (`GradientValueEditor`) and which wants
3
+ // the gradient adapter the 2D lane wrote — that is a section of its own rather
4
+ // than a control squeezed in here.
5
+ //
6
+ // PAINT HAS NO BLOCK DELTAS in v1, so a paint read and a paint write are both
7
+ // BASE and cannot disagree. That is why nothing here goes through
8
+ // `setEffectiveField`.
9
+ import { ColorPicker, InspectorRow, InspectorSection } from "@dailies-studio/ui";
10
+ import type { Layer3D, Paint } from "@dailies-studio/shot/doc";
11
+ import { NO_SCRUB, useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
12
+
13
+ const DEFAULT_FILL = "#ffffff";
14
+
15
+ export function FillSection() {
16
+ const write = useSelectionWrite();
17
+ const layers = useSelectedLayers();
18
+ const fill = useSelectionValue((layer) => solidOf(layer.paint.fill));
19
+ const present = useSelectionValue((layer) => layer.paint.fill !== undefined);
20
+
21
+ // A FILL IS INERT ON MEDIA, so the control is absent rather than dead.
22
+ // `drawLayer` paints `paint.fill` only in the arm that is neither "mask" nor
23
+ // "plate" (raster.ts), and `MediaPlane` asks for exactly those two — the
24
+ // content pixels come from the asset. A colour swatch that writes a document
25
+ // field and moves nothing is worse than no swatch: the author changes it,
26
+ // sees nothing, and concludes the renderer is broken.
27
+ //
28
+ // ONLY when the whole selection is media. A mixed one keeps the section,
29
+ // because the rects in it still fill and `useSelectionWrite` already skips
30
+ // the layers a write cannot reach.
31
+ //
32
+ // Deliberately NOT extended to mesh/instance/cloner, whose fills are inert
33
+ // for a different reason (they mount their own content and are refused by
34
+ // `rasterizesLayer` upstream of this) — one gate per reason.
35
+ if (layers.length > 0 && layers.every(isMedia)) return null;
36
+
37
+ return (
38
+ <InspectorSection
39
+ title="Fill"
40
+ persistKey="shot:fill"
41
+ action={
42
+ <button
43
+ type="button"
44
+ className="section-action"
45
+ title={present.value === true ? "Remove fill" : "Add fill"}
46
+ onClick={() =>
47
+ write((layer) =>
48
+ present.value === true
49
+ ? { ...layer, paint: withoutFill(layer.paint) }
50
+ : { ...layer, paint: { ...layer.paint, fill: { type: "solid", color: DEFAULT_FILL } } },
51
+ )
52
+ }
53
+ >
54
+ {present.value === true ? "−" : "+"}
55
+ </button>
56
+ }
57
+ >
58
+ {present.value === true ? (
59
+ <InspectorRow label="Color">
60
+ <ColorPicker
61
+ value={fill.value ?? DEFAULT_FILL}
62
+ ariaLabel="Fill colour"
63
+ scrub={NO_SCRUB}
64
+ commit={(hex) =>
65
+ write((layer) => ({ ...layer, paint: { ...layer.paint, fill: { type: "solid", color: hex } } }))
66
+ }
67
+ />
68
+ </InspectorRow>
69
+ ) : null}
70
+ </InspectorSection>
71
+ );
72
+ }
73
+
74
+ /** A gradient has no single colour, so the swatch shows its FIRST stop and the
75
+ * picker would overwrite it — which is why the row is only offered for a solid
76
+ * and a gradient keeps whatever the document holds. */
77
+ function solidOf(paint: Paint | undefined): string {
78
+ if (paint === undefined) return DEFAULT_FILL;
79
+ return paint.type === "solid" ? paint.color : (paint.stops[0]?.color ?? DEFAULT_FILL);
80
+ }
81
+
82
+ function isMedia(layer: Layer3D): boolean {
83
+ return layer.kind === "image" || layer.kind === "video";
84
+ }
85
+
86
+ function withoutFill(paint: { fill?: Paint }): typeof paint {
87
+ const { fill: _drop, ...rest } = paint;
88
+ return rest;
89
+ }