cozyclay 1.2.0 → 1.5.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 (113) hide show
  1. package/CHANGELOG.md +193 -0
  2. package/LICENSE +67 -80
  3. package/LICENSES/GPL-3.0-or-later.txt +674 -0
  4. package/LICENSING.md +32 -0
  5. package/README.md +48 -6
  6. package/THIRD_PARTY_NOTICES.md +47 -1
  7. package/bin/cozyclay.mjs +120 -50
  8. package/bin/mcp-runtime.mjs +115 -0
  9. package/bin/open-browser.mjs +10 -0
  10. package/bin/update-check.mjs +158 -0
  11. package/dist/ai-camera-control/index.html +406 -0
  12. package/dist/app/index.html +5 -5
  13. package/dist/ardy/assembled/run-jump-15s.npz +0 -0
  14. package/dist/assets/app-CqOk49Bk.css +1 -0
  15. package/dist/assets/app-zu633VCI.js +4811 -0
  16. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  17. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  18. package/dist/fonts/Inter-OFL.txt +92 -0
  19. package/dist/fonts/README.md +15 -0
  20. package/dist/index.html +62 -17
  21. package/dist/sitemap.xml +7 -1
  22. package/mcp/LIVE-PROTOCOL.md +144 -0
  23. package/mcp/README.md +160 -0
  24. package/mcp/ardy-prompts.mjs +188 -0
  25. package/mcp/live-hub.mjs +319 -0
  26. package/mcp/package.json +27 -0
  27. package/mcp/runtime/package-lock.json +1211 -0
  28. package/mcp/runtime/package.json +16 -0
  29. package/mcp/server.mjs +2008 -0
  30. package/package.json +142 -90
  31. package/src/App.jsx +5475 -1028
  32. package/src/ardy/cskel27.js +7 -2
  33. package/src/ardy/ik.js +25 -15
  34. package/src/ardy/motion-edit.js +172 -0
  35. package/src/ardy/npz.js +64 -3
  36. package/src/ardy/playback.js +31 -1
  37. package/src/ardy/pose-pin.js +97 -0
  38. package/src/ardy/prompt-clips.js +7 -2
  39. package/src/ardy/retime.js +211 -0
  40. package/src/ardy/root-drop.js +180 -0
  41. package/src/ardy/timeline-coordinates.js +13 -0
  42. package/src/ardy/timeline-resize.js +3 -1
  43. package/src/ardy/timeline.jsx +471 -63
  44. package/src/ardy/to-cskel27.js +34 -12
  45. package/src/ardy/trim.js +33 -0
  46. package/src/ardy/waypoints.js +4 -1
  47. package/src/asset-pane.jsx +354 -0
  48. package/src/asset-shelf.js +82 -0
  49. package/src/camera-block.js +42 -0
  50. package/src/camera-follow.js +149 -27
  51. package/src/camera-move.js +45 -19
  52. package/src/camera-rail-schedule.js +6 -0
  53. package/src/cuts.js +67 -54
  54. package/src/dualview.jsx +58 -12
  55. package/src/generation/generation-request.js +17 -0
  56. package/src/generation/use-generation.js +3 -14
  57. package/src/hierarchy-model.js +100 -16
  58. package/src/hierarchy-panel.jsx +139 -6
  59. package/src/live-control.js +142 -0
  60. package/src/matte-editor.js +543 -0
  61. package/src/matte.js +503 -0
  62. package/src/mp4-muxer.js +52 -0
  63. package/src/multimodel-ingest.js +344 -0
  64. package/src/object-gizmo.jsx +248 -25
  65. package/src/offscreen-export.js +166 -0
  66. package/src/otio.js +217 -0
  67. package/src/planview.jsx +57 -38
  68. package/src/pose-extract/detector.js +80 -0
  69. package/src/pose-extract/image-frame.js +40 -0
  70. package/src/pose-extract/index.js +4 -0
  71. package/src/pose-extract/take.js +114 -0
  72. package/src/pose-extract/video-frames.js +91 -0
  73. package/src/pose-thumbs.js +152 -0
  74. package/src/poses.js +19 -265
  75. package/src/posestudio.jsx +441 -17
  76. package/src/project-browser.jsx +135 -0
  77. package/src/project-poses.js +9 -0
  78. package/src/project.js +373 -0
  79. package/src/props.jsx +69 -3
  80. package/src/room.jsx +89 -37
  81. package/src/sample-at.js +136 -0
  82. package/src/scene-asset-cache.js +166 -0
  83. package/src/scene-assets.js +378 -0
  84. package/src/scene-objects.js +308 -7
  85. package/src/scenes.js +235 -26
  86. package/src/shot-authoring.js +120 -41
  87. package/src/shot.js +53 -13
  88. package/src/source-offer.jsx +12 -0
  89. package/src/stable-items.js +83 -0
  90. package/src/styles.css +2283 -349
  91. package/src/ui.jsx +32 -4
  92. package/src/usd-camera.js +145 -0
  93. package/tools/ardy/BRIDGE.md +11 -7
  94. package/tools/ardy/README.md +24 -5
  95. package/tools/ardy/artifacts.mjs +34 -0
  96. package/tools/ardy/bridge.mjs +89 -12
  97. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  98. package/tools/ardy/cclay_constrained_generate.py +123 -11
  99. package/tools/ardy/cclay_sequence_generate.py +49 -0
  100. package/tools/ardy/extract.mjs +372 -0
  101. package/tools/ardy/footage.mjs +467 -0
  102. package/tools/ardy/npz.mjs +74 -9
  103. package/tools/ardy/run-on-box.sh +25 -0
  104. package/tools/ardy/run-sequence-on-box.sh +15 -0
  105. package/tools/ardy/runners/local.mjs +4 -1
  106. package/tools/ardy/runners/remote.mjs +8 -2
  107. package/tools/ardy/setup-text-encoder.py +14 -4
  108. package/tools/dev-full.mjs +63 -5
  109. package/tools/generation/bridge.mjs +14 -1
  110. package/tools/process-supervisor.mjs +97 -1
  111. package/tools/run-tests.mjs +169 -0
  112. package/dist/assets/app-Cgpk2hwX.js +0 -4803
  113. package/dist/assets/app-DgZvaAE1.css +0 -1
@@ -31,9 +31,14 @@ export const SCENE_VERSION = 1;
31
31
  const EULER_ORDER = "XYZ";
32
32
  const DEG = Math.PI / 180;
33
33
 
34
- /** Room half-extent; matches the plan board's ROOM_LIMIT. */
35
- const ROOM_LIMIT = 11;
36
- const CEILING = 6;
34
+ /** Stage half-extent; matches the plan board's ROOM_LIMIT. The set is an
35
+ * open 500 m deck now, so the clamp is a guard against runaway coordinates,
36
+ * not a wall — it stops just inside the floor's edge. */
37
+ const ROOM_LIMIT = 240;
38
+ // Headroom, not a ceiling: the walls (and the 6.2 m room they implied) are
39
+ // gone, so this only stops a runaway coordinate. A rocket, a crane or a
40
+ // skyline piece all have to fit under it.
41
+ const CEILING = 240;
37
42
  const SCALE_MIN = 0.1;
38
43
  const SCALE_MAX = 100;
39
44
 
@@ -72,10 +77,71 @@ export const OBJECT_LIBRARY = [
72
77
  * inspector. */
73
78
  export const OBJECT_COLORS = ["#e2e5e6", GREY_BOX, "#9aa1a5", "#767d81", "#d9b18c", "#8fae9b"];
74
79
 
80
+ /**
81
+ * A cutout is a standee: an imported image standing on a card. Its size is NOT
82
+ * library data — the height is measured by the user and the width follows the
83
+ * picture's own aspect — so the record carries `assetId`, `aspect` and
84
+ * `height`, and the footprint is DERIVED from them. Deriving rather than
85
+ * storing is what stops a card persisting a width its picture disagrees with.
86
+ */
87
+ export const CUTOUT_KIND = "cutout";
88
+ /** Card thickness in metres: thin enough to read as flat, thick enough that
89
+ * the plan board and dropToSurfacePatch still have a rectangle to work on. */
90
+ export const CUTOUT_THICKNESS = 0.02;
91
+ /** A fresh cutout stands as tall as the figure it blocks against
92
+ * (SUBJECT_HEIGHT_M), so the first thing you see is honest scale. */
93
+ export const CUTOUT_DEFAULT_HEIGHT = 1.8;
94
+ const CUTOUT_HEIGHT_MIN = 0.05;
95
+ const CUTOUT_ASPECT_MIN = 0.02;
96
+ const CUTOUT_ASPECT_MAX = 50;
97
+ /** Cutouts carry their own colour: unlike a primitive, the card IS the thing
98
+ * it depicts, so it is tinted white and multiplies the image untouched. */
99
+ const CUTOUT_TINT = "#ffffff";
100
+ const CUTOUT_ENTRY = {
101
+ kind: CUTOUT_KIND,
102
+ label: "Cutout",
103
+ group: "Images",
104
+ footprint: { width: 1, depth: CUTOUT_THICKNESS },
105
+ height: CUTOUT_DEFAULT_HEIGHT,
106
+ color: CUTOUT_TINT,
107
+ };
108
+
109
+ /** Every kind that can exist in a scene: the catalogue you can create from,
110
+ * plus the kinds that arrive by import and so are deliberately absent from the
111
+ * "Add object" menu (a cutout without an image has nothing to draw). */
75
112
  function objectLibraryEntry(kind) {
113
+ if (kind === CUTOUT_KIND) return CUTOUT_ENTRY;
76
114
  return OBJECT_LIBRARY.find((entry) => entry.kind === kind) ?? null;
77
115
  }
78
116
 
117
+ const cutoutHeight = (value) => Math.max(CUTOUT_HEIGHT_MIN, value);
118
+ const cutoutAspect = (value) => clamp(value, CUTOUT_ASPECT_MIN, CUTOUT_ASPECT_MAX);
119
+
120
+ /** How far a card may be pulled off the picture's own proportions. */
121
+ export const CUTOUT_STRETCH_MIN = 0.1;
122
+ export const CUTOUT_STRETCH_MAX = 10;
123
+ export const cutoutStretch = (value) => {
124
+ const n = Number(value);
125
+ return Number.isFinite(n) ? clamp(n, CUTOUT_STRETCH_MIN, CUTOUT_STRETCH_MAX) : 1;
126
+ };
127
+
128
+ /**
129
+ * The plan-board rectangle a card of this height and picture aspect occupies.
130
+ * `aspect` is the image's width / height.
131
+ *
132
+ * `stretch` is how far the card has been pulled off those proportions: 1 is the
133
+ * picture undistorted. It is kept as its own factor rather than folded into
134
+ * `aspect` so the photograph's true proportions survive every edit — a re-cut
135
+ * recomputes `aspect` from the trimmed image, and a card that had been widened
136
+ * would otherwise silently lose or compound that widening.
137
+ */
138
+ export function cutoutFootprint(height, aspect, stretch = 1) {
139
+ return {
140
+ width: cutoutHeight(height) * cutoutAspect(aspect) * cutoutStretch(stretch),
141
+ depth: CUTOUT_THICKNESS,
142
+ };
143
+ }
144
+
79
145
  export function sceneObjectHierarchyId(id) {
80
146
  return `object:${id}`;
81
147
  }
@@ -90,6 +156,9 @@ export function sceneObjectIdFromHierarchy(hierarchyId) {
90
156
  * camera); everything else starts neutral so the first drag is predictable.
91
157
  */
92
158
  export function createSceneObject(kind, existing = [], placement = {}) {
159
+ // Cutouts come from an import, never from the catalogue: without an asset
160
+ // id the record has nothing to draw. `createCutoutObject` is their door.
161
+ if (kind === CUTOUT_KIND) return null;
93
162
  const entry = objectLibraryEntry(kind);
94
163
  if (!entry) return null;
95
164
  const names = new Set(existing.map((object) => object.name));
@@ -112,11 +181,93 @@ export function createSceneObject(kind, existing = [], placement = {}) {
112
181
  scaleY: 1,
113
182
  scaleZ: 1,
114
183
  color: entry.color,
184
+ parent: null,
115
185
  footprint: { ...entry.footprint },
116
186
  height: entry.height,
117
187
  };
118
188
  }
119
189
 
190
+ /**
191
+ * A fresh cutout for an imported image. `assetId` addresses the picture in the
192
+ * asset store — the record never carries the bytes, which is what keeps a
193
+ * scene small enough to live in localStorage — and `aspect` is the image's
194
+ * width / height so the card can be sized by height alone.
195
+ *
196
+ * `name` seeds the display name (the file's own name is the obvious caller
197
+ * choice); everything else starts neutral, exactly like a catalogue object.
198
+ */
199
+ export function createCutoutObject({ assetId, aspect = 1, height = CUTOUT_DEFAULT_HEIGHT, name = "", sourceAssetId, matteAssetId, matteScale, stretch } = {}, existing = [], placement = {}) {
200
+ if (typeof assetId !== "string" || !assetId) return null;
201
+ const pictureAspect = cutoutAspect(Number(aspect));
202
+ const cardHeight = cutoutHeight(Number(height));
203
+ if (!Number.isFinite(pictureAspect) || !Number.isFinite(cardHeight)) return null;
204
+ // A duplicate hands the lineage in so the copy stays re-editable: the
205
+ // picture it renders, the photograph it came from, the selection mask,
206
+ // the trim factor and the stretch. A fresh import passes none, so the
207
+ // defaults below leave it as its own unmasked original — exactly the
208
+ // record an untouched card has always carried.
209
+ const cardStretch = cutoutStretch(stretch);
210
+ const base = typeof name === "string" && name.trim() ? name.trim() : CUTOUT_ENTRY.label;
211
+ const names = new Set(existing.map((object) => object.name));
212
+ let displayName = base;
213
+ for (let n = 2; names.has(displayName); n += 1) displayName = `${base} ${n}`;
214
+ const ids = new Set(existing.map((object) => object.id));
215
+ let id = CUTOUT_KIND;
216
+ for (let n = 2; ids.has(id); n += 1) id = `${CUTOUT_KIND}-${n}`;
217
+ return {
218
+ id,
219
+ name: displayName,
220
+ renderer: CUTOUT_KIND,
221
+ x: clamp(Number(placement.x) || 0, -ROOM_LIMIT, ROOM_LIMIT),
222
+ y: 0,
223
+ z: clamp(Number(placement.z) || 0, -ROOM_LIMIT, ROOM_LIMIT),
224
+ rot: wrapAngle(Number(placement.rot) || 0),
225
+ rotX: 0,
226
+ rotZ: 0,
227
+ scaleX: 1,
228
+ scaleY: 1,
229
+ scaleZ: 1,
230
+ color: CUTOUT_TINT,
231
+ parent: null,
232
+ // Key order matches what `normalizeSceneObject` writes, so a record
233
+ // survives a storage round trip byte-for-byte.
234
+ assetId,
235
+ // A picture nobody has cut is its own original, with no purple on it;
236
+ // a duplicate passes the lineage through so the copy stays re-editable.
237
+ sourceAssetId: typeof sourceAssetId === "string" && sourceAssetId ? sourceAssetId : assetId,
238
+ matteAssetId: typeof matteAssetId === "string" ? matteAssetId : "",
239
+ matteScale: Number.isFinite(Number(matteScale)) ? Number(matteScale) : 1,
240
+ aspect: pictureAspect,
241
+ // A new card wears the picture's own proportions; a widened one carries
242
+ // its factor through so the copy keeps the shape it was pulled to.
243
+ stretch: cardStretch,
244
+ footprint: cutoutFootprint(cardHeight, pictureAspect, cardStretch),
245
+ height: cardHeight,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The option bundle a cutout duplicate hands to `createCutoutObject`. A copy
251
+ * is minted through the same door an import is, so it must carry the picture
252
+ * it renders AND the photograph it came from, the selection mask, the trim
253
+ * factor and the stretch — otherwise the matte is silently reset and the
254
+ * duplicate is no longer re-editable. Kept as its own pure helper so the
255
+ * duplicate path and its test speak the very same option-building, not a
256
+ * hand-rolled copy.
257
+ */
258
+ export function duplicateCutoutOptions(object) {
259
+ return {
260
+ assetId: object.assetId,
261
+ aspect: object.aspect,
262
+ height: object.height,
263
+ name: object.name,
264
+ sourceAssetId: object.sourceAssetId,
265
+ matteAssetId: object.matteAssetId,
266
+ matteScale: object.matteScale,
267
+ stretch: object.stretch,
268
+ };
269
+ }
270
+
120
271
  /** Every writable transform channel and the rule that keeps it in the room. */
121
272
  const TRANSFORM_LIMITS = {
122
273
  x: (value) => clamp(value, -ROOM_LIMIT, ROOM_LIMIT),
@@ -130,10 +281,60 @@ const TRANSFORM_LIMITS = {
130
281
  scaleZ: (value) => clamp(value, SCALE_MIN, SCALE_MAX),
131
282
  };
132
283
 
284
+ /** Every object that hangs off `id`, at any depth. A cycle cannot form because
285
+ * setParent refuses one, but the seen-set keeps this total even if data is
286
+ * hand-edited into a loop. */
287
+ export function descendantsOf(objects, id) {
288
+ const out = [];
289
+ const seen = new Set([id]);
290
+ let frontier = [id];
291
+ while (frontier.length) {
292
+ const next = [];
293
+ for (const object of objects) {
294
+ if (object.parent && frontier.includes(object.parent) && !seen.has(object.id)) {
295
+ seen.add(object.id);
296
+ out.push(object);
297
+ next.push(object.id);
298
+ }
299
+ }
300
+ frontier = next;
301
+ }
302
+ return out;
303
+ }
304
+
133
305
  export function updateSceneObject(objects, id, patch) {
134
306
  let changed = false;
307
+ const target = objects.find((object) => object.id === id);
308
+ // A parent carries its children: the group is dragged, nudged and dropped as
309
+ // one body. Only translation rides along — rotating or scaling a group would
310
+ // have to orbit and rescale every child about the parent's origin, which is a
311
+ // different feature and is deliberately not pretended at here.
312
+ const carried = target ? descendantsOf(objects, id) : [];
313
+ const delta = { x: 0, y: 0, z: 0 };
314
+ if (target) {
315
+ for (const axis of ["x", "y", "z"]) {
316
+ if (patch[axis] === undefined) continue;
317
+ const value = Number(patch[axis]);
318
+ if (!Number.isFinite(value)) continue;
319
+ delta[axis] = TRANSFORM_LIMITS[axis](value) - target[axis];
320
+ }
321
+ }
322
+ const moving = new Set(carried.map((object) => object.id));
323
+ const shifts = delta.x || delta.y || delta.z;
324
+
135
325
  const next = objects.map((object) => {
136
- if (object.id !== id) return object;
326
+ if (object.id !== id) {
327
+ if (!shifts || !moving.has(object.id)) return object;
328
+ const update = {};
329
+ for (const axis of ["x", "y", "z"]) {
330
+ if (!delta[axis]) continue;
331
+ const bounded = TRANSFORM_LIMITS[axis](object[axis] + delta[axis]);
332
+ if (bounded !== object[axis]) update[axis] = bounded;
333
+ }
334
+ if (!Object.keys(update).length) return object;
335
+ changed = true;
336
+ return { ...object, ...update };
337
+ }
137
338
  const update = {};
138
339
  for (const [key, limit] of Object.entries(TRANSFORM_LIMITS)) {
139
340
  if (patch[key] === undefined) continue;
@@ -147,6 +348,51 @@ export function updateSceneObject(objects, id, patch) {
147
348
  if (typeof patch[key] !== "string" || !patch[key] || patch[key] === object[key]) continue;
148
349
  update[key] = patch[key];
149
350
  }
351
+ // A cutout is sized in metres — you measure something in the picture and
352
+ // type its height — so `height` and `aspect` are writable where every
353
+ // other kind takes them from the library. The footprint is DERIVED here
354
+ // and never patched: one owner for the card's width is what keeps it
355
+ // from disagreeing with its own image.
356
+ if (object.renderer === CUTOUT_KIND) {
357
+ // The picture itself is writable: cutting the background out stores a
358
+ // NEW asset (different bytes, different id) and points the card at it,
359
+ // which is what makes the cut undoable — the original stays addressed
360
+ // by the history entry before it.
361
+ // Three ids, because a cut card is not one picture: `assetId` is what
362
+ // the set renders, `sourceAssetId` is the photograph it came from and
363
+ // keeps being edited from, and `matteAssetId` is the purple itself.
364
+ // Keeping all three is what makes the cut re-editable instead of
365
+ // destructive — the original is never replaced, only masked.
366
+ for (const key of ["assetId", "sourceAssetId", "matteAssetId"]) {
367
+ if (typeof patch[key] === "string" && patch[key] && patch[key] !== object[key]) update[key] = patch[key];
368
+ }
369
+ // How much of the original frame the trimmed card is. Stored so a
370
+ // second edit can work out the height the card would have at full
371
+ // frame instead of compounding one trim onto the last.
372
+ if (Number.isFinite(Number(patch.matteScale))) {
373
+ const scale = clamp(Number(patch.matteScale), 0.01, 1);
374
+ if (scale !== object.matteScale) update.matteScale = scale;
375
+ }
376
+ const patchedHeight = patch.height === undefined ? NaN : cutoutHeight(Number(patch.height));
377
+ const patchedAspect = patch.aspect === undefined ? NaN : cutoutAspect(Number(patch.aspect));
378
+ const height = Number.isFinite(patchedHeight) ? patchedHeight : object.height;
379
+ const aspect = Number.isFinite(patchedAspect) ? patchedAspect : object.aspect;
380
+ const previousStretch = cutoutStretch(object.stretch);
381
+ // A width in metres is what the inspector and the drag both speak, so it
382
+ // is accepted directly and kept as the factor the record stores.
383
+ const patchedStretch =
384
+ patch.width !== undefined && Number.isFinite(Number(patch.width))
385
+ ? cutoutStretch(Number(patch.width) / (height * aspect))
386
+ : patch.stretch === undefined
387
+ ? previousStretch
388
+ : cutoutStretch(patch.stretch);
389
+ if (height !== object.height || aspect !== object.aspect || patchedStretch !== previousStretch) {
390
+ update.height = height;
391
+ update.aspect = aspect;
392
+ update.stretch = patchedStretch;
393
+ update.footprint = cutoutFootprint(height, aspect, patchedStretch);
394
+ }
395
+ }
150
396
  if (!Object.keys(update).length) return object;
151
397
  changed = true;
152
398
  return { ...object, ...update };
@@ -154,9 +400,33 @@ export function updateSceneObject(objects, id, patch) {
154
400
  return changed ? next : objects;
155
401
  }
156
402
 
403
+ /**
404
+ * Attach `id` to `parentId` (or detach with null). Refuses the two shapes that
405
+ * would corrupt the tree: an object parented to itself, and a cycle formed by
406
+ * parenting an object to one of its own descendants.
407
+ */
408
+ export function setSceneObjectParent(objects, id, parentId) {
409
+ if (id === parentId) return objects;
410
+ if (parentId !== null && !objects.some((object) => object.id === parentId)) return objects;
411
+ if (parentId !== null && descendantsOf(objects, id).some((object) => object.id === parentId)) return objects;
412
+ let changed = false;
413
+ const next = objects.map((object) => {
414
+ if (object.id !== id) return object;
415
+ const parent = parentId ?? null;
416
+ if ((object.parent ?? null) === parent) return object;
417
+ changed = true;
418
+ return { ...object, parent };
419
+ });
420
+ return changed ? next : objects;
421
+ }
422
+
157
423
  export function removeSceneObject(objects, id) {
158
424
  const next = objects.filter((object) => object.id !== id);
159
- return next.length === objects.length ? objects : next;
425
+ if (next.length === objects.length) return objects;
426
+ // Deleting a parent must not leave its children pointing at a ghost: they
427
+ // are promoted to top level rather than vanishing with it, because a group
428
+ // is an editing convenience and never an owner of the parts.
429
+ return next.map((object) => (object.parent === id ? { ...object, parent: null } : object));
160
430
  }
161
431
  /* -------------------------------------------------- persistence ---- */
162
432
 
@@ -173,6 +443,11 @@ export function normalizeSceneObject(record) {
173
443
  const entry = objectLibraryEntry(record.renderer);
174
444
  if (!entry) return null;
175
445
  if (typeof record.id !== "string" || !record.id) return null;
446
+ // A cutout addresses its picture by id. A record without one has nothing to
447
+ // draw, so it is dropped rather than restored as a blank card — the same
448
+ // rule an unknown renderer already gets.
449
+ const isCutout = entry.kind === CUTOUT_KIND;
450
+ if (isCutout && (typeof record.assetId !== "string" || !record.assetId)) return null;
176
451
  // Defensive import fallback, not a migration: hand-authored or external
177
452
  // payloads may carry one `scale` (the pre-split record shape). It fans
178
453
  // out to all three axes only when no axis is present — an explicit
@@ -198,8 +473,34 @@ export function normalizeSceneObject(record) {
198
473
  scaleY: TRANSFORM_LIMITS.scaleY(pick(record.scaleY, scaleFallback)),
199
474
  scaleZ: TRANSFORM_LIMITS.scaleZ(pick(record.scaleZ, scaleFallback)),
200
475
  color: typeof record.color === "string" && record.color ? record.color : entry.color,
201
- footprint: { ...entry.footprint },
202
- height: entry.height,
476
+ // Group membership. null is a top-level object; the id of another object
477
+ // makes this one ride along when that object moves.
478
+ parent: typeof record.parent === "string" && record.parent ? record.parent : null,
479
+ // Library kinds take their size from the library — a stored footprint is
480
+ // stale data, not a fact. A cutout is the exception: its size IS
481
+ // per-instance, so height and aspect are repaired from the record and
482
+ // the footprint is rebuilt from the pair.
483
+ ...(isCutout
484
+ ? {
485
+ assetId: record.assetId,
486
+ // An older record (or a hand-authored one) has no source: the
487
+ // picture it points at IS the original, because nothing has
488
+ // been cut from it yet.
489
+ sourceAssetId: typeof record.sourceAssetId === "string" && record.sourceAssetId ? record.sourceAssetId : record.assetId,
490
+ matteAssetId: typeof record.matteAssetId === "string" ? record.matteAssetId : "",
491
+ matteScale: clamp(pick(record.matteScale, 1), 0.01, 1),
492
+ aspect: cutoutAspect(pick(record.aspect, 1)),
493
+ // A record written before cards could be stretched has none, and
494
+ // 1 is exactly what it meant: the picture's own proportions.
495
+ stretch: cutoutStretch(pick(record.stretch, 1)),
496
+ footprint: cutoutFootprint(
497
+ pick(record.height, CUTOUT_DEFAULT_HEIGHT),
498
+ pick(record.aspect, 1),
499
+ pick(record.stretch, 1),
500
+ ),
501
+ height: cutoutHeight(pick(record.height, CUTOUT_DEFAULT_HEIGHT)),
502
+ }
503
+ : { footprint: { ...entry.footprint }, height: entry.height }),
203
504
  };
204
505
  }
205
506