@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.
- package/dist/assets/index-B7notge7.css +1 -0
- package/dist/assets/index-BBE6Ta9L.js +52 -0
- package/dist/index.html +13 -0
- package/package.json +45 -0
- package/src/api.ts +289 -0
- package/src/app/App.tsx +394 -0
- package/src/audio/index.tsx +74 -0
- package/src/audio/preview.ts +364 -0
- package/src/bridge.ts +45 -0
- package/src/bridgeTypes.ts +582 -0
- package/src/channels.ts +40 -0
- package/src/main.tsx +15 -0
- package/src/panels/ResizeHandle.tsx +113 -0
- package/src/panels/agent/AgentPanel.tsx +478 -0
- package/src/panels/agent/agent.css +322 -0
- package/src/panels/export/ExportDialog.tsx +284 -0
- package/src/panels/files/FilePanel.tsx +564 -0
- package/src/panels/files/McpSection.tsx +66 -0
- package/src/panels/files/icons.tsx +118 -0
- package/src/panels/header/DocSwitcher.tsx +85 -0
- package/src/panels/header/Header.tsx +132 -0
- package/src/panels/header/HistoryControls.tsx +20 -0
- package/src/panels/header/StatusBadges.tsx +49 -0
- package/src/panels/header/TransportBar.tsx +86 -0
- package/src/panels/header/ZoomControls.tsx +38 -0
- package/src/panels/help/HelpSection.tsx +190 -0
- package/src/panels/inspector/FramingRows.tsx +69 -0
- package/src/panels/inspector/InspectorPanel.tsx +178 -0
- package/src/panels/inspector/MarkerSection.tsx +63 -0
- package/src/panels/inspector/ParamRow.tsx +71 -0
- package/src/panels/inspector/animate/BlockTiming.tsx +341 -0
- package/src/panels/inspector/animate/DuringBlocks.tsx +85 -0
- package/src/panels/inspector/animate/InOutPresets.tsx +57 -0
- package/src/panels/inspector/animate/ResolvedChannels.tsx +150 -0
- package/src/panels/inspector/animate/ShotSection.tsx +126 -0
- package/src/panels/inspector/animate/useBlocks.ts +43 -0
- package/src/panels/inspector/design/AlignRow.tsx +63 -0
- package/src/panels/inspector/design/AppearanceSection.tsx +49 -0
- package/src/panels/inspector/design/ClonerSection.tsx +142 -0
- package/src/panels/inspector/design/ComponentSection.tsx +94 -0
- package/src/panels/inspector/design/CornerSection.tsx +52 -0
- package/src/panels/inspector/design/FillSection.tsx +89 -0
- package/src/panels/inspector/design/HtmlSection.tsx +50 -0
- package/src/panels/inspector/design/InstanceSection.tsx +302 -0
- package/src/panels/inspector/design/MediaSection.tsx +251 -0
- package/src/panels/inspector/design/MeshSection.tsx +172 -0
- package/src/panels/inspector/design/ShadowSection.tsx +73 -0
- package/src/panels/inspector/design/StrokeSection.tsx +85 -0
- package/src/panels/inspector/design/TransformSection.tsx +91 -0
- package/src/panels/inspector/design/TypographySection.tsx +170 -0
- package/src/panels/inspector/scene/CameraSection.tsx +59 -0
- package/src/panels/inspector/scene/EffectsSection.tsx +123 -0
- package/src/panels/inspector/scene/SceneSection.tsx +71 -0
- package/src/panels/inspector/scene/TransitionSection.tsx +136 -0
- package/src/panels/inspector/useDocWrite.ts +268 -0
- package/src/panels/material/MaterialPicker.tsx +172 -0
- package/src/panels/stage/StageMenu.tsx +38 -0
- package/src/panels/stage/StagePanel.tsx +131 -0
- package/src/panels/timeline/AudioLane.tsx +340 -0
- package/src/panels/timeline/BlockRow.tsx +242 -0
- package/src/panels/timeline/ChannelRow.tsx +266 -0
- package/src/panels/timeline/LifespanRow.tsx +165 -0
- package/src/panels/timeline/MarkerLane.tsx +146 -0
- package/src/panels/timeline/SceneStrip.tsx +145 -0
- package/src/panels/timeline/TimelineDock.tsx +488 -0
- package/src/panels/timeline/model.ts +641 -0
- package/src/panels/timeline/snap.ts +191 -0
- package/src/panels/toolbar/AddMenu.tsx +230 -0
- package/src/panels/toolbar/CameraViewBar.tsx +70 -0
- package/src/panels/toolbar/ShadingBar.tsx +42 -0
- package/src/panels/toolbar/ToolDockBar.tsx +110 -0
- package/src/panels/toolbar/ViewModeBar.tsx +49 -0
- package/src/shotTokens.css +131 -0
- package/src/stage/FrustumGizmo.tsx +184 -0
- package/src/stage/GridPlane.tsx +274 -0
- package/src/stage/MoveGizmo.tsx +411 -0
- package/src/stage/PerfHud.tsx +206 -0
- package/src/stage/SplitRenderer.tsx +511 -0
- package/src/stage/StageApp.tsx +3423 -0
- package/src/stage/StageTools.tsx +581 -0
- package/src/stage/bridgeClient.ts +278 -0
- package/src/stage/feed.ts +243 -0
- package/src/stage/frustumGeometry.ts +172 -0
- package/src/stage/gizmoMath.ts +494 -0
- package/src/stage/handles.ts +151 -0
- package/src/stage/instanceModules.ts +152 -0
- package/src/stage/interactions/move.ts +172 -0
- package/src/stage/interactions/panZoom.ts +60 -0
- package/src/stage/isolation.ts +155 -0
- package/src/stage/main.tsx +110 -0
- package/src/stage/modalState.ts +427 -0
- package/src/stage/overlays/Overlays.tsx +1046 -0
- package/src/stage/panes.ts +121 -0
- package/src/stage/penState.ts +112 -0
- package/src/stage/project.ts +402 -0
- package/src/stage/shading.ts +55 -0
- package/src/stage/tokenColor.ts +26 -0
- package/src/stage/view.ts +182 -0
- package/src/stage/workingView.ts +636 -0
- package/src/state/agentActions.ts +203 -0
- package/src/state/clientId.ts +25 -0
- package/src/state/context.tsx +33 -0
- package/src/state/insert.ts +507 -0
- package/src/state/keymap.ts +560 -0
- package/src/state/menu.ts +141 -0
- package/src/state/panelSize.ts +26 -0
- package/src/state/renderEvents.ts +100 -0
- package/src/state/store.ts +4094 -0
- package/src/state/triangles.ts +70 -0
- package/src/state/types.ts +384 -0
- package/src/styles.css +2948 -0
- 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'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
|
+
}
|