@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,50 @@
1
+ // Only for HTML layers, and absent otherwise — the same rule every section in
2
+ // this folder follows.
3
+ //
4
+ // TWO FIELDS AND NOT ONE, because the split is a correctness rule rather than a
5
+ // layout preference: a snippet is parsed as XML, where an unescaped `a > b`
6
+ // inside a `<style>` element is a fatal error. Carrying the CSS separately is
7
+ // what lets `serializeHtmlSvg` wrap it in CDATA — see HtmlLayer3D.
8
+ //
9
+ // The fields commit on BLUR, which `TextField` already owns. That is the right
10
+ // granularity here and not a compromise: every commit re-serializes the snippet,
11
+ // re-decodes it through an SVG image and re-rasters the layer, so a per-keystroke
12
+ // commit would put a decode on the critical path of every character typed.
13
+ import { InspectorRow, InspectorSection, TextField } from "@dailies-studio/ui";
14
+ import type { HtmlLayer3D, Layer3D } from "@dailies-studio/shot/doc";
15
+ import { useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
16
+
17
+ export function HtmlSection() {
18
+ const write = useSelectionWrite();
19
+ const layers = useSelectedLayers();
20
+ const anyHtml = layers.some((l) => l.kind === "html");
21
+ const markup = useSelectionValue((layer) => (layer.kind === "html" ? layer.html.markup : undefined));
22
+ const css = useSelectionValue((layer) => (layer.kind === "html" ? (layer.html.css ?? "") : undefined));
23
+ if (!anyHtml || markup.value === undefined) return null;
24
+
25
+ const patch = (next: Partial<HtmlLayer3D["html"]>): void =>
26
+ write((layer) => (layer.kind === "html" ? ({ ...layer, html: { ...layer.html, ...next } } as Layer3D) : layer));
27
+
28
+ return (
29
+ <InspectorSection title="Html" persistKey="shot:html">
30
+ <InspectorRow label="Markup">
31
+ <TextField
32
+ value={markup.value}
33
+ multiline
34
+ ariaLabel="Html markup"
35
+ placeholder="<div>…</div>"
36
+ onCommit={(next) => patch({ markup: next })}
37
+ />
38
+ </InspectorRow>
39
+ <InspectorRow label="CSS">
40
+ <TextField
41
+ value={css.value ?? ""}
42
+ multiline
43
+ ariaLabel="Html css"
44
+ placeholder=".root { … }"
45
+ onCommit={(next) => patch({ css: next })}
46
+ />
47
+ </InspectorRow>
48
+ </InspectorSection>
49
+ );
50
+ }
@@ -0,0 +1,302 @@
1
+ // Only for INSTANCE layers, and absent otherwise — the rule every other
2
+ // kind-specific section here follows.
3
+ //
4
+ // ROWS COME FROM THE CONTRACT, NEVER FROM THE STORED VALUES. The widget for a
5
+ // prop is decided by its ControlSpec through the shared `PropControl` cluster,
6
+ // the same dispatch the motion editor's InstanceSection and the canvas editor's
7
+ // EmbedSection use — so a colour prop gets a swatch and an enum gets its
8
+ // options here for exactly the reason it does there. Deriving rows from
9
+ // `instance.props` instead would show only props somebody had already changed,
10
+ // with no kinds, no ranges and no labels.
11
+ //
12
+ // WHICH contract: the LIVE one from the registry when the project still has the
13
+ // component, falling back to the `{source:"snapshot"}` contract the document
14
+ // itself carries. The inspector is an authoring surface — it should offer what
15
+ // the component accepts NOW — while the snapshot keeps a component that has
16
+ // left the project editable rather than dead.
17
+ //
18
+ // ── QUARANTINE, WHICH IS THE POINT OF THE WHOLE SECTION ─────────────────
19
+ //
20
+ // A stored prop whose contract key has gone is NOT dropped and NOT hidden. The
21
+ // rename/orphan planner (`instancePropPlan`, which is
22
+ // `resolveTracksAgainstContract` underneath) classifies it, and it gets its own
23
+ // row above the contract rows: the value it is holding, what it was keyed on,
24
+ // and the two things an author can do about it. Both documented real-world
25
+ // versions of the alternative are silent data loss — Figma text properties
26
+ // resetting on publish, After Effects expression controls vanishing with their
27
+ // keyframes — so the value survives every save, every rebind and every reload
28
+ // until an author deletes it on purpose.
29
+ //
30
+ // DEFAULTS ARE NOT WRITTEN. A prop with no stored value shows the spec's
31
+ // default and stores nothing; `buildProps` supplies it at render time. Baking
32
+ // defaults in at insert time would freeze them against later edits to the
33
+ // component itself.
34
+ import { useEffect, useState } from "react";
35
+ import {
36
+ instanceContractDrift,
37
+ instancePropPlan,
38
+ orphanedPropKeys,
39
+ } from "@dailies-studio/shot/doc";
40
+ import type { Contract, ControlSpec } from "@dailies-studio/contract";
41
+ import type { Value } from "@dailies-studio/engine";
42
+ import { InspectorRow, InspectorSection, NumberField, PropControl } from "@dailies-studio/ui";
43
+ import { useShotEditor, useStore } from "../../../state/context.js";
44
+ import { NO_SCRUB, useSelectedLayers } from "../useDocWrite.js";
45
+
46
+ /** Contract kinds whose values are engine `Value`s and can therefore live in
47
+ * `instance.props`. Everything else is composite (curve/gradient — those ride
48
+ * an encoded string, which IS a Value) or not a value at all (slot/event). */
49
+ const VALUE_KINDS = new Set([
50
+ "number",
51
+ "string",
52
+ "color",
53
+ "boolean",
54
+ "enum",
55
+ "asset",
56
+ "vec2",
57
+ "vec3",
58
+ "quaternion",
59
+ "curve",
60
+ "gradient",
61
+ ]);
62
+
63
+ /** Contract order, then key — the motion inspector's `orderedSpecs` rule. */
64
+ function orderedSpecs(contract: Contract): ControlSpec[] {
65
+ return Object.values(contract.keys).sort((a, b) => {
66
+ const ao = a.order ?? Number.MAX_SAFE_INTEGER;
67
+ const bo = b.order ?? Number.MAX_SAFE_INTEGER;
68
+ return ao !== bo ? ao - bo : a.key.localeCompare(b.key);
69
+ });
70
+ }
71
+
72
+ /** PropControl hands back whatever its widget produced; only engine Values may
73
+ * be stored. Anything else is refused rather than written, because the next
74
+ * save would refuse the document. */
75
+ function asValue(raw: unknown): Value | null {
76
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
77
+ if (typeof raw === "string" || typeof raw === "boolean") return raw;
78
+ if (
79
+ Array.isArray(raw) &&
80
+ (raw.length === 2 || raw.length === 3 || raw.length === 4) &&
81
+ raw.every((n) => typeof n === "number" && Number.isFinite(n))
82
+ ) {
83
+ return raw as Value;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export function InstanceSection() {
89
+ const store = useStore();
90
+ const doc = useShotEditor((s) => s.doc);
91
+ const contracts = useShotEditor((s) => s.contracts);
92
+ const library = useShotEditor((s) => s.library);
93
+ const libraryLoaded = useShotEditor((s) => s.libraryLoaded);
94
+ const libraryError = useShotEditor((s) => s.libraryError);
95
+ const layers = useSelectedLayers();
96
+ const [rebinding, setRebinding] = useState(false);
97
+ const [rebindError, setRebindError] = useState<string | null>(null);
98
+
99
+ // ONE layer at a time, deliberately. An instance's props belong to one
100
+ // component's contract, so a fan-out across a mixed selection would write
101
+ // keys the other layers' components never had.
102
+ const layer = layers.length === 1 && layers[0]?.kind === "instance" ? layers[0] : null;
103
+ const componentId = layer?.instance.component ?? null;
104
+
105
+ // Hooks stay unconditional (the section mounts for every selection and
106
+ // self-gates on kind).
107
+ useEffect(() => {
108
+ if (componentId === null) return;
109
+ void store.loadContract(componentId);
110
+ if (!store.getState().libraryLoaded) void store.refreshLibrary();
111
+ }, [store, componentId]);
112
+
113
+ if (doc === null || layer === null || componentId === null) return null;
114
+
115
+ const row = library.find((r) => r.id === componentId);
116
+ const ref = layer.instance.contract;
117
+ const snapshot: Contract | null = ref?.source === "snapshot" ? ref.contract : null;
118
+ const live = contracts[componentId];
119
+ const contract = live ?? snapshot;
120
+ // Judged only once the registry has ANSWERED — and "answered" means a
121
+ // successful fetch, not merely "loading finished". A FAILED fetch also ends
122
+ // with an empty list, and reporting every instance as an unknown component
123
+ // over a transient network failure would tell the author to delete work.
124
+ const drift = libraryLoaded
125
+ ? instanceContractDrift(doc, (id) => {
126
+ const r = library.find((c) => c.id === id);
127
+ const c = contracts[id];
128
+ return r === undefined || c === undefined
129
+ ? null
130
+ : { availability: r.contractAvailability ?? "inferred", contract: c };
131
+ }).filter((d) => d.layerId === layer.id)
132
+ : [];
133
+ const missing = libraryLoaded && row === undefined;
134
+
135
+ const specs = contract === null || contract === undefined ? [] : orderedSpecs(contract);
136
+ // The quarantine list. Against the LIVE contract when there is one: a key is
137
+ // only orphaned relative to what the component accepts today.
138
+ const orphans =
139
+ live === undefined ? [] : orphanedPropKeys(instancePropPlan(layer.instance.props, snapshot ?? live, live));
140
+
141
+ return (
142
+ <InspectorSection title="Component" persistKey="shot:instance">
143
+ <InspectorRow label="Module">
144
+ <span className="insp-fixed" title={componentId}>
145
+ {row?.name ?? componentId}
146
+ </span>
147
+ </InspectorRow>
148
+
149
+ {/* mo1's "Play" on an instance, and the reason it is a NUMBER rather than
150
+ a button: retriggering a module is an offset, not state, so it stays a
151
+ pure function of the frame and an out-of-order export samples the same
152
+ pose a scrub does. Negative holds the module at its own frame 0 until
153
+ the scene catches up. */}
154
+ <InspectorRow label="Clock">
155
+ <NumberField
156
+ value={layer.instance.clockOffsetFrames ?? 0}
157
+ ariaLabel="Instance clock offset"
158
+ unit="f"
159
+ step={1}
160
+ onCommit={(next) => store.setInstanceClockOffset(layer.id, next)}
161
+ />
162
+ </InspectorRow>
163
+
164
+ {libraryError !== null && (
165
+ <div className="insp-inline-error">
166
+ {`The component registry could not be read (${libraryError}) — this instance cannot be checked.`}
167
+ </div>
168
+ )}
169
+
170
+ {missing && (
171
+ <div className="insp-notice is-warn">
172
+ {`${componentId} is not in the component registry. The generated scene's import will not ` +
173
+ `resolve and the save will be refused until the component is back or the layer is removed.`}
174
+ </div>
175
+ )}
176
+
177
+ {drift.length > 0 && (
178
+ <div className="insp-notice is-warn">
179
+ {drift.map((d) => (
180
+ <span key={d.code}>{d.message}</span>
181
+ ))}
182
+ {/* The ONE action drift offers, and it is never automatic. Re-binding
183
+ is a statement that the component's CURRENT contract is the one
184
+ this instance means; the planner moves every renamed key's value
185
+ and leaves every orphan exactly where it is. */}
186
+ <button
187
+ className="btn"
188
+ disabled={rebinding}
189
+ data-instance-rebind={layer.id}
190
+ title="Re-record this instance's contract hash and source from the project's current component, following any renames"
191
+ onClick={() => {
192
+ setRebinding(true);
193
+ void store.rebindInstance(layer.id).then((err) => {
194
+ setRebinding(false);
195
+ setRebindError(err);
196
+ });
197
+ }}
198
+ >
199
+ Accept current contract
200
+ </button>
201
+ </div>
202
+ )}
203
+ {rebindError !== null && <div className="insp-inline-error">{rebindError}</div>}
204
+
205
+ {orphans.map((key) => (
206
+ <div key={`orphan:${key}`} className="insp-notice is-warn" data-instance-orphan={key}>
207
+ {`"${key}" is not a prop of this component any more. Its value is kept here and is not ` +
208
+ `passed to the component — nothing was deleted.`}
209
+ <button
210
+ className="btn"
211
+ data-instance-drop={key}
212
+ title={`Delete the stored value for "${key}". This is the only thing that ever removes it.`}
213
+ onClick={() => store.clearInstanceProp(layer.id, key)}
214
+ >
215
+ Discard
216
+ </button>
217
+ </div>
218
+ ))}
219
+
220
+ {contract === null || contract === undefined ? (
221
+ <div className="insp-notice">
222
+ No contract available for this component — its props cannot be edited here.
223
+ </div>
224
+ ) : (
225
+ <>
226
+ {live === undefined && snapshot !== null && (
227
+ <div className="insp-notice">
228
+ Showing the contract snapshot recorded with this layer (the project&apos;s copy is not
229
+ loaded).
230
+ </div>
231
+ )}
232
+ {specs.length === 0 && <div className="insp-notice">This component has no props.</div>}
233
+ {specs.map((spec) => (
234
+ <InstancePropRow
235
+ key={spec.key}
236
+ layerId={layer.id}
237
+ spec={spec}
238
+ value={layer.instance.props[spec.key]}
239
+ />
240
+ ))}
241
+ </>
242
+ )}
243
+ </InspectorSection>
244
+ );
245
+ }
246
+
247
+ function InstancePropRow(props: { layerId: string; spec: ControlSpec; value: Value | undefined }) {
248
+ const { layerId, spec } = props;
249
+ const store = useStore();
250
+ const [error, setError] = useState<string | null>(null);
251
+ // An unset prop shows (and edits from) the spec's default — the value the
252
+ // runtime's `buildProps` will supply — without ever storing it. `slot` and
253
+ // `event` carry no default at all, hence the presence check.
254
+ const value = props.value ?? ("default" in spec ? spec.default : undefined);
255
+ const storable = VALUE_KINDS.has(spec.kind);
256
+ const inherited = props.value === undefined;
257
+
258
+ const commit = (raw: unknown): void => {
259
+ const next = asValue(raw);
260
+ if (next === null) {
261
+ setError("instance props hold engine values only (number, string, boolean, vec2/3/4)");
262
+ return;
263
+ }
264
+ setError(null);
265
+ store.setInstanceProp(layerId, spec.key, next);
266
+ };
267
+
268
+ return (
269
+ <div className={`insp-prop${inherited ? " is-inherited" : ""}`}>
270
+ <div className="insp-row">
271
+ {spec.kind !== "number" && (
272
+ <span className="insp-label" title={spec.description ?? spec.key}>
273
+ {spec.label ?? spec.key}
274
+ </span>
275
+ )}
276
+ <div className="insp-control">
277
+ {storable ? (
278
+ <PropControl spec={spec} value={value} commit={commit} scrub={NO_SCRUB} />
279
+ ) : (
280
+ <span
281
+ className="badge"
282
+ title={`"${spec.kind}" values are not engine Values, so they cannot be stored on an instance — set this prop inside the component itself.`}
283
+ >
284
+ {spec.kind} — not editable on an instance
285
+ </span>
286
+ )}
287
+ {!inherited && (
288
+ <button
289
+ className="btn"
290
+ data-instance-reset={spec.key}
291
+ title={`Clear the stored value for "${spec.label ?? spec.key}" and fall back to the component's default.`}
292
+ onClick={() => store.clearInstanceProp(layerId, spec.key)}
293
+ >
294
+ Reset
295
+ </button>
296
+ )}
297
+ </div>
298
+ </div>
299
+ {error !== null && <div className="insp-inline-error">{error}</div>}
300
+ </div>
301
+ );
302
+ }
@@ -0,0 +1,251 @@
1
+ // Only for IMAGE and VIDEO layers, and absent otherwise — the same rule
2
+ // MeshSection and TypographySection follow, for the same reason: a media
3
+ // section over a rectangle is six controls that write nothing.
4
+ //
5
+ // ── ONE SECTION FOR TWO KINDS ───────────────────────────────────────────
6
+ //
7
+ // `image` and `video` are one pipeline everywhere it matters: the runtime
8
+ // collapses both to {asset, fit, crop} and draws them through one plane. So the
9
+ // controls they share are authored once here, and the two fields that differ —
10
+ // `alt` on an image, `offsetFrames` on a video — are rows that only their own
11
+ // kind renders rather than a second section.
12
+ //
13
+ // ── WHY THIS SECTION HAD TO EXIST ───────────────────────────────────────
14
+ //
15
+ // `fit`, `crop`, `alt` and `offsetFrames` were all reachable ONLY through an
16
+ // MCP call. A document can therefore hold a crop or a source offset that the
17
+ // author cannot see, let alone undo, from the chrome that wrote the layer.
18
+ import {
19
+ InspectorRow,
20
+ InspectorSection,
21
+ NumberField,
22
+ PropertyGrid,
23
+ SegmentedControl,
24
+ TextField,
25
+ } from "@dailies-studio/ui";
26
+ import type { Crop, ImageLayer3D, Layer3D, LayerFit, VideoLayer3D } from "@dailies-studio/shot/doc";
27
+ import { useSelectedLayers, useSelectionValue, useSelectionWrite } from "../useDocWrite.js";
28
+
29
+ /**
30
+ * The two content blocks as ONE shape.
31
+ *
32
+ * An intersection rather than a union: every field is either shared or optional
33
+ * on both sides, so one mapper can carry a write to whichever block the layer
34
+ * has, and the per-kind fields are gated by the ROW that offers them instead of
35
+ * by a second block type and a second mapper.
36
+ */
37
+ type MediaBlock = ImageLayer3D["image"] & VideoLayer3D["video"];
38
+
39
+ /** The fits, as data. `satisfies` pins them to the schema's own union, so a fit
40
+ * renamed there is a compile error here rather than a segment that writes a
41
+ * value the rasterizer ignores. */
42
+ const FITS = ["fill", "fit", "stretch"] as const satisfies readonly LayerFit[];
43
+
44
+ /** What `+` adds: the identity crop. Adding one must not move a pixel — the
45
+ * author is opening the control, not asking for a change — and "the whole
46
+ * source" is exactly what an absent crop already means. */
47
+ const FULL_CROP: Crop = { x: 0, y: 0, w: 1, h: 1 };
48
+
49
+ export function MediaSection() {
50
+ const write = useSelectionWrite();
51
+ const layers = useSelectedLayers();
52
+ const media = useSelectionValue((layer) => mediaOf(layer));
53
+ // The first media layer in the selection, which is what the title names and
54
+ // what the per-kind rows are chosen by. Read from the LIST rather than from
55
+ // `media.value` (which is the first SELECTED layer's, media or not), so a
56
+ // rect clicked before an image does not empty the section.
57
+ const subject = layers.find(isMedia);
58
+ if (subject === undefined) return null;
59
+ const block = mediaOf(subject);
60
+ if (block === undefined) return null;
61
+
62
+ /** Every write goes through one mapper, KIND-GUARDED INSIDE IT: the seam fans
63
+ * out over the whole selection, and a rect has no `fit` to set.
64
+ *
65
+ * `only` narrows that fan-out further, and the per-kind rows MUST use it.
66
+ * The rows are chosen by the first media layer in the selection, but the
67
+ * write reaches every one of them — so with an image and a video selected
68
+ * together, the image's Alt row would otherwise write `alt` into the video
69
+ * block and the video's Start row would write `offsetFrames` into the image.
70
+ * `MediaBlock` is the intersection of the two, which makes both foreign keys
71
+ * type-legal, and `validateShotDoc` is deliberately not stricter than the
72
+ * resolver, so nothing downstream refuses them. They surface much later, as
73
+ * an excess-property error in the generated `satisfies Doc3D` literal. */
74
+ const patch = (next: (media: MediaBlock) => MediaBlock, only?: "image" | "video"): void =>
75
+ write((layer) =>
76
+ layer.kind === "image" && only !== "video"
77
+ ? { ...layer, image: next(layer.image) }
78
+ : layer.kind === "video" && only !== "image"
79
+ ? { ...layer, video: next(layer.video) }
80
+ : layer,
81
+ );
82
+
83
+ const setCrop = (key: keyof Crop, value: number): void =>
84
+ patch((m) => ({ ...m, crop: { ...(m.crop ?? FULL_CROP), [key]: clamp01(value) } }));
85
+
86
+ const crop = block.crop;
87
+
88
+ return (
89
+ <InspectorSection
90
+ title={subject.kind === "video" ? "Video" : "Image"}
91
+ persistKey="shot:media"
92
+ action={
93
+ <button
94
+ type="button"
95
+ className="section-action"
96
+ title={crop === undefined ? "Add crop" : "Remove crop"}
97
+ onClick={() =>
98
+ patch((m) => (m.crop === undefined ? { ...m, crop: FULL_CROP } : without(m, "crop")))
99
+ }
100
+ >
101
+ {crop === undefined ? "+" : "−"}
102
+ </button>
103
+ }
104
+ >
105
+ <InspectorRow label="Fit">
106
+ <SegmentedControl
107
+ ariaLabel="Media fit"
108
+ value={block.fit}
109
+ options={FITS.map((fit) => ({ value: fit, label: fit }))}
110
+ onChange={(next) => patch((m) => ({ ...m, fit: next as LayerFit }))}
111
+ />
112
+ </InspectorRow>
113
+
114
+ {/* Fractions of the SOURCE, not of the box, and y counts DOWN from the
115
+ top row the way a screenshot's own pixels are numbered — media.ts owns
116
+ the flip into uv space, so these are the numbers a design tool would
117
+ report for the same rectangle. */}
118
+ {crop !== undefined ? (
119
+ <PropertyGrid>
120
+ <InspectorRow>
121
+ <NumberField
122
+ value={crop.x}
123
+ mixed={media.mixed}
124
+ label="X"
125
+ ariaLabel="Crop x"
126
+ min={0}
127
+ max={1}
128
+ step={0.01}
129
+ onCommit={(next) => setCrop("x", next)}
130
+ />
131
+ </InspectorRow>
132
+ <InspectorRow>
133
+ <NumberField
134
+ value={crop.y}
135
+ mixed={media.mixed}
136
+ label="Y"
137
+ ariaLabel="Crop y"
138
+ min={0}
139
+ max={1}
140
+ step={0.01}
141
+ onCommit={(next) => setCrop("y", next)}
142
+ />
143
+ </InspectorRow>
144
+ <InspectorRow>
145
+ <NumberField
146
+ value={crop.w}
147
+ mixed={media.mixed}
148
+ label="W"
149
+ ariaLabel="Crop width"
150
+ min={0}
151
+ max={1}
152
+ step={0.01}
153
+ onCommit={(next) => setCrop("w", next)}
154
+ />
155
+ </InspectorRow>
156
+ <InspectorRow>
157
+ <NumberField
158
+ value={crop.h}
159
+ mixed={media.mixed}
160
+ label="H"
161
+ ariaLabel="Crop height"
162
+ min={0}
163
+ max={1}
164
+ step={0.01}
165
+ onCommit={(next) => setCrop("h", next)}
166
+ />
167
+ </InspectorRow>
168
+ </PropertyGrid>
169
+ ) : null}
170
+
171
+ {subject.kind === "image" ? (
172
+ <InspectorRow label="Alt">
173
+ <TextField
174
+ value={block.alt ?? ""}
175
+ ariaLabel="Alt text"
176
+ onCommit={(next) =>
177
+ patch((m) => {
178
+ const trimmed = next.trim();
179
+ // `alt: ""` is a described image whose description is empty,
180
+ // which assistive tech reads differently from an undescribed
181
+ // one. Absent is what "nothing to say" actually spells.
182
+ return trimmed === "" ? without(m, "alt") : { ...m, alt: trimmed };
183
+ }, "image")
184
+ }
185
+ />
186
+ </InspectorRow>
187
+ ) : null}
188
+
189
+ {subject.kind === "video" ? (
190
+ // PLACEMENT, NOT A TRIM. This is the scene frame the source's own t=0
191
+ // lands on, so every frame before it holds the source's FIRST frame and
192
+ // nothing is cut off the head. Audio's identically-named `offsetFrames`
193
+ // is the other thing — a trim into the source — and confusing the two
194
+ // is why the label says "starts at" rather than "start".
195
+ <InspectorRow label="Starts at" labelTitle="The scene frame this clip's first frame lands on — a delay, never a trim into the source">
196
+ <NumberField
197
+ value={block.offsetFrames ?? 0}
198
+ mixed={media.mixed}
199
+ ariaLabel="Video start frame"
200
+ unit="f"
201
+ step={1}
202
+ // NO `min`. NumberField clamps against it on every commit path —
203
+ // typing, the stepper and the drag — so `min={0}` would turn the
204
+ // one value this feature exists to preserve into 0 the first time
205
+ // the author touched the field, and the `frames === 0` branch below
206
+ // would then DELETE it, jumping the footage. A negative offset is
207
+ // what `splitScene` produces for a clip already playing at the cut,
208
+ // and it is untypeable, so erasing one is unrecoverable.
209
+ //
210
+ // 0 DELETES the key: absent already means "starts at frame 0", and
211
+ // an explicit 0 is a second way to say the same thing that codegen
212
+ // then prints. `Math.round` covers the only bound the save gate
213
+ // actually holds — the value must be an integer.
214
+ onCommit={(next) => {
215
+ const frames = Math.round(next);
216
+ patch(
217
+ (m) => (frames === 0 ? without(m, "offsetFrames") : { ...m, offsetFrames: frames }),
218
+ "video",
219
+ );
220
+ }}
221
+ />
222
+ </InspectorRow>
223
+ ) : null}
224
+ </InspectorSection>
225
+ );
226
+ }
227
+
228
+ function isMedia(layer: Layer3D): boolean {
229
+ return layer.kind === "image" || layer.kind === "video";
230
+ }
231
+
232
+ function mediaOf(layer: Layer3D): MediaBlock | undefined {
233
+ if (layer.kind === "image") return layer.image;
234
+ if (layer.kind === "video") return layer.video;
235
+ return undefined;
236
+ }
237
+
238
+ /** Drop one optional key, rather than setting it to `undefined`. Under
239
+ * exactOptionalPropertyTypes those are different values, and codegen prints
240
+ * the block by key order — a present-but-undefined key is a key. */
241
+ function without(media: MediaBlock, key: "crop" | "alt" | "offsetFrames"): MediaBlock {
242
+ const { [key]: _drop, ...rest } = media;
243
+ return rest;
244
+ }
245
+
246
+ /** A crop is a fraction of the source. Past 0..1 it samples nothing, and
247
+ * `cropUv` clamps anyway — so the field clamps too rather than storing a
248
+ * number the renderer silently disagrees with. */
249
+ function clamp01(value: number): number {
250
+ return Math.max(0, Math.min(1, value));
251
+ }