cozyclay 3.2.0 → 3.2.1

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 (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
package/src/project.js CHANGED
@@ -25,9 +25,12 @@ export const PROJECT_VERSION = 5;
25
25
  export const PREVIS_MODES = Object.freeze(["storyboard", "animation"]);
26
26
  export const DEFAULT_PREVIS_MODE = "animation";
27
27
 
28
- export function normalizePrevisMode(value) {
29
- return PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE;
30
- }
28
+ export const normalizePrevisMode = (value) => (PREVIS_MODES.includes(value) ? value : DEFAULT_PREVIS_MODE);
29
+ // Which bodies the project is cast with: rigged humanoids for detailed previs,
30
+ // capsule figures for simple previs, or a mix. A file without the field
31
+ // predates the choice and already holds a rig character, so it reads as hybrid.
32
+ export const CAST_STYLES = Object.freeze(["rig", "capsule", "hybrid"]), DEFAULT_CAST_STYLE = "hybrid";
33
+ export const normalizeCastStyle = (value) => (CAST_STYLES.includes(value) ? value : DEFAULT_CAST_STYLE);
31
34
  export const PROJECT_EXTENSION = ".cclayproject";
32
35
  export const WORKFLOW_VERSION = 1;
33
36
  export const WORKFLOW_STORAGE_KEY = "cozyclay.workflow.v1";
@@ -301,7 +304,7 @@ function readEmbeddedAssets(value, report) {
301
304
  * assetRef references them. Throws `resources-too-large` when the whole
302
305
  * manifest exceeds its budget.
303
306
  */
304
- export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, assets, savedAt, workflow }) {
307
+ export function createProjectDocument({ scenesDocument, workspaceLayout, customPoses, name, previsMode, castStyle, assets, savedAt, workflow }) {
305
308
  const assetRecords = new Map();
306
309
  for (const record of Array.isArray(assets) ? assets : []) {
307
310
  const asset = embeddedAsset(record);
@@ -323,7 +326,7 @@ export function createProjectDocument({ scenesDocument, workspaceLayout, customP
323
326
  kind: "project",
324
327
  version: PROJECT_VERSION,
325
328
  name: typeof name === "string" && name.trim() ? name.trim() : "Untitled",
326
- previsMode: normalizePrevisMode(previsMode),
329
+ previsMode: normalizePrevisMode(previsMode), castStyle: normalizeCastStyle(castStyle),
327
330
  ...(Number.isFinite(savedAt) && savedAt > 0 ? { savedAt } : {}),
328
331
  scenes: scenesDocument,
329
332
  workspace: workspaceLayout ?? null,
@@ -355,7 +358,7 @@ export function buildAnimationProjectFromStoryboard(project) {
355
358
  workspaceLayout: structuredClone(project?.workspaceLayout ?? null),
356
359
  customPoses: structuredClone(project?.customPoses ?? []),
357
360
  name: `${project?.name || "Untitled"} - ${ko("Animation", "애니메이션")}`,
358
- previsMode: "animation",
361
+ previsMode: "animation", castStyle: project?.castStyle,
359
362
  assets: project?.assets ?? [],
360
363
  workflow: structuredClone(project?.workflow ?? createWorkflowGraph()),
361
364
  savedAt: Date.now(),
@@ -367,8 +370,8 @@ export function buildAnimationProjectFromStoryboard(project) {
367
370
  * problems?, project? }. Invalid embedded records are skipped, each one
368
371
  * reported as a warning string and a `{ kind, id, code, message }` problem.
369
372
  * v2/v3 files carry images at the top-level `assets`; v4 moves everything
370
- * under `resources`; v5 adds the top-level `previsMode` (absent or unknown
371
- * reads as the default). Older files are read as-is, never rewritten.
373
+ * under `resources`; v5 adds the top-level `previsMode` and `castStyle`
374
+ * (absent or unknown reads as the default). Older files are read as-is, never rewritten.
372
375
  */
373
376
  export function readProjectDocument(raw) {
374
377
  let parsed;
@@ -402,7 +405,7 @@ export function readProjectDocument(raw) {
402
405
  problems,
403
406
  project: {
404
407
  name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : "Untitled",
405
- previsMode: normalizePrevisMode(parsed.previsMode),
408
+ previsMode: normalizePrevisMode(parsed.previsMode), castStyle: normalizeCastStyle(parsed.castStyle),
406
409
  savedAt: Number.isFinite(parsed.savedAt) && parsed.savedAt > 0 ? parsed.savedAt : null,
407
410
  scenesDocument: { version: scenes.version, activeSceneId: scenes.activeSceneId ?? null, scenes: scenes.scenes },
408
411
  workspaceLayout: parsed.workspace && typeof parsed.workspace === "object" ? parsed.workspace : null,
@@ -549,17 +552,18 @@ export function loadProjectSession(storage = globalThis.localStorage) {
549
552
  const value = JSON.parse(storage?.getItem(PROJECT_SESSION_KEY) || "null");
550
553
  if (!value || typeof value !== "object") return null;
551
554
  const name = typeof value.name === "string" ? value.name.trim() : "";
552
- return name ? { name, previsMode: normalizePrevisMode(value.previsMode), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
555
+ return name ? { name, previsMode: normalizePrevisMode(value.previsMode), castStyle: normalizeCastStyle(value.castStyle), updatedAt: Number.isFinite(value.updatedAt) ? value.updatedAt : 0 } : null;
553
556
  } catch {
554
557
  return null;
555
558
  }
556
559
  }
557
560
 
558
- export function storeProjectSession(name, previsMode = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
561
+ // `identity` is the previs mode, or `{ previsMode, castStyle }`.
562
+ export function storeProjectSession(name, identity = DEFAULT_PREVIS_MODE, storage = globalThis.localStorage) {
559
563
  const normalized = typeof name === "string" ? name.trim() : "";
560
564
  if (!normalized) return false;
561
565
  try {
562
- storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(previsMode), updatedAt: Date.now() }));
566
+ storage?.setItem(PROJECT_SESSION_KEY, JSON.stringify({ name: normalized, previsMode: normalizePrevisMode(identity?.previsMode ?? identity), castStyle: normalizeCastStyle(identity?.castStyle), updatedAt: Date.now() }));
563
567
  return true;
564
568
  } catch {
565
569
  return false;
@@ -0,0 +1,11 @@
1
+ // Capsule figure dimensions in metres, shared by the renderer
2
+ // (proxy-figure.jsx) and the rig-free bounds (character-kind.js).
3
+ export const PROXY_BODY_RADIUS = 0.22;
4
+ export const PROXY_BODY_HEIGHT = 1.45;
5
+ export const PROXY_HEAD_RADIUS = 0.12;
6
+ // Head centre sits this far above the top of the body or torso.
7
+ export const PROXY_HEAD_LIFT = 0.13;
8
+ // Sit is an L: a vertical torso at the hip and one leg capsule on the floor
9
+ // reaching this far forward of the hip, along the way the figure faces (+z).
10
+ export const PROXY_SIT_TORSO_HEIGHT = 0.85;
11
+ export const PROXY_SIT_LEG_REACH = 0.55;
@@ -1,10 +1,20 @@
1
1
  import { memo, useEffect, useMemo } from "react";
2
2
  import * as THREE from "three";
3
3
  import { defaultCharacterTint } from "./app-stage.jsx";
4
+ import {
5
+ PROXY_BODY_HEIGHT as BODY_HEIGHT, PROXY_BODY_RADIUS as BODY_RADIUS, PROXY_HEAD_LIFT,
6
+ PROXY_HEAD_RADIUS as HEAD_RADIUS, PROXY_SIT_LEG_REACH, PROXY_SIT_TORSO_HEIGHT,
7
+ } from "./proxy-figure-shape.js";
4
8
 
5
- const BODY_RADIUS = 0.22;
6
- const BODY_HEIGHT = 1.45;
7
- const HEAD_RADIUS = 0.12;
9
+ function addPart(group, geometry, material, position) {
10
+ const mesh = new THREE.Mesh(geometry, material);
11
+ mesh.position.set(...position);
12
+ mesh.castShadow = true;
13
+ mesh.receiveShadow = true;
14
+ mesh.frustumCulled = false;
15
+ group.add(mesh);
16
+ return mesh;
17
+ }
8
18
 
9
19
  export function proxyYaw(rot, posture = "stand") {
10
20
  return (rot * Math.PI) / 180 + (posture === "lie" ? Math.PI : 0);
@@ -19,8 +29,8 @@ export const ProxyFigure = memo(function ProxyFigure({
19
29
  pickId,
20
30
  selected,
21
31
  }) {
22
- const bodyHeight = posture === "sit" ? BODY_HEIGHT * 0.6 : BODY_HEIGHT;
23
- const headY = bodyHeight + 0.13;
32
+ const seated = posture === "sit";
33
+ const bodyHeight = seated ? PROXY_SIT_TORSO_HEIGHT : BODY_HEIGHT;
24
34
  const model = useMemo(() => {
25
35
  const bodyMaterial = new THREE.MeshStandardMaterial({
26
36
  color: tint ?? defaultCharacterTint({ model: "proxy-figure" }, 0),
@@ -29,20 +39,17 @@ export const ProxyFigure = memo(function ProxyFigure({
29
39
  envMapIntensity: 0.35,
30
40
  });
31
41
  const group = new THREE.Group();
32
- const body = new THREE.Mesh(new THREE.CapsuleGeometry(BODY_RADIUS, bodyHeight - BODY_RADIUS * 2, 12, 20), bodyMaterial);
33
- body.position.y = bodyHeight / 2;
34
- body.castShadow = true;
35
- body.receiveShadow = true;
36
- body.frustumCulled = false;
37
- group.add(body);
38
- const head = new THREE.Mesh(new THREE.SphereGeometry(HEAD_RADIUS, 16, 12), bodyMaterial);
39
- head.position.y = headY;
40
- head.castShadow = true;
41
- head.receiveShadow = true;
42
- head.frustumCulled = false;
43
- group.add(head);
42
+ addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, bodyHeight - BODY_RADIUS * 2, 12, 20), bodyMaterial, [0, bodyHeight / 2, 0]);
43
+ addPart(group, new THREE.SphereGeometry(HEAD_RADIUS, 16, 12), bodyMaterial, [0, bodyHeight + PROXY_HEAD_LIFT, 0]);
44
+ if (seated) {
45
+ // Legs on the floor from behind the hip to PROXY_SIT_LEG_REACH ahead of
46
+ // it, along local +z: the way the figure (and a rig at the same rot) faces.
47
+ const legs = addPart(group, new THREE.CapsuleGeometry(BODY_RADIUS, PROXY_SIT_LEG_REACH - BODY_RADIUS, 12, 20), bodyMaterial,
48
+ [0, BODY_RADIUS, (PROXY_SIT_LEG_REACH - BODY_RADIUS) / 2]);
49
+ legs.rotation.x = Math.PI / 2;
50
+ }
44
51
  return group;
45
- }, [bodyHeight, headY, tint]);
52
+ }, [bodyHeight, seated, tint]);
46
53
  useEffect(() => () => {
47
54
  const materials = new Set();
48
55
  model.traverse(node => {
@@ -19,6 +19,8 @@
19
19
  * adapter is the only part that needs a browser.
20
20
  */
21
21
  import { isHdrAssetId, normalizeHdrAsset } from "./stage-hdr.js";
22
+ // A `sculpt-` id is a sculpt card on the shelf: the user's own library, never swept.
23
+ import { isSculptAssetId, normalizeSculptAsset } from "./sculpt-shelf-file.js";
22
24
 
23
25
  /** Content-addressed, so the same bytes always land on the same id. */
24
26
  export const ASSET_ID_PREFIX = "img-";
@@ -86,7 +88,7 @@ export function isMeshAssetId(value) {
86
88
  }
87
89
 
88
90
  export function isAssetId(value) {
89
- return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value);
91
+ return isImageAssetId(value) || isMeshAssetId(value) || isHdrAssetId(value) || isSculptAssetId(value);
90
92
  }
91
93
 
92
94
  async function sha256Hex(bytes, subtle) {
@@ -152,6 +154,7 @@ export function normalizeAsset(record) {
152
154
  if (!bytes || !bytes.byteLength) return null;
153
155
  // An `hdr-` id is an HDR environment file (stage-hdr.js), stored verbatim.
154
156
  if (isHdrAssetId(record.id)) return normalizeHdrAsset(record, bytes);
157
+ if (isSculptAssetId(record.id)) return normalizeSculptAsset(record, bytes);
155
158
  // Prefix and MIME must agree: a mesh id with an image type (or the reverse)
156
159
  // is a cross-wired record and cannot be drawn as either kind.
157
160
  if (isMeshAssetId(record.id)) {
@@ -378,14 +381,23 @@ export async function deleteAssetWithGraphGuard({
378
381
  return false;
379
382
  }
380
383
 
381
- /** Stored ids that nothing points at any more, ready to be swept. */
384
+ // Stored ids that nothing points at any more, ready to be swept. A sculpt card
385
+ // is referenced by no scene on purpose: the shelf, not Manage storage, deletes it.
382
386
  export function unreachableAssetIds(storedIds, scenes) {
383
387
  const reachable = referencedAssetIds(scenes);
384
- return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !reachable.has(id));
388
+ return (Array.isArray(storedIds) ? storedIds : []).filter((id) => isAssetId(id) && !isSculptAssetId(id) && !reachable.has(id));
385
389
  }
386
390
 
387
391
  /* ------------------------------------------------------------ storage ---- */
388
392
 
393
+ // A readwrite transaction settles on complete, error or abort.
394
+ const committed = (tx) =>
395
+ new Promise((resolve, reject) => {
396
+ tx.oncomplete = resolve;
397
+ tx.onerror = () => reject(tx.error);
398
+ tx.onabort = () => reject(tx.error);
399
+ });
400
+
389
401
  const request = (req) =>
390
402
  new Promise((resolve, reject) => {
391
403
  req.onsuccess = () => resolve(req.result);
@@ -416,11 +428,7 @@ export async function putAsset(db, record) {
416
428
  if (!asset) throw new TypeError("putAsset needs a normalizable asset record");
417
429
  const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
418
430
  tx.objectStore(ASSET_STORE_NAME).put(asset);
419
- await new Promise((resolve, reject) => {
420
- tx.oncomplete = resolve;
421
- tx.onerror = () => reject(tx.error);
422
- tx.onabort = () => reject(tx.error);
423
- });
431
+ await committed(tx);
424
432
  return asset;
425
433
  }
426
434
 
@@ -440,10 +448,6 @@ export async function deleteAsset(db, id) {
440
448
  if (!isAssetId(id)) return false;
441
449
  const tx = db.transaction(ASSET_STORE_NAME, "readwrite");
442
450
  tx.objectStore(ASSET_STORE_NAME).delete(id);
443
- await new Promise((resolve, reject) => {
444
- tx.oncomplete = resolve;
445
- tx.onerror = () => reject(tx.error);
446
- tx.onabort = () => reject(tx.error);
447
- });
451
+ await committed(tx);
448
452
  return true;
449
453
  }
package/src/scenes.js CHANGED
@@ -52,10 +52,12 @@ export function isProxyFigure(character) {
52
52
  export const DEFAULT_SUBJECT_ONE = "a young woman in a tan coat";
53
53
  export const DEFAULT_SUBJECT_TWO = "a man in a dark coat";
54
54
 
55
+ // The one-character cast every scene used to open with. A new scene now
56
+ // starts on an empty stage; legacy documents that never stored a cast, and
57
+ // fixtures that want a body to work with, still seed this one.
58
+ const SEED_CHARACTER = Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE });
55
59
  const DEFAULT_SCENE_STAGE = Object.freeze({
56
- characters: Object.freeze([
57
- Object.freeze({ id: "char-a", model: DEFAULT_CHARACTER_MODEL, x: 0, z: 0, rot: 0, hidden: false, pose: null, subject: DEFAULT_SUBJECT_ONE }),
58
- ]),
60
+ characters: Object.freeze([]),
59
61
  hasCharSheet: false,
60
62
  // The look reference for the location: a data URL so it survives save/load
61
63
  environmentImage: null,
@@ -241,16 +243,17 @@ function migrateLegacyCast(source) {
241
243
  return cast;
242
244
  }
243
245
 
244
- const STAGE_ENVELOPE_KEYS = new Set([
246
+ const LEGACY_CAST_KEYS = Object.freeze(["charA", "charB", "showB", "poseA", "poseB", "subject", "subject2"]), STAGE_ENVELOPE_KEYS = new Set([
245
247
  "characters", "hasCharSheet", "environmentImage", "environment", "style", "hasEnvSheet", "shotAspect", "cameraPresetId", "sensorId", "keyLight", "lighting",
246
- "charA", "charB", "showB", "poseA", "poseB", "subject", "subject2",
248
+ ...LEGACY_CAST_KEYS,
247
249
  ]);
248
250
 
249
251
  export function createSceneStage(stage = null) {
250
252
  const source = plainObject(stage) ? stage : {};
251
- const characters = Array.isArray(source.characters) && source.characters.length
253
+ // With no cast list, a pre-v3 envelope folds its fixed cast in; anything else is an empty stage.
254
+ const characters = Array.isArray(source.characters)
252
255
  ? source.characters.map((entry, index) => createCharacterEntry(entry, index))
253
- : migrateLegacyCast(source);
256
+ : LEGACY_CAST_KEYS.some((key) => Object.hasOwn(source, key)) ? migrateLegacyCast(source) : [];
254
257
  // Unknown extra keys (shotAspect was one, once) ride along so the envelope
255
258
  // stays sealed-but-lossless; legacy cast keys are deliberately dropped.
256
259
  const extras = {};
@@ -359,8 +362,13 @@ export function activeScene(scenes, activeSceneId) {
359
362
  return index < 0 ? null : scenes[index];
360
363
  }
361
364
 
362
- export function createSceneDocument() {
363
- const scene = createScene();
365
+ // The stage a scene opened with before empty stages: one standing Y Bot.
366
+ export const createSeededSceneStage = () => createSceneStage({ characters: [SEED_CHARACTER] });
367
+
368
+ // A one-scene document on an empty stage. `{ seedCharacter: true }` gives
369
+ // the scene the old one-character cast instead.
370
+ export function createSceneDocument({ seedCharacter = false } = {}) {
371
+ const scene = { ...createScene(), ...(seedCharacter === true ? { stage: createSeededSceneStage() } : {}) };
364
372
  return { version: SCENES_VERSION, activeSceneId: scene.id, scenes: [scene] };
365
373
  }
366
374
 
@@ -410,7 +418,8 @@ export function readSceneDocument(raw, legacyRaw = null) {
410
418
  if (!plainObject(legacy) || legacy.version !== 1 || !Array.isArray(legacy.objects)) {
411
419
  return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
412
420
  }
413
- const document = createSceneDocument();
421
+ // A v1 scene had no stored cast; it always opened with the seed character.
422
+ const document = createSceneDocument({ seedCharacter: true });
414
423
  document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
415
424
  return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
416
425
  }
@@ -432,7 +441,9 @@ export function readSceneDocument(raw, legacyRaw = null) {
432
441
  // v4 → v5 needs no step of its own: every cast member passes through
433
442
  // createCharacterEntry, which fills the missing posture with "stand".
434
443
  const legacyClock = payload.version < 4;
435
- const repaired = repairDocument(payload, createSceneStage(payload.stage), legacyClock);
444
+ // Before v3 the cast lived on one global stage, and a document without one
445
+ // opened with the seed character.
446
+ const repaired = repairDocument(payload, payload.version < 3 && !plainObject(payload.stage) ? createSeededSceneStage() : createSceneStage(payload.stage), legacyClock);
436
447
  return { status: migrating ? "migrated" : "valid", ...repaired };
437
448
  }
438
449
 
@@ -2,6 +2,7 @@
2
2
  // reference picture into an orthographic model sheet, the drawing a sculpt is
3
3
  // built from. The template names no subject: everything subject-specific comes
4
4
  // from the attached reference and the operator's optional notes.
5
+ import { referenceMask } from "./sculpt-compare.js";
5
6
 
6
7
  /** The sheet's fixed layout, as fractions of the image: a 2 x 2 grid. FRONT and
7
8
  * SIDE share the top row so their head and foot lines are the same pixels;
@@ -63,3 +64,58 @@ export function blueprintErrorMessage(error, fallback) {
63
64
  }
64
65
  return fallback;
65
66
  }
67
+
68
+ /** The camera yaw each compared view was drawn from (sculpt-compare.js: 0 in
69
+ * front, positive toward the subject's own left). TOP looks straight down and is
70
+ * cropped but not compared: sculpt.compare's elevation stops at 60 degrees. */
71
+ export const BLUEPRINT_VIEW_YAWS = Object.freeze({ front: 0, side: 90, back: 180 });
72
+
73
+ /** One panel of an RGBA sheet, cut on whole pixels from the layout fractions. */
74
+ export function cropBlueprintPanel(image, { x, y, width, height }) {
75
+ const left = Math.round(x * image.width), top = Math.round(y * image.height);
76
+ const w = Math.round((x + width) * image.width) - left, h = Math.round((y + height) * image.height) - top;
77
+ const data = new Uint8Array(w * h * 4);
78
+ for (let row = 0; row < h; row += 1) {
79
+ const start = ((top + row) * image.width + left) * 4;
80
+ data.set(image.data.subarray(start, start + w * 4), row * w * 4);
81
+ }
82
+ return { width: w, height: h, data };
83
+ }
84
+
85
+ /**
86
+ * Cut a generated sheet (RGBA) into its views by BLUEPRINT_LAYOUT and mask each
87
+ * one. A view whose mask fails (no subject, no background, or a subject that
88
+ * touches the panel edge and so was cut or spills into its neighbour) is dropped
89
+ * with a notice. Returns { views: [{ view, label, yaw, image, mask }], notices }.
90
+ */
91
+ export function cropBlueprintViews(sheet, layout = BLUEPRINT_LAYOUT) {
92
+ const views = [], notices = [];
93
+ for (const panel of layout) {
94
+ const image = cropBlueprintPanel(sheet, panel);
95
+ try {
96
+ const { width, height, mask, source } = referenceMask(image);
97
+ let minX = width, maxX = -1, minY = height, maxY = -1;
98
+ for (let i = 0; i < mask.length; i += 1) if (mask[i]) {
99
+ const px = i % width, py = (i - px) / width;
100
+ minX = Math.min(minX, px); maxX = Math.max(maxX, px); minY = Math.min(minY, py); maxY = Math.max(maxY, py);
101
+ }
102
+ if (minX === 0 || minY === 0 || maxX === width - 1 || maxY === height - 1) throw new Error("the subject touches the panel edge");
103
+ const box = { x: minX / width, y: minY / height, width: (maxX - minX + 1) / width, height: (maxY - minY + 1) / height };
104
+ views.push({ view: panel.view, label: panel.label, yaw: BLUEPRINT_VIEW_YAWS[panel.view] ?? null, image, mask: { source, box } });
105
+ } catch (error) {
106
+ notices.push(`${panel.label} view dropped: ${String(error?.message || error).replace(/\.$/, "")}.`);
107
+ }
108
+ }
109
+ return { views, notices };
110
+ }
111
+
112
+ /** The cropped views of a generated sheet as PNG data URLs, through an image
113
+ * port ({ decode, encodePng }: commands/sculpt-images.js in the browser). */
114
+ export async function blueprintViewImages(sheetDataUrl, images) {
115
+ const { views, notices } = cropBlueprintViews(await images.decode({ dataUrl: sheetDataUrl }));
116
+ return { notices, views: await Promise.all(views.map(async ({ view, label, yaw, image, mask }) => ({ view, label, yaw, mask, width: image.width, height: image.height, dataUrl: await images.encodePng(image) }))) };
117
+ }
118
+
119
+ /** What a sculpt run takes from a sheet: its side and back views (the front is the reference itself). */
120
+ export const blueprintRunViews = (sheet) => (sheet?.views ?? []).filter((view) => view.view === "side" || view.view === "back")
121
+ .map(({ view, yaw, dataUrl }) => ({ view, yaw, dataUrl, name: `blueprint-${view}.png` }));
@@ -115,6 +115,11 @@ export function sculptAgentClient(transport, readContext) {
115
115
  * signed in, or a provider key held by the sidecar). */
116
116
  export const agentSessionReady = (status) => Boolean(status?.signedIn || status?.providersConfigured > 0);
117
117
 
118
+ /** Whether the sidecar holds a ChatGPT token: /oauth/status `signedIn` is the
119
+ * ChatGPT login alone (a provider key only counts in providersConfigured). The
120
+ * blueprint's image model is ChatGPT-only, so its button needs this (#970). */
121
+ export const chatgptSignedIn = (status) => status?.signedIn === true;
122
+
118
123
  /** A stored image asset as a data URL (the bytes the store holds, so the run
119
124
  * scores against exactly the stored, possibly downscaled, picture). */
120
125
  export function assetDataUrl(asset) {
@@ -0,0 +1,115 @@
1
+ // The part gizmo's arithmetic (#740 S8). Pure: no React, no DOM. A part's
2
+ // `position` and `rotation` are written in its PARENT's frame (the object
3
+ // frame for a root part), so a gizmo that moves the part in the world is
4
+ // read back through the parent: world = objectWorld × parent × local. A
5
+ // mirrored copy is edited through the part the recipe holds: the copy's own
6
+ // local values reflected across x = 0 of the same frame, which is how
7
+ // expandSculptParts derived the copy in the first place.
8
+ import * as THREE from "three";
9
+ import { SCULPT_MIRROR_SUFFIX, expandSculptParts } from "./sculpt-recipe.js";
10
+ import { tidy } from "./sculpt-recipe-read.js";
11
+
12
+ const DEG = Math.PI / 180;
13
+ // The recipe keeps 1e-4 (tidy); a field the drag left within that is unchanged.
14
+ const SAME = 1e-4;
15
+
16
+ const reflect = (local) => ({ position: [-local.position[0], local.position[1], local.position[2]], rotation: [local.rotation[0], -local.rotation[1], -local.rotation[2]] });
17
+ const wrap = (deg) => { const out = ((((deg + 180) % 360) + 360) % 360) - 180; return out === -180 ? 180 : out; };
18
+ const angleGap = (a, b) => Math.abs(wrap(a - b));
19
+
20
+ /**
21
+ * Where the selected part is drawn, as the gizmo needs it: the drawn mesh id,
22
+ * the object-frame matrix of the frame its values are written in (`parent`,
23
+ * column-major), and its local position, rotation (deg, XYZ) and size. A
24
+ * mirrored selection gives the copy's frame and values. Null when the recipe
25
+ * has no such part.
26
+ */
27
+ export function sculptPartFrame(recipe, selected) {
28
+ if (!recipe?.parts || !selected?.partId) return null;
29
+ // A cutter (op) is never drawn, so it has no mesh for the gizmo to sit on (the tree does not offer it).
30
+ if (recipe.parts.find((part) => part.id === selected.partId)?.op) return null;
31
+ const drawn = expandSculptParts(recipe);
32
+ const meshId = selected.mirrored ? `${selected.partId}${SCULPT_MIRROR_SUFFIX}` : selected.partId;
33
+ const row = drawn.find((entry) => entry.id === meshId);
34
+ if (!row) return null;
35
+ const parent = row.parent === undefined ? new THREE.Matrix4().toArray() : drawn.find((entry) => entry.id === row.parent).matrix;
36
+ return { meshId, parent, position: [...row.position], rotation: [...row.rotation], size: [...row.size] };
37
+ }
38
+
39
+ /** A local transform (position, rotation in degrees XYZ, scale) as a matrix. */
40
+ function localMatrix({ position, rotation, scale = [1, 1, 1] }) {
41
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0] * DEG, rotation[1] * DEG, rotation[2] * DEG, "XYZ"));
42
+ return new THREE.Matrix4().compose(new THREE.Vector3(...position), quaternion, new THREE.Vector3(...scale));
43
+ }
44
+
45
+ /** The world matrix of the frame a part's values are written in: objectWorld × parent. */
46
+ export function partParentWorld(objectWorld, frame) {
47
+ return objectWorld.clone().multiply(new THREE.Matrix4().fromArray(frame.parent));
48
+ }
49
+
50
+ /** Parent space → world: the matrix a part with `local` values draws at. */
51
+ export function partWorldMatrix(objectWorld, frame, local = frame) {
52
+ return partParentWorld(objectWorld, frame).multiply(localMatrix(local));
53
+ }
54
+
55
+ /** World → parent space: the position, rotation (deg) and scale `world` has in the part's frame. */
56
+ export function partLocalFromWorld(objectWorld, frame, world) {
57
+ const local = partParentWorld(objectWorld, frame).invert().multiply(world);
58
+ const position = new THREE.Vector3(), quaternion = new THREE.Quaternion(), scale = new THREE.Vector3();
59
+ local.decompose(position, quaternion, scale);
60
+ return partLocalOf({ position, quaternion, scale });
61
+ }
62
+
63
+ /** An Object3D-like transform (position, quaternion, scale) as recipe-style local values. */
64
+ export function partLocalOf({ position, quaternion, scale }) {
65
+ const euler = new THREE.Euler().setFromQuaternion(quaternion, "XYZ");
66
+ return { position: position.toArray(), rotation: [euler.x / DEG, euler.y / DEG, euler.z / DEG], scale: scale.toArray() };
67
+ }
68
+
69
+ /** Shift's uniform scale: every axis takes the factor of the axis that moved furthest from 1. */
70
+ export function uniformScale(scale) {
71
+ const lead = scale.reduce((best, value) => (Math.abs(value - 1) > Math.abs(best - 1) ? value : best), 1);
72
+ return [lead, lead, lead];
73
+ }
74
+
75
+ /**
76
+ * The one `object.sculpt` edit a finished drag writes: `{ set: partId,
77
+ * position?, rotation?, size? }` with only the fields the drag changed, or
78
+ * null when it changed nothing. `local` is the gizmo's result in the frame
79
+ * sculptPartFrame gave (scale multiplies the size). A mirrored selection
80
+ * writes the reflected values onto the part the recipe holds, so the pair
81
+ * moves together. Nothing is clamped: an undrawable size goes to the command,
82
+ * which refuses it with the reason.
83
+ */
84
+ export function sculptPartEdit(recipe, selected, local) {
85
+ const frame = sculptPartFrame(recipe, selected);
86
+ if (!frame) return null;
87
+ const moved = selected.mirrored ? reflect(local) : local;
88
+ const authored = selected.mirrored ? reflect(frame) : frame;
89
+ const edit = { set: selected.partId };
90
+ const position = moved.position.map(tidy);
91
+ if (position.some((value, axis) => Math.abs(value - authored.position[axis]) >= SAME)) edit.position = position;
92
+ const rotation = moved.rotation.map((deg) => tidy(wrap(deg)));
93
+ if (rotation.some((value, axis) => angleGap(value, authored.rotation[axis]) >= SAME)) edit.rotation = rotation;
94
+ const size = frame.size.map((value, axis) => tidy(value * (local.scale?.[axis] ?? 1)));
95
+ if (size.some((value, axis) => Math.abs(value - frame.size[axis]) >= SAME)) edit.size = size;
96
+ return Object.keys(edit).length > 1 ? edit : null;
97
+ }
98
+
99
+ /**
100
+ * The drag's live preview without rebuilding geometry: every drawn part's
101
+ * object-frame matrix after `edit`, and how much the edited part's size
102
+ * grew per axis (its meshes scale by that until the rebuilt sculpt lands).
103
+ * `Map(meshId → { matrix, ratio })`.
104
+ */
105
+ export function previewSculptParts(recipe, edit) {
106
+ const parts = recipe.parts.map((part) => (part.id === edit?.set ? { ...part, ...edit } : part));
107
+ const sizes = new Map(recipe.parts.map((part) => [part.id, part.size]));
108
+ const out = new Map();
109
+ for (const row of expandSculptParts({ ...recipe, parts })) {
110
+ const source = row.id.endsWith(SCULPT_MIRROR_SUFFIX) ? row.id.slice(0, -SCULPT_MIRROR_SUFFIX.length) : row.id;
111
+ const before = sizes.get(source) ?? row.size;
112
+ out.set(row.id, { matrix: row.matrix, ratio: row.size.map((value, axis) => (before[axis] ? value / before[axis] : 1)) });
113
+ }
114
+ return out;
115
+ }