@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,191 @@
1
+ // Snapping on the master strip. PURE — no DOM, no store, no React.
2
+ //
3
+ // ── THE TWO RULES ────────────────────────────────────────────────────────────
4
+ //
5
+ // 1. IT IS ON BY DEFAULT, and a held modifier suppresses it by passing an EMPTY
6
+ // TARGET LIST rather than by branching around the call. One code path, so
7
+ // "what does a drag do" cannot have two answers, and the suppressed case is
8
+ // the same function with nothing to bite on.
9
+ //
10
+ // 2. THE RESULT IS ALWAYS AN INTEGER FRAME. Snapping to a sub-frame time would
11
+ // put a float back into a system whose whole invariant is integer frames over
12
+ // a rational fps — the same float that would then ride into a document, an
13
+ // export and a cache key.
14
+ //
15
+ // The threshold is a SCREEN distance divided by the live pixels-per-frame, so
16
+ // the pull feels the same size at every zoom. A threshold in frames would get
17
+ // stronger as the author zoomed out until a whole scene was one sticky point;
18
+ // the store's layout snapping is divided by the view zoom for the same reason.
19
+ import { sceneWindows } from "@dailies-studio/shot/doc";
20
+ import type { Doc3D } from "@dailies-studio/shot/doc";
21
+ import { lifespanOf } from "./model.js";
22
+
23
+ /** How close a moving edge has to come, in SCREEN px. */
24
+ export const TIMELINE_SNAP_PX = 6;
25
+
26
+ export type SnapKind = "playhead" | "marker" | "scene" | "block" | "lifespan" | "audio";
27
+
28
+ export interface SnapTarget {
29
+ /** MASTER frame. Every target in one space, because a gesture that crosses a
30
+ * scene boundary has to compare a scene-2 block edge with a scene-1 one. */
31
+ frame: number;
32
+ kind: SnapKind;
33
+ /** What the guide would say if the UI ever labelled it. */
34
+ label: string;
35
+ }
36
+
37
+ export interface SnapHit {
38
+ /** The snapped frame, or the rounded input when nothing was near. ALWAYS an
39
+ * integer. */
40
+ frame: number;
41
+ /** What it landed on, or null. Non-null is exactly the condition for drawing
42
+ * a guide: a drag that merely rounded has nothing to point at. */
43
+ target: SnapTarget | null;
44
+ }
45
+
46
+ /** The pull distance in FRAMES at this zoom. */
47
+ export function snapThresholdFrames(ppf: number): number {
48
+ return TIMELINE_SNAP_PX / Math.max(ppf, 1e-6);
49
+ }
50
+
51
+ export interface SnapScope {
52
+ /** The playhead, in master frames. */
53
+ playhead: number;
54
+ /**
55
+ * What the gesture is moving, whose own edges are NOT targets.
56
+ *
57
+ * Without it every drag is pinned at zero travel: the bar under the pointer
58
+ * is always exactly on its own edge, so it wins every comparison and the
59
+ * gesture never leaves the ground.
60
+ *
61
+ * `blockId` narrows that to the ONE bar. A lifespan drag moves the layer's bar
62
+ * and every block on it, so the whole layer really is in motion and none of its
63
+ * edges is a target. A block drag moves exactly one bar: its siblings and the
64
+ * layer's own lifespan stay put, and butting a During block against the In
65
+ * block's end — or onto the lifespan in, which is the pairing the lifespan row
66
+ * exists to make visible — is the most natural alignment in the lane. Excluding
67
+ * them buys nothing and disables it.
68
+ */
69
+ exclude?: {
70
+ sceneId?: string;
71
+ layerId?: string;
72
+ blockId?: string;
73
+ /** The audio clip a gesture is moving. Document-level, so it carries no
74
+ * scene: `doc.audio` is timed on MASTER frames. */
75
+ audioClipId?: string;
76
+ /** The marker a gesture is moving. Same reason as `audioClipId`, and it is
77
+ * not optional politeness: a marker sits exactly on its own target, so
78
+ * without this a marker drag is pinned at zero travel. */
79
+ markerId?: string;
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Everything a drag on the master strip may land on, in master frames.
85
+ *
86
+ * Collected ONCE at the press and held for the whole gesture — like every other
87
+ * value in this lane — because the document changes under a live drag and a
88
+ * target list that re-derived per move would include the thing being dragged.
89
+ *
90
+ * Deduped by frame, first writer wins, which makes the push order below a
91
+ * PRIORITY order: the playhead and the markers are what the author put there on
92
+ * purpose, so a guide at a frame that is also a scene boundary names the marker.
93
+ */
94
+ export function collectSnapTargets(doc: Doc3D, opts: SnapScope): SnapTarget[] {
95
+ const out: SnapTarget[] = [];
96
+ const seen = new Set<number>();
97
+ const push = (frame: number, kind: SnapKind, label: string): void => {
98
+ const at = Math.round(frame);
99
+ if (at < 0 || seen.has(at)) return;
100
+ seen.add(at);
101
+ out.push({ frame: at, kind, label });
102
+ };
103
+
104
+ push(opts.playhead, "playhead", "playhead");
105
+ for (const marker of doc.markers ?? []) {
106
+ if (marker.id === opts.exclude?.markerId) continue;
107
+ push(marker.frame, "marker", marker.label ?? "marker");
108
+ }
109
+
110
+ // Audio edges are MASTER frames already, so they need no scene offset — and
111
+ // they come before the scene loop because landing a block on the start of the
112
+ // music is the alignment this lane is for.
113
+ for (const clip of Object.values(doc.audio)) {
114
+ if (clip.id === opts.exclude?.audioClipId) continue;
115
+ push(clip.frame, "audio", "audio in");
116
+ push(clip.frame + clip.durationFrames, "audio", "audio out");
117
+ }
118
+
119
+ for (const window of sceneWindows(doc)) {
120
+ const scene = doc.scenesById[window.id];
121
+ if (scene === undefined) continue;
122
+ push(window.start, "scene", `${scene.name} start`);
123
+ push(window.end, "scene", `${scene.name} end`);
124
+ for (const layer of Object.values(scene.layersById)) {
125
+ const moving =
126
+ opts.exclude?.sceneId === window.id && opts.exclude.layerId === layer.id ? opts.exclude : undefined;
127
+ // The whole layer only when the whole layer moves — see SnapScope.exclude.
128
+ if (moving !== undefined && moving.blockId === undefined) continue;
129
+ const life = lifespanOf(layer, scene);
130
+ push(window.start + life.in, "lifespan", `${layer.label} in`);
131
+ push(window.start + life.out, "lifespan", `${layer.label} out`);
132
+ for (const block of layer.blocks) {
133
+ if (block.id === moving?.blockId) continue;
134
+ push(window.start + block.frame, "block", `${layer.label} ${block.preset}`);
135
+ push(window.start + block.frame + block.durationFrames, "block", `${layer.label} ${block.preset} end`);
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /** The nearest target within `thresholdFrames`, or the rounded input. Distance
143
+ * is measured against the RAW frame, so a candidate 0.4 frames from a target
144
+ * and 0.6 from another lands on the right one rather than on whichever the
145
+ * rounding happened to favour. */
146
+ export function snapFrame(
147
+ frame: number,
148
+ targets: readonly SnapTarget[],
149
+ thresholdFrames: number,
150
+ ): SnapHit {
151
+ const rounded = Math.round(frame);
152
+ if (!(thresholdFrames > 0)) return { frame: rounded, target: null };
153
+ let best: SnapTarget | null = null;
154
+ let bestDistance = Infinity;
155
+ for (const target of targets) {
156
+ const distance = Math.abs(target.frame - frame);
157
+ if (distance <= thresholdFrames && distance < bestDistance) {
158
+ best = target;
159
+ bestDistance = distance;
160
+ }
161
+ }
162
+ return best === null ? { frame: rounded, target: null } : { frame: best.frame, target: best };
163
+ }
164
+
165
+ /**
166
+ * What a row is handed at the press: give it the frame you want and it gives
167
+ * back the frame you get, having told the dock where to draw the guide.
168
+ *
169
+ * A closure rather than the three arguments, because the row has no business
170
+ * knowing the target list, the zoom or how a guide is drawn — it knows one
171
+ * candidate frame, which is the only part of snapping that differs per gesture.
172
+ */
173
+ export type SnapPress = (
174
+ exclude: SnapScope["exclude"],
175
+ suppressed: boolean,
176
+ ) => (candidateMasterFrame: number) => number;
177
+
178
+ /**
179
+ * Is snapping held off for this press?
180
+ *
181
+ * ⌘/ctrl, read AT THE PRESS. Every NLE defaults snapping on and gives it a
182
+ * hold-to-suppress rather than a toggle, and for a lane whose whole job is
183
+ * landing on other people's edges that polarity is the right one — the author
184
+ * asks for the exception, not for the rule.
185
+ *
186
+ * Structural rather than `PointerEvent`, so the pure lane stays free of the DOM
187
+ * and a test can hand it a literal.
188
+ */
189
+ export function isSnapSuppressed(event: { metaKey: boolean; ctrlKey: boolean }): boolean {
190
+ return event.metaKey || event.ctrlKey;
191
+ }
@@ -0,0 +1,230 @@
1
+ // The ADD menu: the layer kinds a DRAG CANNOT EXPRESS.
2
+ //
3
+ // A rectangle is a box, so the rect tool is a drag. A model is a FILE, a device
4
+ // is a CHOICE FROM A REGISTRY, and a cloner is an operation on what is already
5
+ // selected — none of those is a rubber band, and giving each of them a tool
6
+ // would put three buttons in the dock that all do the same thing when dragged
7
+ // (nothing) and something else entirely when clicked.
8
+ //
9
+ // ── THE ROWS ARE GATED ON THE SCHEMA AT RUNTIME ─────────────────────────
10
+ //
11
+ // `hasLayerKind` reads `LAYER_KINDS`, so a kind removed from the document model
12
+ // removes its row here instead of leaving a button that mints a layer nothing
13
+ // can render. It has to be a runtime test and not `LAYER_KINDS.includes("mesh")`
14
+ // inline: against the literal tuple that call is a TYPE error the day the kind
15
+ // goes, which is a build nobody can ship — the menu could not degrade, only
16
+ // fail to compile.
17
+ //
18
+ // ── ONE FILE INPUT, TWO PICKERS ─────────────────────────────────────────
19
+ //
20
+ // The model row needs one, and so does the FRAME TOOL (`image`, labelled
21
+ // "Frame" because the file it takes may be an image or a video) — which draws
22
+ // its box on the canvas and only then needs a file, so its geometry parks in
23
+ // the store (`imagePick`) and is claimed here. A dialog can only be opened by a
24
+ // click on a real `<input>`, and the store is not one.
25
+ import { useEffect, useRef, useState } from "react";
26
+ import { DEVICE_PRESET_IDS } from "@dailies-studio/shot/doc";
27
+ import { useShotEditor, useStore } from "../../state/context.js";
28
+ import { hasLayerKind } from "../../state/insert.js";
29
+
30
+ /**
31
+ * What the hidden input is being asked for, or null when idle.
32
+ *
33
+ * An OBJECT and not a bare string, so asking for the same kind twice is a new
34
+ * value: a dialog the author cancelled fires no event at all, and with a string
35
+ * the second "image" would be `===` the stale first one, the effect would not
36
+ * re-run, and the picker would silently never open again.
37
+ */
38
+ type Pick = { kind: "image" | "model" };
39
+
40
+ export function AddMenu() {
41
+ const store = useStore();
42
+ const imagePick = useShotEditor((s) => s.imagePick);
43
+ const hasSelection = useShotEditor((s) => s.selection.length > 0);
44
+ const canGroup = useShotEditor((s) => s.selection.length >= 2);
45
+ const library = useShotEditor((s) => s.library);
46
+ const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
47
+ const [open, setOpen] = useState(false);
48
+ const [busy, setBusy] = useState(false);
49
+ const [want, setWant] = useState<Pick | null>(null);
50
+ const fileRef = useRef<HTMLInputElement | null>(null);
51
+
52
+ // A drawn image box is waiting on a file: open the dialog for it. An EFFECT
53
+ // is the seam because the draw happened in the STAGE's document, so there is
54
+ // no click of ours to hang it off. User activation propagates from a
55
+ // same-origin iframe, so the dialog is still allowed to open; if a browser
56
+ // ever stops propagating it, the ceiling is that the image tool needs a
57
+ // second click here rather than anything breaking.
58
+ useEffect(() => {
59
+ if (imagePick === null) return;
60
+ setWant({ kind: "image" });
61
+ }, [imagePick]);
62
+
63
+ useEffect(() => {
64
+ if (want !== null) fileRef.current?.click();
65
+ }, [want]);
66
+
67
+ // The registry, once, when the menu first opens rather than at mount: it is
68
+ // an HTTP call, and a chrome that never adds a component should never make
69
+ // it. A failure leaves the instance rows absent (the error surfaces in the
70
+ // inspector, which is the surface that can say what it means for a layer).
71
+ useEffect(() => {
72
+ if (open && !libraryLoaded) void store.refreshLibrary();
73
+ }, [open, libraryLoaded, store]);
74
+
75
+ const report = (e: unknown): void =>
76
+ store.reportError(e instanceof Error ? e.message : String(e));
77
+
78
+ const chose = (file: File | null): void => {
79
+ const asked = want;
80
+ setWant(null);
81
+ if (asked === null) return;
82
+ setBusy(true);
83
+ const done =
84
+ asked.kind === "image"
85
+ ? store.completeImageDraw(file)
86
+ : file === null
87
+ ? Promise.resolve()
88
+ : store.addMeshLayer(file);
89
+ void done.catch(report).finally(() => setBusy(false));
90
+ };
91
+
92
+ // Every row is gated, so a menu with nothing left in it is not an empty
93
+ // popover — it is no button at all.
94
+ const rows: { key: string; label: string; disabled?: boolean; title?: string; run: () => void }[] = [];
95
+ if (hasLayerKind("mesh")) {
96
+ rows.push({ key: "mesh", label: "3D model…", run: () => setWant({ kind: "model" }) });
97
+ }
98
+ if (hasLayerKind("device")) {
99
+ // FROM THE REGISTRY, never a list written here: a hardcoded one is a second
100
+ // place that knows what a device is, and `validateShotDoc` refuses a preset
101
+ // the registry does not have — so the two disagreeing means a menu row that
102
+ // can only produce a document the author cannot save.
103
+ for (const preset of DEVICE_PRESET_IDS) {
104
+ rows.push({
105
+ key: `device:${preset}`,
106
+ label: preset.charAt(0).toUpperCase() + preset.slice(1),
107
+ run: () => store.addDeviceLayer(preset),
108
+ });
109
+ }
110
+ }
111
+ if (hasLayerKind("html")) {
112
+ rows.push({ key: "html", label: "Html snippet", run: () => store.addHtmlLayer() });
113
+ }
114
+ if (hasLayerKind("instance")) {
115
+ // FROM THE LIVE REGISTRY, filtered to the only host that can mount inside
116
+ // the shot's 3D canvas. Same reason the device rows come from
117
+ // DEVICE_PRESET_IDS: a hardcoded list would be a second place that knows
118
+ // what a component is, and the save gate refuses anything else — so a row
119
+ // offering a "dom" component could only ever produce a document the author
120
+ // cannot save.
121
+ for (const row of library.filter((r) => r.host === "three")) {
122
+ rows.push({
123
+ key: `instance:${row.id}`,
124
+ label: row.name,
125
+ title: row.id,
126
+ run: () => void store.addInstanceLayer(row.id).catch(report),
127
+ });
128
+ }
129
+ }
130
+ if (hasLayerKind("group")) {
131
+ rows.push({
132
+ key: "group",
133
+ label: "Group",
134
+ // The pointer twin of Ctrl/Cmd+G. Two members minimum, same floor
135
+ // `groupSelection` itself enforces: a one-layer group is just a layer
136
+ // with an extra transform nobody asked for.
137
+ disabled: !canGroup,
138
+ title: canGroup ? "Group the selection (Ctrl/Cmd+G)" : "Select two or more layers to group",
139
+ run: () => store.groupSelection(),
140
+ });
141
+ }
142
+ if (hasLayerKind("cloner")) {
143
+ rows.push({
144
+ key: "cloner",
145
+ label: "Cloner",
146
+ // No empty cloner, ever: `validateShotDoc` refuses one with no children,
147
+ // so the row that inserted it would be a row that breaks saving.
148
+ disabled: !hasSelection,
149
+ title: hasSelection ? "Wrap the selection in a cloner" : "Select a layer to clone first",
150
+ run: () => store.addClonerLayer(),
151
+ });
152
+ }
153
+ if (hasLayerKind("component")) {
154
+ rows.push({
155
+ key: "component",
156
+ label: "Component",
157
+ // A definition is MADE from something, exactly like a cloner: there is no
158
+ // empty one, because a definition with no roots is refused at the gate.
159
+ disabled: !hasSelection,
160
+ title: hasSelection
161
+ ? "Make the selection reusable, and leave one placement here"
162
+ : "Select the layers to turn into a component first",
163
+ run: () => store.createComponentFromSelection(),
164
+ });
165
+ }
166
+ if (rows.length === 0) return null;
167
+
168
+ return (
169
+ <div className="tool-flyout-anchor">
170
+ <button
171
+ type="button"
172
+ className="tool-button"
173
+ aria-haspopup="menu"
174
+ aria-expanded={open}
175
+ title="Add a layer at the playhead"
176
+ data-testid="add-menu-button"
177
+ disabled={busy}
178
+ onClick={() => setOpen((v) => !v)}
179
+ >
180
+ {busy ? "…" : "+"}
181
+ </button>
182
+ {open ? (
183
+ <div className="tool-flyout" role="menu" data-testid="add-menu">
184
+ {rows.map((row) => (
185
+ <button
186
+ key={row.key}
187
+ type="button"
188
+ role="menuitem"
189
+ className="tool-button"
190
+ disabled={row.disabled === true}
191
+ title={row.title ?? row.label}
192
+ onClick={() => {
193
+ setOpen(false);
194
+ row.run();
195
+ }}
196
+ >
197
+ {row.label}
198
+ </button>
199
+ ))}
200
+ </div>
201
+ ) : null}
202
+ <input
203
+ ref={fileRef}
204
+ className="audio-file-input"
205
+ type="file"
206
+ // EXTENSIONS, NOT `image/*` OR `video/*`. The "sniff" on the far end
207
+ // is a filename extension map (`KIND_BY_EXT` in cli/src/assetStore.ts),
208
+ // and `storeAsset` writes the bytes BEFORE the kind is consulted — so a
209
+ // file the picker offered and the map does not know (.mkv, .svg, .heic)
210
+ // is stored, then refused, leaving an orphan blob in the
211
+ // content-addressed store that nothing ever collects. The list is
212
+ // exactly what the map answers "image" or "video" for.
213
+ accept={
214
+ want?.kind === "model"
215
+ ? ".glb,.gltf,model/gltf-binary"
216
+ : ".png,.jpg,.jpeg,.webp,.avif,.gif,.mp4,.webm,.mov,.m4v," +
217
+ "image/png,image/jpeg,image/webp,image/avif,image/gif," +
218
+ "video/mp4,video/webm,video/quicktime"
219
+ }
220
+ hidden
221
+ data-testid="add-file-input"
222
+ onChange={(e) => {
223
+ chose(e.currentTarget.files?.[0] ?? null);
224
+ // Cleared so re-picking the SAME file fires change again.
225
+ e.currentTarget.value = "";
226
+ }}
227
+ />
228
+ </div>
229
+ );
230
+ }
@@ -0,0 +1,70 @@
1
+ // The camera verbs that belong to the WORKING PANE, floating over it.
2
+ //
3
+ // ── WHY THESE THREE ARE TOGETHER, AND WHY THEY ARE HERE ─────────────────
4
+ //
5
+ // A frustum you can look at but not act on is decoration. What makes a free
6
+ // view worth having is the ROUND TRIP between it and the camera, and the round
7
+ // trip is exactly these: point the camera at what I am looking at, keep
8
+ // pointing it there while I move, and frame this selection. They sit over the
9
+ // pane they act on rather than in the inspector, because the pane is where the
10
+ // author's eyes and pointer already are.
11
+ //
12
+ // Mounted only while the working pane is on screen: in camera mode there is no
13
+ // "this view" to align to, and a control that is present but meaningless is
14
+ // worse than one that is absent.
15
+ //
16
+ // ── THE FOLLOW TOGGLE IS NOT CALLED A LOCK ──────────────────────────────
17
+ //
18
+ // Blender's long-running complaint about the equivalent toggle is that its name
19
+ // says the opposite of what it does — nothing is locked, the camera is being
20
+ // moved by every orbit — and that it is easy to forget it is on. So it is named
21
+ // for what happens, it is `aria-pressed`, and the affordance that stops it
22
+ // being forgotten is drawn INSIDE the pane (see Overlays' FollowBadge). A
23
+ // checkbox in a panel nobody is looking at while they orbit is how the Blender
24
+ // version gets forgotten.
25
+ //
26
+ // A FLOATING SIBLING of the stage, never a wrapper. See App.tsx's header.
27
+ import { useShotEditor, useStore } from "../../state/context.js";
28
+
29
+ export function CameraViewBar() {
30
+ const store = useStore();
31
+ const mode = useShotEditor((s) => s.stageLayout.mode);
32
+ const follows = useShotEditor((s) => s.cameraFollowsView);
33
+ const hasSelection = useShotEditor((s) => s.selection.length > 0);
34
+ const open = useShotEditor((s) => s.name !== null);
35
+ if (!open || mode === "camera") return null;
36
+
37
+ return (
38
+ <div className="camera-view-bar" role="group" aria-label="Camera" data-testid="camera-view-bar">
39
+ <button
40
+ type="button"
41
+ className="btn"
42
+ title="Point the shot camera where this view is looking (⌥C)"
43
+ onClick={() => store.alignCameraToWorkingView()}
44
+ >
45
+ Align camera
46
+ </button>
47
+ <button
48
+ type="button"
49
+ className={`btn${follows ? " is-armed" : ""}`}
50
+ aria-pressed={follows}
51
+ title="Keep the shot camera pointed where this view looks, while you orbit"
52
+ onClick={() => store.setCameraFollowsView(!follows)}
53
+ >
54
+ Follow view
55
+ </button>
56
+ <button
57
+ type="button"
58
+ className="btn"
59
+ // Disabled rather than refusing, because "nothing is selected" is
60
+ // visible on screen — a refusal message is for the cases the author
61
+ // cannot see, like a shot painting over the value they just wrote.
62
+ disabled={!hasSelection}
63
+ title="Frame the selected layers"
64
+ onClick={() => store.fitCameraToSelection()}
65
+ >
66
+ Fit to selection
67
+ </button>
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,42 @@
1
+ // How the WORKING pane draws: solid clay, wireframe, real materials, or the
2
+ // graded render from the working camera.
3
+ //
4
+ // A floating sibling like ViewModeBar, and its structural twin: a table of
5
+ // modes so glyph, label and cycle order live beside each other. It renders
6
+ // NOTHING when the layout is camera-only — the camera pane is always the
7
+ // graded render, so with no working pane on screen there is nothing this bar
8
+ // addresses and an armed button over an invisible pane would be a lie.
9
+ import { useShotEditor, useStore } from "../../state/context.js";
10
+ import { SHADING_MODES } from "../../stage/shading.js";
11
+ import type { ShadingMode } from "../../bridgeTypes.js";
12
+
13
+ const SHADING_GLYPHS: Readonly<Record<ShadingMode, { glyph: string; label: string }>> = {
14
+ solid: { glyph: "◯", label: "Solid" },
15
+ wireframe: { glyph: "◇", label: "Wireframe" },
16
+ material: { glyph: "◑", label: "Material" },
17
+ rendered: { glyph: "✦", label: "Rendered" },
18
+ };
19
+
20
+ export function ShadingBar() {
21
+ const store = useStore();
22
+ const shading = useShotEditor((s) => s.shadingMode);
23
+ const layoutMode = useShotEditor((s) => s.stageLayout.mode);
24
+ const open = useShotEditor((s) => s.name !== null);
25
+ if (!open || layoutMode === "camera") return null;
26
+ return (
27
+ <div className="view-mode-bar" role="group" aria-label="Working-pane shading" data-testid="shading-bar">
28
+ {SHADING_MODES.map((mode) => (
29
+ <button
30
+ key={mode}
31
+ type="button"
32
+ className={`tool-button${shading === mode ? " is-armed" : ""}`}
33
+ aria-pressed={shading === mode}
34
+ title={`${SHADING_GLYPHS[mode].label} — working pane (Z cycles)`}
35
+ onClick={() => store.setShadingMode(mode)}
36
+ >
37
+ {SHADING_GLYPHS[mode].glyph}
38
+ </button>
39
+ ))}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,110 @@
1
+ // The tool dock: one button per DOCK_TOOLS row, plus the shape flyout.
2
+ //
3
+ // Everything here is derived from `TOOL_SPECS`. The icon map is keyed on
4
+ // `DockTool` and `FlyoutTool`, so a tool added to the table without a glyph is
5
+ // a COMPILE ERROR rather than an empty button — which is the whole reason the
6
+ // table's surfaces are types rather than strings.
7
+ import { useState } from "react";
8
+ import { DOCK_TOOLS, FLYOUT_TOOLS, specOf } from "../../tools.js";
9
+ import type { DockTool, FlyoutTool, ShotTool } from "../../tools.js";
10
+ import { useShotEditor, useStore } from "../../state/context.js";
11
+ import { AddMenu } from "./AddMenu.js";
12
+
13
+ /** Text glyphs rather than an icon set: one fewer dependency, and every one of
14
+ * these is a shape the font already draws. */
15
+ const DOCK_GLYPHS: Record<DockTool, string> = {
16
+ select: "▹",
17
+ move: "✥",
18
+ text: "T",
19
+ image: "▤",
20
+ rect: "▭",
21
+ path: "✎",
22
+ };
23
+
24
+ const FLYOUT_GLYPHS: Record<FlyoutTool, string> = {
25
+ ellipse: "◯",
26
+ line: "╱",
27
+ arrow: "↗",
28
+ };
29
+
30
+ export function ToolDockBar() {
31
+ const store = useStore();
32
+ const tool = useShotEditor((s) => s.tool);
33
+ const animateMode = useShotEditor((s) => s.animateMode);
34
+ const open = useShotEditor((s) => s.name !== null);
35
+ const [flyoutOpen, setFlyoutOpen] = useState(false);
36
+ if (!open) return null;
37
+
38
+ const label = (t: ShotTool): string => {
39
+ const spec = specOf(t);
40
+ const key = spec.key === null ? "" : ` (${spec.shift === true ? "⇧" : ""}${spec.key.toUpperCase()})`;
41
+ return `${spec.label}${key}`;
42
+ };
43
+
44
+ return (
45
+ <div className="tool-dock" role="toolbar" aria-label="Tools" data-testid="tool-dock">
46
+ {DOCK_TOOLS.map((t) => (
47
+ <button
48
+ key={t}
49
+ type="button"
50
+ className={`tool-button${tool === t ? " is-armed" : ""}`}
51
+ aria-pressed={tool === t}
52
+ title={label(t)}
53
+ onClick={() => store.setTool(t)}
54
+ >
55
+ {DOCK_GLYPHS[t]}
56
+ </button>
57
+ ))}
58
+
59
+ <div className="tool-flyout-anchor">
60
+ <button
61
+ type="button"
62
+ className={`tool-button${FLYOUT_TOOLS.includes(tool as FlyoutTool) ? " is-armed" : ""}`}
63
+ aria-haspopup="menu"
64
+ aria-expanded={flyoutOpen}
65
+ title="Shapes"
66
+ onClick={() => setFlyoutOpen((v) => !v)}
67
+ >
68
+ ◇
69
+ </button>
70
+ {flyoutOpen ? (
71
+ <div className="tool-flyout" role="menu">
72
+ {FLYOUT_TOOLS.map((t) => (
73
+ <button
74
+ key={t}
75
+ type="button"
76
+ role="menuitem"
77
+ className={`tool-button${tool === t ? " is-armed" : ""}`}
78
+ title={label(t)}
79
+ onClick={() => {
80
+ store.setTool(t);
81
+ setFlyoutOpen(false);
82
+ }}
83
+ >
84
+ {FLYOUT_GLYPHS[t]} {specOf(t).label}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ ) : null}
89
+ </div>
90
+
91
+ {/* NOT a tool either, and beside the shapes for the same reason: it is
92
+ the kinds a DRAG CANNOT EXPRESS — a file, a registry choice, an
93
+ operation on the selection. See AddMenu. */}
94
+ <AddMenu />
95
+
96
+ {/* NOT a tool, and the separator says so. Animate changes what an edit
97
+ MEANS while any tool is held — see tools.ts. */}
98
+ <span className="tool-separator" aria-hidden="true" />
99
+ <button
100
+ type="button"
101
+ className={`tool-button tool-animate${animateMode ? " is-armed" : ""}`}
102
+ aria-pressed={animateMode}
103
+ title="Animate (⇧A) — a canvas edit becomes a block ending at the playhead"
104
+ onClick={() => store.setAnimateMode(!animateMode)}
105
+ >
106
+ ⏺
107
+ </button>
108
+ </div>
109
+ );
110
+ }