@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,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
+ }