@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,507 @@
|
|
|
1
|
+
// WHAT A DRAWN LAYER IS. One pure function from (tool, design points) to a
|
|
2
|
+
// layer, and the insert map every creating tool has to answer in.
|
|
3
|
+
//
|
|
4
|
+
// ── WHY IT IS NOT IN THE STORE ──────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// Everything here is arithmetic and defaults: no document, no history, no
|
|
7
|
+
// asynchrony, no DOM. That makes the part with actual geometry in it testable
|
|
8
|
+
// by calling it, which matters because the two facts worth pinning — a drag
|
|
9
|
+
// from bottom-right to top-left gives the SAME box as the reverse, and the
|
|
10
|
+
// endpoints of a line survive that normalisation as fractions — are exactly the
|
|
11
|
+
// ones a store test would have to reach through a fake stage to see.
|
|
12
|
+
//
|
|
13
|
+
// ── THE COORDINATE CONTRACT ─────────────────────────────────────────────
|
|
14
|
+
//
|
|
15
|
+
// Points arrive in the schema's own space: design px from the CANVAS CENTRE,
|
|
16
|
+
// +y DOWN (see the header of @dailies-studio/shot's types.ts). The stage did the
|
|
17
|
+
// conversion because it is the half that knows the pane's pixel size. Nothing
|
|
18
|
+
// here divides by a zoom, and nothing here knows what a screen is.
|
|
19
|
+
import { LAYER_KINDS, clampSize, pathBounds, pathToD, translatePath } from "@dailies-studio/shot/doc";
|
|
20
|
+
import type {
|
|
21
|
+
InstanceContractRef,
|
|
22
|
+
Layer3D,
|
|
23
|
+
Layer3DKind,
|
|
24
|
+
Paint,
|
|
25
|
+
PathData,
|
|
26
|
+
Stroke,
|
|
27
|
+
TextStyle,
|
|
28
|
+
} from "@dailies-studio/shot/doc";
|
|
29
|
+
import type { Vec2, Vec3 } from "@dailies-studio/engine";
|
|
30
|
+
import type { InsertTool } from "../tools.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A layer before `addLayer` gives it an id and a paint order.
|
|
34
|
+
*
|
|
35
|
+
* DISTRIBUTED over the union, unlike `Omit<Layer3D, …>`, which is not: `Omit`
|
|
36
|
+
* of a union keeps only the keys every member shares, so that spelling would
|
|
37
|
+
* quietly drop `text`, `line` and every other content block and let this file
|
|
38
|
+
* build a text layer with no text in it.
|
|
39
|
+
*/
|
|
40
|
+
export type NewLayer3D = {
|
|
41
|
+
[K in Layer3DKind]: Omit<Extract<Layer3D, { kind: K }>, "id" | "order">;
|
|
42
|
+
}[Layer3DKind];
|
|
43
|
+
|
|
44
|
+
/** Design px from the canvas centre. */
|
|
45
|
+
export type DrawPoint = readonly [number, number];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The box a click gets, in design px.
|
|
49
|
+
*
|
|
50
|
+
* One size for every tool rather than a table of them, because a click is not
|
|
51
|
+
* an expression of intent about size — it is "put one here" — and the author's
|
|
52
|
+
* next gesture on a freshly selected layer is a resize either way.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_DRAW_SIZE: Vec2 = [240, 160];
|
|
55
|
+
|
|
56
|
+
/** A model's box. Square, because `fit` preserves the model's own proportions
|
|
57
|
+
* inside it and a square makes no claim about which way the model is long. */
|
|
58
|
+
export const DEFAULT_MESH_SIZE: Vec2 = [480, 480];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A device's box. Portrait, and the height is a CHOICE this file has to make:
|
|
62
|
+
* a preset carries ratios of the WIDTH only and deliberately no aspect (see
|
|
63
|
+
* devicePresets.ts), so there is no registry answer to inherit.
|
|
64
|
+
*/
|
|
65
|
+
export const DEFAULT_DEVICE_SIZE: Vec2 = [400, 800];
|
|
66
|
+
|
|
67
|
+
/** A snippet's box. Landscape and generous: the box IS the layout viewport for
|
|
68
|
+
* an html layer — a percentage width inside it resolves against this — so a
|
|
69
|
+
* square default would make every first snippet reflow the moment it is
|
|
70
|
+
* resized to something a card actually wants. */
|
|
71
|
+
export const DEFAULT_HTML_SIZE: Vec2 = [720, 360];
|
|
72
|
+
|
|
73
|
+
/** An instance's box. Square, like a model's, and for the same reason: the
|
|
74
|
+
* component decides its own extent (the box is a placement and a `CompSize`,
|
|
75
|
+
* not a crop), so a rectangle here would make a claim about which way an
|
|
76
|
+
* arbitrary module is long. */
|
|
77
|
+
export const DEFAULT_INSTANCE_SIZE: Vec2 = [480, 480];
|
|
78
|
+
|
|
79
|
+
/** What a drawn shape is filled with. White on the near-black a fresh scene
|
|
80
|
+
* opens on: a default that draws nothing looks exactly like a tool that did
|
|
81
|
+
* nothing. */
|
|
82
|
+
export const DRAW_FILL: Paint = { type: "solid", color: "#ffffff" };
|
|
83
|
+
|
|
84
|
+
/** …and what a drawn line, arrow or path is stroked with. A line layer with no
|
|
85
|
+
* stroke is not drawn at all (`drawStroke` is the only thing that draws one),
|
|
86
|
+
* so this is not a nicety. */
|
|
87
|
+
export const DRAW_STROKE: Stroke = { color: "#ffffff", widthPx: 4, align: "center" };
|
|
88
|
+
|
|
89
|
+
export const DRAW_TEXT_STYLE: TextStyle = {
|
|
90
|
+
fontFamily: "Inter, system-ui, sans-serif",
|
|
91
|
+
weight: 600,
|
|
92
|
+
sizePx: 64,
|
|
93
|
+
lineHeight: 1.2,
|
|
94
|
+
letterSpacingPx: 0,
|
|
95
|
+
align: "left",
|
|
96
|
+
color: "#ffffff",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The arrowhead, as a fraction of the box's larger side — the unit the schema
|
|
100
|
+
* uses, so it scales with the arrow instead of needing a second edit. */
|
|
101
|
+
const ARROW_HEAD = 0.12;
|
|
102
|
+
|
|
103
|
+
/** Where the playhead is and how long its scene is: everything a new layer
|
|
104
|
+
* needs to land in TIME. */
|
|
105
|
+
export interface InsertContext {
|
|
106
|
+
/** SCENE-LOCAL frame under the playhead. */
|
|
107
|
+
sceneFrame: number;
|
|
108
|
+
/** The scene's own length, which is where a lifetime that starts late ends. */
|
|
109
|
+
durationFrames: number;
|
|
110
|
+
/** For the kinds that name one. Absent for everything else. */
|
|
111
|
+
asset?: string;
|
|
112
|
+
/**
|
|
113
|
+
* WHICH media the asset turned out to be, as the SERVER sniffed it.
|
|
114
|
+
*
|
|
115
|
+
* The Frame tool draws ONE box and the file decides what fills it, so the
|
|
116
|
+
* `image` arm below is the only one that forks on something outside the
|
|
117
|
+
* geometry. It is the store's kind and never a browser MIME guess: the sniff
|
|
118
|
+
* is the stored filename's extension, which is the same answer
|
|
119
|
+
* `validateShotDoc` and the exporter will get, and a client-side guess that
|
|
120
|
+
* disagreed would mint a layer kind the document cannot back.
|
|
121
|
+
*
|
|
122
|
+
* Absent = an image, which is what every caller before video existed meant.
|
|
123
|
+
*/
|
|
124
|
+
assetKind?: "image" | "video";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A drag, normalised into the box it describes.
|
|
129
|
+
*
|
|
130
|
+
* `from`/`to` are the drag's own ends as FRACTIONS of that box, which is what
|
|
131
|
+
* survives the normalisation: the box is direction-free by construction (it is
|
|
132
|
+
* a min/max), and a line drawn up-left has to keep pointing up-left.
|
|
133
|
+
*/
|
|
134
|
+
interface DrawBox {
|
|
135
|
+
/** Top-left corner, design px. Only the path builder needs it. */
|
|
136
|
+
min: Vec2;
|
|
137
|
+
/** Centre, which is what `Transform3D.position` means. */
|
|
138
|
+
position: Vec3;
|
|
139
|
+
size: Vec2;
|
|
140
|
+
from: Vec2;
|
|
141
|
+
to: Vec2;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The box N points describe.
|
|
146
|
+
*
|
|
147
|
+
* A CLICK — every point identical, which is also how the stage reports a drag
|
|
148
|
+
* that never passed its travel threshold — is rewritten as a drag of the
|
|
149
|
+
* default size centred on it, and then falls through the ordinary path. That is
|
|
150
|
+
* one branch here instead of one in every insert below: a clicked rect gets its
|
|
151
|
+
* default box and a clicked line gets a diagonal across the same box, with no
|
|
152
|
+
* tool having to answer "and what if it was a click".
|
|
153
|
+
*
|
|
154
|
+
* Each axis goes through `clampSize`, the SAME clamp a resize gesture uses. A
|
|
155
|
+
* perfectly horizontal line drag has a zero-height bounding box, and
|
|
156
|
+
* `validateShotDoc` refuses a non-positive size — so without the floor the most
|
|
157
|
+
* ordinary line an author can draw would produce a document that cannot be
|
|
158
|
+
* saved. The ceiling comes along for free, which is what a drag across a
|
|
159
|
+
* zoomed-way-out pane needs.
|
|
160
|
+
*/
|
|
161
|
+
function boxOf(points: readonly DrawPoint[]): DrawBox | null {
|
|
162
|
+
const first = points[0];
|
|
163
|
+
const last = points[points.length - 1];
|
|
164
|
+
if (first === undefined || last === undefined) return null;
|
|
165
|
+
|
|
166
|
+
let minX = Infinity;
|
|
167
|
+
let minY = Infinity;
|
|
168
|
+
let maxX = -Infinity;
|
|
169
|
+
let maxY = -Infinity;
|
|
170
|
+
for (const [x, y] of points) {
|
|
171
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
172
|
+
minX = Math.min(minX, x);
|
|
173
|
+
maxX = Math.max(maxX, x);
|
|
174
|
+
minY = Math.min(minY, y);
|
|
175
|
+
maxY = Math.max(maxY, y);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const rawW = maxX - minX;
|
|
179
|
+
const rawH = maxY - minY;
|
|
180
|
+
if (rawW <= 0 && rawH <= 0) {
|
|
181
|
+
const [w, h] = DEFAULT_DRAW_SIZE;
|
|
182
|
+
return boxOf([
|
|
183
|
+
[minX - w / 2, minY - h / 2],
|
|
184
|
+
[minX + w / 2, minY + h / 2],
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The fraction is taken against the RAW span, never the floored one: a
|
|
189
|
+
// degenerate axis has no meaningful fraction, and 0.5 puts the endpoint on
|
|
190
|
+
// the box's centre line, which is where a horizontal line actually is.
|
|
191
|
+
const frac = (p: DrawPoint): Vec2 => [
|
|
192
|
+
rawW > 0 ? (p[0] - minX) / rawW : 0.5,
|
|
193
|
+
rawH > 0 ? (p[1] - minY) / rawH : 0.5,
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
min: [minX, minY],
|
|
198
|
+
position: [(minX + maxX) / 2, (minY + maxY) / 2, 0],
|
|
199
|
+
size: [clampSize(rawW), clampSize(rawH)],
|
|
200
|
+
from: frac(first),
|
|
201
|
+
to: frac(last),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* THE INSERT MAP. Keyed on `InsertTool`, so a tool added to the table without
|
|
207
|
+
* an answer here is a compile error rather than a button that does nothing.
|
|
208
|
+
*
|
|
209
|
+
* A builder returns null when it cannot make a layer it would be honest about —
|
|
210
|
+
* only `image` can, and only for want of an asset.
|
|
211
|
+
*/
|
|
212
|
+
const INSERT: Record<
|
|
213
|
+
InsertTool,
|
|
214
|
+
(box: DrawBox, points: readonly DrawPoint[], ctx: InsertContext) => NewLayer3D | null
|
|
215
|
+
> = {
|
|
216
|
+
rect: (box) => ({ kind: "rect", label: "Rect", ...common(box), paint: { fill: DRAW_FILL } }),
|
|
217
|
+
ellipse: (box) => ({ kind: "ellipse", label: "Ellipse", ...common(box), paint: { fill: DRAW_FILL } }),
|
|
218
|
+
text: (box) => ({
|
|
219
|
+
kind: "text",
|
|
220
|
+
label: "Text",
|
|
221
|
+
...common(box),
|
|
222
|
+
paint: {},
|
|
223
|
+
text: { content: "Text", style: DRAW_TEXT_STYLE },
|
|
224
|
+
}),
|
|
225
|
+
// ONE ARM, TWO KINDS. `image` and `video` are one pipeline everywhere it
|
|
226
|
+
// matters — the runtime collapses both to {asset, fit, crop} and draws them
|
|
227
|
+
// through one plane — so the Frame tool draws one box and the file the author
|
|
228
|
+
// picks decides which kind it becomes. Both wire kinds stay: merging them is
|
|
229
|
+
// a schema break for zero runtime gain.
|
|
230
|
+
image: (box, _points, ctx) => {
|
|
231
|
+
if (ctx.asset === undefined) return null;
|
|
232
|
+
// "fill" and not "fit", for both: the author drew the box they want
|
|
233
|
+
// covered, and letterboxing it would leave the drawn rectangle half empty.
|
|
234
|
+
if (ctx.assetKind !== "video") {
|
|
235
|
+
return { kind: "image", label: "Image", ...common(box), paint: {}, image: { asset: ctx.asset, fit: "fill" } };
|
|
236
|
+
}
|
|
237
|
+
// THE SOURCE STARTS WHERE THE LAYER DOES. `withLifetime` puts the layer's
|
|
238
|
+
// `in` at the playhead, and a video whose source clock still began at scene
|
|
239
|
+
// frame 0 would open already that many frames in — a clip dropped at frame
|
|
240
|
+
// 50 showing its 50th frame first, which reads as a corrupt asset rather
|
|
241
|
+
// than as a placement. Written only when it is non-zero, and CONDITIONALLY
|
|
242
|
+
// SPREAD rather than assigned: absent already means 0, and under
|
|
243
|
+
// exactOptionalPropertyTypes `offsetFrames: undefined` is a key.
|
|
244
|
+
const start = startFrameOf(ctx);
|
|
245
|
+
return {
|
|
246
|
+
kind: "video",
|
|
247
|
+
label: "Video",
|
|
248
|
+
...common(box),
|
|
249
|
+
paint: {},
|
|
250
|
+
video: { asset: ctx.asset, fit: "fill", ...(start > 0 ? { offsetFrames: start } : {}) },
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
line: (box) => ({
|
|
254
|
+
kind: "line",
|
|
255
|
+
label: "Line",
|
|
256
|
+
...common(box),
|
|
257
|
+
paint: { stroke: DRAW_STROKE },
|
|
258
|
+
line: { from: box.from, to: box.to },
|
|
259
|
+
}),
|
|
260
|
+
arrow: (box) => ({
|
|
261
|
+
kind: "arrow",
|
|
262
|
+
label: "Arrow",
|
|
263
|
+
...common(box),
|
|
264
|
+
paint: { stroke: DRAW_STROKE },
|
|
265
|
+
arrow: { from: box.from, to: box.to, head: ARROW_HEAD },
|
|
266
|
+
}),
|
|
267
|
+
path: (box, points) => ({
|
|
268
|
+
kind: "path",
|
|
269
|
+
label: "Path",
|
|
270
|
+
...common(box),
|
|
271
|
+
paint: { stroke: DRAW_STROKE },
|
|
272
|
+
// BOX-RELATIVE, with the viewBox set to the box's own size, so the two
|
|
273
|
+
// scales the rasterizer computes (`w / vw`, `h / vh`) are 1 until the
|
|
274
|
+
// author resizes the layer — and then the artwork stretches with the box,
|
|
275
|
+
// which is what a path layer's box means.
|
|
276
|
+
path: { d: polyline(points, box.min), viewBox: [box.size[0], box.size[1]] },
|
|
277
|
+
}),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** The fields every drawn layer fills in the same way. */
|
|
281
|
+
function common(box: DrawBox): { transform: Layer3D["transform"]; blocks: [] } {
|
|
282
|
+
return {
|
|
283
|
+
transform: {
|
|
284
|
+
position: [box.position[0], box.position[1], box.position[2]],
|
|
285
|
+
size: [box.size[0], box.size[1]],
|
|
286
|
+
tilt: [0, 0],
|
|
287
|
+
rollDeg: 0,
|
|
288
|
+
opacity: 1,
|
|
289
|
+
},
|
|
290
|
+
blocks: [],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** `M`/`L` over the points, relative to the box's top-left corner. Rounded, so
|
|
295
|
+
* a document written twice from the same drag is byte-identical. */
|
|
296
|
+
function polyline(points: readonly DrawPoint[], min: Vec2): string {
|
|
297
|
+
const n = (v: number): string => String(Math.round(v * 100) / 100);
|
|
298
|
+
return points.map((p, i) => `${i === 0 ? "M" : "L"} ${n(p[0] - min[0])} ${n(p[1] - min[1])}`).join(" ");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The layer a draw gesture asks for, or null when the points cannot make one.
|
|
303
|
+
*
|
|
304
|
+
* Null is the honest answer for a cancelled pen (no points), a pen with a
|
|
305
|
+
* single point (a path needs a segment) and an image whose upload has not
|
|
306
|
+
* landed — refusing to insert beats inserting something the author then has to
|
|
307
|
+
* find and delete.
|
|
308
|
+
*/
|
|
309
|
+
export function layerFromDraw(
|
|
310
|
+
tool: InsertTool,
|
|
311
|
+
points: readonly DrawPoint[],
|
|
312
|
+
ctx: InsertContext,
|
|
313
|
+
): NewLayer3D | null {
|
|
314
|
+
// The pen's own minimum. Every other tool takes two points and a click is
|
|
315
|
+
// already two identical ones, so this is the only arity worth stating.
|
|
316
|
+
if (tool === "path" && dedupe(points).length < 2) return null;
|
|
317
|
+
const cleaned = tool === "path" ? dedupe(points) : points;
|
|
318
|
+
const box = boxOf(cleaned);
|
|
319
|
+
if (box === null) return null;
|
|
320
|
+
const layer = INSERT[tool](box, cleaned, ctx);
|
|
321
|
+
return layer === null ? null : withLifetime(layer, ctx);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The layer the PEN commits: anchors, handles, and the printed `d` that must
|
|
326
|
+
* agree with them.
|
|
327
|
+
*
|
|
328
|
+
* Separate from `layerFromDraw` because the pen no longer draws a polyline.
|
|
329
|
+
* `INSERT.path` stays for the callers that still hand over bare points (the
|
|
330
|
+
* bridge's `onDraw` lane), and this is the one that carries curves.
|
|
331
|
+
*
|
|
332
|
+
* THE ORDER MATTERS. Bounds first, INCLUDING the handles — `pathBounds` takes
|
|
333
|
+
* control points precisely so a curve that bulges past its anchors is not
|
|
334
|
+
* clipped by its own box. Then translate into box-local coordinates, so the
|
|
335
|
+
* viewBox starts at 0,0 like every other path layer's. Then print, LAST, from
|
|
336
|
+
* the translated model: printing before the translate would bake the old
|
|
337
|
+
* origin into `d` and break `pathToD(points) === d` the moment the document was
|
|
338
|
+
* opened.
|
|
339
|
+
*/
|
|
340
|
+
export function penLayer(data: PathData, ctx: InsertContext): NewLayer3D | null {
|
|
341
|
+
if (data.points.length < 2) return null;
|
|
342
|
+
const bounds = pathBounds(data);
|
|
343
|
+
const box = boxOf([
|
|
344
|
+
[bounds.x, bounds.y],
|
|
345
|
+
[bounds.x + bounds.w, bounds.y + bounds.h],
|
|
346
|
+
]);
|
|
347
|
+
if (box === null) return null;
|
|
348
|
+
const local = translatePath(data, -bounds.x, -bounds.y);
|
|
349
|
+
return withLifetime(
|
|
350
|
+
{
|
|
351
|
+
kind: "path",
|
|
352
|
+
label: "Path",
|
|
353
|
+
...common(box),
|
|
354
|
+
// A closed path still gets no default fill: the pen's stroke is what the
|
|
355
|
+
// author was drawing, and a filled shape appearing on close would be a
|
|
356
|
+
// second edit they did not make. The inspector adds one in a click.
|
|
357
|
+
paint: { stroke: DRAW_STROKE },
|
|
358
|
+
path: { d: pathToD(local), viewBox: [box.size[0], box.size[1]], points: local },
|
|
359
|
+
},
|
|
360
|
+
ctx,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** A model layer, from the ADD menu. */
|
|
365
|
+
export function meshLayer(asset: string, ctx: InsertContext): NewLayer3D {
|
|
366
|
+
return withLifetime(
|
|
367
|
+
{
|
|
368
|
+
kind: "mesh",
|
|
369
|
+
label: "Model",
|
|
370
|
+
transform: centred(DEFAULT_MESH_SIZE),
|
|
371
|
+
// Inert on a mesh — the look is `mesh.shading`, not a Canvas2D fill.
|
|
372
|
+
paint: {},
|
|
373
|
+
blocks: [],
|
|
374
|
+
mesh: { asset },
|
|
375
|
+
},
|
|
376
|
+
ctx,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A device slab, from the ADD menu. */
|
|
381
|
+
export function deviceLayer(preset: string, ctx: InsertContext): NewLayer3D {
|
|
382
|
+
return withLifetime(
|
|
383
|
+
{
|
|
384
|
+
kind: "device",
|
|
385
|
+
label: preset.charAt(0).toUpperCase() + preset.slice(1),
|
|
386
|
+
transform: centred(DEFAULT_DEVICE_SIZE),
|
|
387
|
+
paint: {},
|
|
388
|
+
blocks: [],
|
|
389
|
+
device: { preset, children: [] },
|
|
390
|
+
},
|
|
391
|
+
ctx,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* An html snippet, from the ADD menu.
|
|
397
|
+
*
|
|
398
|
+
* The default RENDERS, which is the rule every factory here follows and the one
|
|
399
|
+
* that matters most for this kind: a snippet that starts empty draws an empty
|
|
400
|
+
* plane, and an author has no way to tell that apart from a snippet that failed
|
|
401
|
+
* to parse. Starting with something on screen makes the difference visible.
|
|
402
|
+
*
|
|
403
|
+
* XHTML from the first character — void tags self-closed, CSS in `css` — so the
|
|
404
|
+
* thing an author edits is already shaped like the thing that has to be saved.
|
|
405
|
+
*/
|
|
406
|
+
export function htmlLayer(ctx: InsertContext): NewLayer3D {
|
|
407
|
+
return withLifetime(
|
|
408
|
+
{
|
|
409
|
+
kind: "html",
|
|
410
|
+
label: "Html",
|
|
411
|
+
transform: centred(DEFAULT_HTML_SIZE),
|
|
412
|
+
paint: {},
|
|
413
|
+
blocks: [],
|
|
414
|
+
html: {
|
|
415
|
+
markup: `<div class="root"><span>Html</span></div>`,
|
|
416
|
+
css:
|
|
417
|
+
".root{width:100%;height:100%;display:flex;align-items:center;justify-content:center;" +
|
|
418
|
+
"font:600 64px Inter,system-ui,sans-serif;color:#fff}",
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
ctx,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* A contracted component, from the ADD menu.
|
|
427
|
+
*
|
|
428
|
+
* The BINDING is recorded here and only here: the component id, the contract
|
|
429
|
+
* hash it was bound at, and the contract itself when the registry says a
|
|
430
|
+
* generated file could not import it. That is what makes a later source edit
|
|
431
|
+
* show up as DRIFT the author resolves, instead of silently re-typing props
|
|
432
|
+
* authored against the old shape. `props` starts EMPTY — every unset key takes
|
|
433
|
+
* the contract's own default through `buildProps`, and baking defaults in at
|
|
434
|
+
* insert time would freeze them against later edits to the component itself.
|
|
435
|
+
*/
|
|
436
|
+
export function instanceLayer(
|
|
437
|
+
component: string,
|
|
438
|
+
label: string,
|
|
439
|
+
binding: { contractHash?: string; contract?: InstanceContractRef },
|
|
440
|
+
ctx: InsertContext,
|
|
441
|
+
): NewLayer3D {
|
|
442
|
+
return withLifetime(
|
|
443
|
+
{
|
|
444
|
+
kind: "instance",
|
|
445
|
+
label,
|
|
446
|
+
transform: centred(DEFAULT_INSTANCE_SIZE),
|
|
447
|
+
// Inert on an instance, as on a mesh: the component paints itself.
|
|
448
|
+
paint: {},
|
|
449
|
+
blocks: [],
|
|
450
|
+
instance: { component, props: {}, ...binding },
|
|
451
|
+
},
|
|
452
|
+
ctx,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function centred(size: Vec2): Layer3D["transform"] {
|
|
457
|
+
return { position: [0, 0, 0], size: [size[0], size[1]], tilt: [0, 0], rollDeg: 0, opacity: 1 };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* NEW LAYERS START AT THE PLAYHEAD, which for a layer means its LIFETIME.
|
|
462
|
+
*
|
|
463
|
+
* The same rule After Effects has, and the one every other create in this
|
|
464
|
+
* chrome already follows. A layer drawn at frame 40 that silently existed from
|
|
465
|
+
* frame 0 would be a layer the author has to go and re-time by hand every time.
|
|
466
|
+
*
|
|
467
|
+
* At frame 0 the lifetime is left ABSENT rather than written as {0, duration}.
|
|
468
|
+
* Absent already means "the whole scene", and it keeps meaning that when the
|
|
469
|
+
* scene is later trimmed — an explicit pair would have to be dragged to follow.
|
|
470
|
+
* Same placement, one fewer thing to maintain.
|
|
471
|
+
*/
|
|
472
|
+
function withLifetime(layer: NewLayer3D, ctx: InsertContext): NewLayer3D {
|
|
473
|
+
const start = startFrameOf(ctx);
|
|
474
|
+
const end = Math.trunc(ctx.durationFrames);
|
|
475
|
+
if (start <= 0 || end <= start) return layer;
|
|
476
|
+
return { ...layer, lifetime: { in: start, out: end } } as NewLayer3D;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The scene frame a new layer lands on. ONE function because TWO things read
|
|
480
|
+
* it — the lifetime's `in` above and a dropped video's `offsetFrames` — and a
|
|
481
|
+
* clip whose source clock started at a different frame from its own lifetime
|
|
482
|
+
* is a clip that opens part-way through. */
|
|
483
|
+
function startFrameOf(ctx: InsertContext): number {
|
|
484
|
+
return Math.max(0, Math.trunc(ctx.sceneFrame));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Consecutive duplicates dropped. The pen's double-click commit lands its
|
|
488
|
+
* second press on the same point as the first, so without this every
|
|
489
|
+
* double-click would leave a zero-length segment in the `d`. */
|
|
490
|
+
function dedupe(points: readonly DrawPoint[]): readonly DrawPoint[] {
|
|
491
|
+
return points.filter((p, i) => {
|
|
492
|
+
const prev = points[i - 1];
|
|
493
|
+
return prev === undefined || prev[0] !== p[0] || prev[1] !== p[1];
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Is this layer kind in the schema's vocabulary at runtime?
|
|
499
|
+
*
|
|
500
|
+
* WIDENED TO STRING on purpose. `LAYER_KINDS.includes("mesh")` against the
|
|
501
|
+
* literal tuple is a TYPE error the day the kind is removed, which is a build
|
|
502
|
+
* nobody can ship — so the menu could not degrade, it could only fail to
|
|
503
|
+
* compile. This is what lets the ADD menu drop an entry instead.
|
|
504
|
+
*/
|
|
505
|
+
export function hasLayerKind(kind: string): boolean {
|
|
506
|
+
return (LAYER_KINDS as readonly string[]).includes(kind);
|
|
507
|
+
}
|