cozyclay 3.1.6 → 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 (224) hide show
  1. package/CHANGELOG.md +145 -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 +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -1 +1 @@
1
- {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.1.6\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"d7b44cfa152ddb449c4e1445adeb8e3ae0a176e8eab6d431c7d9103ee12c25fc\"}","signature":"ruWnt+cS82Ajp7uv+w9ZiHLcqY5lCSNKER2sVtiLV+jnhGAzpPw1/KuZazg9lwFucoycs99uTwY8s+e5A3WQDw=="}
1
+ {"payload":"{\"distribution\":\"npm\",\"package\":\"cozyclay\",\"version\":\"3.2.1\",\"repository\":\"NomaDamas/CozyClay\",\"content_sha256\":\"e718aecfd151dda276e5d63c11a57f1e47b52a54d35fc07dbea34a0b209de6c5\"}","signature":"57sTKSSWpbM4IS3uUaKX1UpUWwfo6aH0+GzQvkJP41AwzK9jGMCAwzatT6oDobQ86JFHBpXnpT2+kSSATwLpCg=="}
package/dist/index.html CHANGED
@@ -46,7 +46,7 @@
46
46
  "applicationCategory": "DesignApplication",
47
47
  "applicationSubCategory": "3D previsualization",
48
48
  "operatingSystem": "Any (Chromium-based browser)",
49
- "softwareVersion": "3.1.6",
49
+ "softwareVersion": "3.2.1",
50
50
  "description": "CozyClay is an open-source, browser-based 3D staging studio. Block a scene, pose characters, sequence motion prompts on a timeline, and preview generated motion in one local workspace that handles like the Unity Editor.",
51
51
  "image": "https://cozyclay.org/media/cozyclay-truck-demo-poster.jpg",
52
52
  "screenshot": "https://cozyclay.org/media/cozyclay-truck-demo-poster.jpg",
@@ -380,6 +380,9 @@
380
380
  .btn, a.card, .seg button, details summary::after { transition: none; }
381
381
  }
382
382
  </style>
383
+ <script type="module" crossorigin src="/assets/landing-2Fy7DU41.js"></script>
384
+ <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
385
+ <link rel="modulepreload" crossorigin href="/assets/analytics-CaTUuLPM.js">
383
386
  </head>
384
387
  <body>
385
388
  <header class="site">
@@ -133,7 +133,7 @@ cclay telemetry on</code></pre>
133
133
  <tbody>
134
134
  <tr><td>install:first_launch, app:session_started, app:session_ended</td><td>Counting installs and sessions; duration and action counts are bucketed. install:first_launch may carry heard_from (x, hn, reddit, github, friend, other, site or playground), from the first-launch prompt or a copied <code>npx cozyclay --via</code> command. app:session_ended also carries scenes_touched (how many scenes were created or loaded in the session, an exact count capped at 20, not bucketed), session_id (the opaque random session id the PostHog SDK generates, used only to join a session's last events, such as an error just before it ended) and per-session edit gesture counts in five groups, pose_edit_bucket, camera_edit_bucket, object_edit_bucket, shot_edit_bucket and prompt_edit_bucket, each bucketed (0, 1-3, 4-10 or gte11); never what was edited</td></tr>
135
135
  <tr><td>app:friction_summary</td><td>Once per session, next to app:session_ended: how much friction the session had, as closed buckets only. active_time (the session duration bucket); counts of refusals the Studio gave the user (refusals_ui) and the agent (refusals_agent), user undos (undos), errors (errors), browser long tasks (long_tasks) and motion compiles (compiles), each as 0, 1, 2, 3, 4-5, 6-10 or gte11; the longest long task (long_task_max: lt50, 50-150, 150-500, 500-2000 or gte2000 ms); the share of late playback ticks (late_tick_ratio: 0, lt5, 5-20, 20-50 or gte50 %). rage_clicks and dead_clicks count every ui:rage_click and ui:dead_click, shortcut_ignored every shortcut:ignored, search_empty every search:no_results, and nav_thrash and camera_thrash every nav:thrash and camera:thrash, including those past their event's cap. Never which command, control, key or text was involved.</td></tr>
136
- <tr><td>studio:refused</td><td>A command the Studio refused, as closed values only: code (one of the Studio's fixed error codes, such as INVALID_ARGUMENT, STALE_SCENE or TARGET_BUSY, else other), origin (ui for the user's own control, agent for the agent or an MCP client), phase (admission, refused before any change, or commit, refused after it started changing) and group (the command's family: object, shot, cast, scene, motion, edit, task, agent or other). At most 3 per code, group and origin per session; the rest are only counted in app:friction_summary. A cancel the caller asked for is not a refusal. Never the command's arguments, target or message.</td></tr>
136
+ <tr><td>studio:refused</td><td>A command the Studio refused, as closed values only: code (one of the Studio's fixed error codes, such as INVALID_ARGUMENT, STALE_SCENE or TARGET_BUSY, else other), origin (ui for the user's own control, agent for the agent or an MCP client), phase (admission, refused before any change, or commit, refused after it started changing) group (the command's family: object, shot, cast, scene, motion, edit, task, agent or other) and command (the refused action's id, such as object.set or shot.setCameraRail, only when it is one of the actions the Studio itself declares; a name the caller made up is left out). At most 3 per code, command (or group, without one) and origin per session; the rest are only counted in app:friction_summary. A cancel the caller asked for is not a refusal. Never the command's arguments, target or message.</td></tr>
137
137
  <tr><td>edit:undone</td><td>A user undo (agent undos are not counted): age (lt2s, 2-10s, 10-60s or gte60s since the undone edit was made, only when the undo was done and its age is known), kind (pose, camera, object, shot, prompt, agent_turn for the agent's whole turn, ik_preview for an un-applied IK drag, or other), via (key, menu or button) and result (done, noop when there was nothing to undo, or refused). At most 20 per session. Never what was undone.</td></tr>
138
138
  <tr><td>agent:turn_summary</td><td>One per agent turn: turn_id (the same random id as agent:turn_*); outcome (succeeded, failed or cancelled); wall (the turn's length) and first_tool (time to the first tool call, absent without tools), each lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m; counts of tool calls (tool_calls), failed tool calls (tool_failed), tool calls the Studio refused (refused), commands re-sent once after a stale revision or a busy gesture (stale_retry) and motion compiles (drafts), each as 0, 1, 2, 3, 4-5, 6-10 or gte11; refusal_top (the most frequent refusal code, or none); authored (true or false: the turn changed the scene); qa_first and qa_last (clean or problems, absent without drafts); qa_trend (improved, flat, worse, or na with fewer than 2 drafts); stuck (true or false: the motion compiler's stop rule fired); cancel_phase (before_tool, during_tool, after_tool or na). No prompt text, tool names, messages or responses.</td></tr>
139
139
  <tr><td>agent:post_turn_edit</td><td>One per agent turn: turn_id (the same random id) and the user's own edit gestures in the 120 seconds after the turn ended, closed early when the next turn starts or the page closes. edited (true or false); counts per group (pose, camera, object, shot, and prompt_block, which counts edits to a take's prompt and take fields), each as 0, 1, 2, 3, 4-5, 6-10 or gte11, where one continuous drag is one gesture; first_delay (time to the first edit: lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m), only when edited is true. Never which object or what changed.</td></tr>
@@ -149,7 +149,7 @@ cclay telemetry on</code></pre>
149
149
  <tr><td>agent:connect_finished</td><td>That attempt ended: provider, method, outcome (ok, failed, cancelled with the Cancel button, abandoned when closed or left unfinished), step it had reached (list, prompt, waiting or done) and duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m). Never the key, token, account, email or error text.</td></tr>
150
150
  <tr><td>agent:model_changed</td><td>A model picked in the agent's model list: its provider (as above, or other) and after_failure (whether the last agent turn had failed). At most 10 per session.</td></tr>
151
151
  <tr><td>agent:prompt_sent</td><td>One per prompt sent to the agent: turn_id (the same random id as agent:turn_*); provider (as for agent:connect_started, or other); via (typed, retry_button, regenerate, chip or supervisor); prompt_len (1-20, 21-80, 81-240 or gte241 characters); compose_ms (first keystroke to send, typed prompts only) and gap (since the previous turn ended, or first) as lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m; similarity (first, same, near or different, computed in the browser against the previous prompt's words); prev_outcome (succeeded, failed, cancelled or none); turn_index (1, 2, 3, 4-5, 6-10 or gte11); attached and task_mode (true or false). Never the prompt text or its words.</td></tr>
152
- <tr><td>ui:rage_click</td><td>Three or more fast clicks (within 1 second and 24 pixels) on one primary control: control (a control id from a fixed list, such as tl_play or export_video, or other), clicks (3, 4-5 or gte6) and changed (whether the Studio responded within half a second). At most 2 per control per session. No coordinates, no text, no element or object ids.</td></tr>
152
+ <tr><td>ui:rage_click</td><td>Three or more fast clicks (within 1 second and 24 pixels) on one primary control: control (a control id from a fixed list, such as tl_play or export_video; for a control not on that list, the Studio area it sits in: other_guide, other_agent, other_menu, other_sculpt, other_assets, other_board, other_timeline, other_inspector or other_viewport; else other), clicks (3, 4-5 or gte6) and changed (whether the Studio responded within half a second). At most 2 per control per session. No coordinates, no text, no element or object ids.</td></tr>
153
153
  <tr><td>ui:dead_click</td><td>A click on a primary control that did nothing: control (as above) and reason (aria_disabled when the control was disabled, refused when the Studio refused the command, no_effect when nothing changed within half a second). At most 2 per control per session. Clicks into text fields are never observed.</td></tr>
154
154
  <tr><td>feature:used</td><td>One signal per feature per session: a fixed feature name such as pose_edit (a real bone or pose change), pose_save (saving a pose to the library), camera_fly, export_video, export_storyboard or export_render_pass</td></tr>
155
155
  <tr><td>$pageview, scene:created, scene:loaded, craft:first_action, activation:completed</td><td>Funnel and drop-off analysis</td></tr>
@@ -161,14 +161,15 @@ cclay telemetry on</code></pre>
161
161
  <tr><td>workflow:result_applied</td><td>Captured or generated image/video output was written to the Workflow canvas: run_id only, at most once per run, independently of a later failure. No image bytes or output contents.</td></tr>
162
162
  <tr><td>agent:turn_requested</td><td>Explicit Agent turn requests from surface (studio or workflow), paired by ephemeral turn_id. No prompts, responses or model identifiers.</td></tr>
163
163
  <tr><td>agent:tool_executed</td><td>Agent tool observations: turn_id, tool_category (workflow_read, workflow_write, workflow_run, frame_capture, image_generate, scene_write or other), outcome (succeeded, failed or cancelled) and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s). Tool names, arguments and results are excluded.</td></tr>
164
- <tr><td>agent:turn_succeeded, agent:turn_failed, agent:turn_cancelled</td><td>Agent turn outcomes: turn_id and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s); failures and actual cancellations also include failure_code (aborted, auth, rate_limited, tool_failed, upstream, key_refused, stale_scene, capability_missing, busy, invalid_request or unknown). auth means CozyClay's local agent has no sign-in or refused the session; key_refused means the model provider rejected the key or credentials; stale_scene means the turn was refused because the scene changed underneath it; capability_missing means a required capability was unavailable, for example the live hub port was already in use; busy means another turn or target was busy; invalid_request means the request itself was rejected. Missing terminal delivery is not success.</td></tr>
164
+ <tr><td>agent:turn_succeeded, agent:turn_failed, agent:turn_cancelled</td><td>Agent turn outcomes: turn_id and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s); failures and actual cancellations also include failure_code (aborted, auth, rate_limited, tool_failed, upstream, network, key_refused, stale_scene, capability_missing, busy, invalid_request or unknown). upstream means the model provider failed (an HTTP 5xx, overload or a cut-off stream); network means the request never got an HTTP answer (offline, connection reset or DNS); unknown is any other failure. auth means CozyClay's local agent has no sign-in or refused the session; key_refused means the model provider rejected the key or credentials; stale_scene means the turn was refused because the scene changed underneath it; capability_missing means a required capability was unavailable, for example the live hub port was already in use; busy means another turn or target was busy; invalid_request means the request itself was rejected. Missing terminal delivery is not success.</td></tr>
165
165
  <tr><td>agent:result_applied</td><td>An Agent turn produced an acknowledged editor change: turn_id only, at most once per turn. Successful reads and focus/navigation do not prove applied output.</td></tr>
166
- <tr><td>mcp:tool_requested</td><td>Connected-editor MCP execution requests: ephemeral request_id and tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other).</td></tr>
167
- <tr><td>mcp:tool_executed</td><td>Connected-editor MCP outcomes: request_id, tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other), outcome (succeeded, failed, uncertain or cancelled) and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s). Control-plane success does not prove a mutation applied.</td></tr>
166
+ <tr><td>mcp:tool_requested</td><td>Connected-editor MCP execution requests: ephemeral request_id, tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other) and tool_name, the CozyClay tool that ran (describe_scene, describe_shot, render_prompt, mark_camera_move, describe_camera_move, save_project, set_camera, frame_shot, add_character, place_character, remove_character, focus_character, place_object, import_mesh, sculpt_object, compare_sculpt, group_objects, update_object, remove_object, apply_batch, add_scene, switch_scene, open_project, capture_frame, studio_commands, studio_run or other; never its arguments).</td></tr>
167
+ <tr><td>mcp:tool_executed</td><td>Connected-editor MCP outcomes: request_id, tool_category (read, camera, scene_write, prompt_authoring, frame_capture, motion_generate, motion_apply, project_io or other), tool_name (describe_scene, describe_shot, render_prompt, mark_camera_move, describe_camera_move, save_project, set_camera, frame_shot, add_character, place_character, remove_character, focus_character, place_object, import_mesh, sculpt_object, compare_sculpt, group_objects, update_object, remove_object, apply_batch, add_scene, switch_scene, open_project, capture_frame, studio_commands, studio_run or other), outcome (succeeded, failed, uncertain or cancelled) and duration_bucket (lt1s, 1-3s, 3-10s, 10-30s or gte30s). Control-plane success does not prove a mutation applied.</td></tr>
168
168
  <tr><td>mcp:result_applied</td><td>A connected editor acknowledged an MCP mutation, paired by request_id. Detached or disconnected MCP sessions remain unobserved.</td></tr>
169
169
  <tr><td>export:blocking_frame_succeeded, export:video_succeeded</td><td>Export reliability</td></tr>
170
170
  <tr><td>export:attempt_started, export:attempt_succeeded, export:attempt_failed, export:attempt_cancelled</td><td>Export attempts and outcomes, paired by an ephemeral random attempt_id; export_kind (video, depth_video, frame, keyframe_pack, contact_sheet, panel_pack, animatic, animation_project, workflow_send, storyboard, render_pass or otio), format (mp4, png, zip, cclayproject or otio), initiating surface (studio, workflow or embed), is_retry (whether the attempt retries an earlier one), retry_of (the retried attempt's attempt_id, only when is_retry is true) and objects (the scene's object count, bucketed 0, 1-3, 4-10 or gte11). Terminal events include a bucketed duration_bucket; failures and real cancellations include a normalized failure_code (unsupported_codec, encode_failed, render_failed, aborted or unknown), the stage where the export stopped (preparing, encoding, finalizing or handoff) and a progress bucket (0, 1-25, 26-50, 51-75 or 76-99 percent), never raw error messages, filenames, URLs, paths, prompts or scene content. Success means pipeline completion or download handoff, not confirmation that the OS saved a file.</td></tr>
171
171
  <tr><td>export:saved</td><td>How each exported file was saved, one event per file: method (workspace, written into the export folder you chose, or download, a browser download) and reason (none; unsupported, the browser has no folder access; no_workspace, no export folder is chosen; permission, folder access was denied; or write_failed, the folder write failed and the file fell back to a download). Never the file name or path.</td></tr>
172
+ <tr><td>export:menu_opened</td><td>The Export menu opened, once per open: surface (studio or embed), mode (animation or storyboard), webcodecs (whether this browser has the video encoder an MP4 export needs; a yes/no check, not a codec test) and shots (shot count bucket, panels in a storyboard: 0, 1-3, 4-10, gte11). Never shot names or what you exported.</td></tr>
172
173
  <tr><td>export:keyframe_pack</td><td>Legacy Workflow pack handoffs, with numeric entries count and source=workflow; not an additional export attempt</td></tr>
173
174
  <tr><td>sculpt_run</td><td>One event per finished Sculpt mode run: outcome (done, cancelled or failed), rounds (0 to 10), final_iou (how closely the final sculpt matched the reference image, 0 to 1 rounded to two decimals; omitted when the run was never scored), duration_ms (elapsed run time in milliseconds) and had_target (whether the run reworked an existing sculpt instead of creating one). Numbers only: never the reference image, recipe, part names or sculpt id.</td></tr>
174
175
  <tr><td>playground:opened, playground:first_action</td><td>Whether the home-page playground gets used</td></tr>
@@ -177,6 +178,7 @@ cclay telemetry on</code></pre>
177
178
  <tr><td>app:gl_lost</td><td>The 3D view's graphics failed, at most once per kind per session: kind (unavailable when WebGL is off at startup, context_lost when the GPU context drops later). Never the GPU renderer string.</td></tr>
178
179
  <tr><td>studio:ready</td><td>Once per session: how long the studio took to show its first frame, from page navigation to the first frame after the first render: boot (lt2s, 2-5s, 5-15s or gte15s). Never what was loaded.</td></tr>
179
180
  <tr><td>playback:summary</td><td>One per playback run (play until stop, or until the frame rate or speed changes), at most 10 per session: duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m); late_ticks, an estimate of dropped frames, not a measured one: the share of playback timer ticks that had to catch up more than one frame (0, lt5, 5-20, 20-50 or gte50 %); max_catchup (the most frames one tick caught up: 1, 2, 3-5 or gte6); fps (12, 24, 30, 60 or other); speed (0.5, 1, 2 or other); measured_fps (the browser's frame rate during playback, the device:profile frame-rate buckets). Every run also feeds app:friction_summary.late_tick_ratio. Never the scene, the take or the frame range.</td></tr>
181
+ <tr><td>landing:cta_clicked</td><td>The landing page (cozyclay.org/) sends one $pageview and, when a call to action is pressed, which one: target (app, github, discord, npm, copy_npx, copy_clone, copy_mcp, copy_terminal or agent_install_doc). Never the URL or the copied text. The landing page sends no session, error, device or performance events.</td></tr>
180
182
  <tr><td>guide:opened</td><td>The first-60-seconds guide opened, at most once per session: mode (animation or storyboard). Never step text or what you did.</td></tr>
181
183
  <tr><td>guide:step</td><td>A guide step marked done, each step at most once per session: mode (animation or storyboard), step (1 to 4) and step_ms (time on that step: lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m).</td></tr>
182
184
  <tr><td>guide:dismissed</td><td>The guide closed before its last step, at most once per session: mode (animation or storyboard) and reached (1 to 4, the step it was showing).</td></tr>
@@ -185,8 +187,9 @@ cclay telemetry on</code></pre>
185
187
  <tr><td>object:deleted_soon</td><td>You deleted an object you added in this session, at most 10 per session: age (how long after adding it: lt2s, 2-10s, 10-60s or gte60s). Object ids are reused names, so this is an estimate. Never the object's id, name or kind.</td></tr>
186
188
  <tr><td>object:delete_undone</td><td>You undid an object deletion within its 7-second undo window, at most 10 per session: age (time from the deletion to the undo: lt2s or 2-10s). Never the object's id, name or kind.</td></tr>
187
189
  <tr><td>update:notice_shown</td><td>The update notice appeared in the top bar of an npm studio, once per session: kind (global or npx) and behind (patch, minor or major: how far the running version is behind the newest release). Never a path or a command</td></tr>
188
- <tr><td>update:clicked</td><td>A button on the update notice, at most 10 per session: action (update_now, copy_command, later, dismiss or release_notes) and kind (global or npx)</td></tr>
190
+ <tr><td>update:clicked</td><td>A button on the update notice, at most 10 per session: action (update_now, copy_command, later, dismiss, release_notes or restart) and kind (global or npx)</td></tr>
189
191
  <tr><td>update:finished</td><td>A one-click update ended, at most 3 per session: outcome (ok or failed), error_class (none, permission, network, node_version, npm_missing or other) and duration (lt10s, 10-30s, 30-60s, 1-2m, 2-5m or gte5m). Never npm's output, a path or the command line</td></tr>
192
+ <tr><td>update:restarted</td><td>Restart now ended in the tab that pressed it, once per session: outcome (ok: the updated launcher answered; timeout: it did not within 30 seconds). Never a version string, a path or the command line</td></tr>
190
193
  <tr><td>device:profile</td><td>Once per session, whether the studio can run smoothly: gpu_vendor (nvidia, amd, intel, apple, qualcomm, arm, software, other or unknown), gpu_class (discrete, integrated, software or unknown), webgl (webgl2, webgl1 or none), cpu_bucket (1-4, 5-8, 9-16, gte17 or unknown), memory_bucket (lt4, 4-7, gte8 or unknown) and frame_rate_bucket (lt20, 20-40, 40-55, gte55 or unknown) from one three-second sample. The GPU renderer string and exact hardware values never leave the browser.</td></tr>
191
194
  <tr><td>survey:use_case</td><td>An optional question shown at most once after a successful export, only while telemetry is on: use_case (animation, film, game, ad_mv, personal, other or skip) and team (team, solo or skip). Closed choices only; no free text.</td></tr>
192
195
  <tr><td>hosted:composer_viewed, hosted:login_started, hosted:ticket_created, hosted:result_opened, hosted:opened_in_studio</td><td>Hosted demo funnel</td></tr>
package/dist/sitemap.xml CHANGED
@@ -2,7 +2,7 @@
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
3
  <url>
4
4
  <loc>https://cozyclay.org/</loc>
5
- <lastmod>2026-10-24</lastmod>
5
+ <lastmod>2026-10-27</lastmod>
6
6
  </url>
7
7
  <url>
8
8
  <loc>https://cozyclay.org/greybox-to-video/</loc>
@@ -22,6 +22,6 @@
22
22
  </url>
23
23
  <url>
24
24
  <loc>https://cozyclay.org/privacy/</loc>
25
- <lastmod>2026-10-25</lastmod>
25
+ <lastmod>2026-10-27</lastmod>
26
26
  </url>
27
27
  </urlset>
@@ -8,11 +8,12 @@
8
8
  <meta name="theme-color" content="#101116" />
9
9
  <meta name="description" content="CozyClay visual workflow builder" />
10
10
  <title>CozyClay Workflow</title>
11
- <script type="module" crossorigin src="/assets/workflow-DJwOUzqJ.js"></script>
12
- <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
13
- <link rel="modulepreload" crossorigin href="/assets/task-board-CkLZGPkv.js">
11
+ <script type="module" crossorigin src="/assets/workflow-CSiBlX1s.js"></script>
12
+ <link rel="modulepreload" crossorigin href="/assets/analytics-CaTUuLPM.js">
13
+ <link rel="modulepreload" crossorigin href="/assets/task-board-UVGTZLRF.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
14
15
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
15
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-t45pWCyq.js">
16
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-CLGNbEBq.js">
16
17
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
17
18
  <link rel="stylesheet" crossorigin href="/assets/workflow-DRdwoDBT.css">
18
19
  </head>
package/mcp/README.md CHANGED
@@ -73,7 +73,7 @@ generated frame matches the blocking instead of drifting off into a generic shot
73
73
  | `place_object` / `update_object` / `remove_object` | the set — `place_object` also accepts `name` and `parent`, so multi-part assets like "Building A" land as one named assembly |
74
74
  | `import_mesh` | load a local GLB, OBJ or FBX from a filesystem path as a mesh prop (live editor required); optional floor position, yaw, standing height, clay |
75
75
  | `sculpt_object` | build a clay prop from a recipe of soft parts (blob, box, cylinder, torus, frame), usually from a reference image; pass `id` with `recipe` to re-sculpt or with `edits` to patch parts; `capture: true` returns the frame too. Works without an editor. Contract: `docs/sculpt-from-image.md` |
76
- | `compare_sculpt` | score a sculpt's silhouette against its reference image (PNG/JPEG path): IoU, proportion delta, missing area by height band, parts that stick out, and a reference \| overlay sheet; `candidates` ranks up to 12 edit sets without applying any. Works without an editor |
76
+ | `compare_sculpt` | score a sculpt's silhouette against its reference image (PNG/JPEG path): IoU, proportion delta, missing area by height band, parts that stick out, and a reference \| overlay sheet; `views` [{ yaw, reference, weight? }] scores several views (front 0.6, side 0.25, back 0.15 by default) in place of `reference`; `candidates` ranks up to 12 edit sets without applying any. Works without an editor |
77
77
  | `group_objects` | attach children to a parent so they move as one; pass `parent: null` to detach |
78
78
  | `apply_batch` | execute up to 100 object mutations as one user-visible undo transaction |
79
79
  | `render_prompt` | the shot as an AI image or video prompt |
package/mcp/live-hub.mjs CHANGED
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomBytes, randomUUID } from "node:crypto";
3
3
  import { isDeepStrictEqual } from "node:util";
4
4
  import { bucketMs, sanitizeProps } from "../src/analytics.js";
5
- import { mcpToolCategory } from "../src/execution-telemetry.js";
5
+ import { mcpToolCategory, mcpToolName } from "../src/execution-telemetry.js";
6
6
 
7
7
  import { WebSocket, WebSocketServer } from "ws";
8
8
 
@@ -192,7 +192,7 @@ export class LiveHub {
192
192
  const startedAt = safely(now);
193
193
  if (typeof requestId !== "string" || !/^[a-f0-9]{32}$/.test(requestId) || !Number.isFinite(startedAt)) return work();
194
194
  const workspaceId = this.workspaceId(handle);
195
- const category = mcpToolCategory(name);
195
+ const category = mcpToolCategory(name), toolName = mcpToolName(name);
196
196
  const emit = (event, props) => safely(() => {
197
197
  // Do not reconstruct this lifecycle on a replacement connection.
198
198
  if (this.editors.get(handle) !== socket || socket.readyState !== WebSocket.OPEN) return;
@@ -266,14 +266,14 @@ export class LiveHub {
266
266
  if (value?.isError === true && !failure) failure = acknowledgedMutation ? "uncertain" : "failed";
267
267
  terminal = true;
268
268
  const endedAt = safely(now);
269
- if (Number.isFinite(endedAt)) emit("mcp:tool_executed", { request_id: requestId, tool_category: category, outcome: failure ?? "succeeded", duration_bucket: bucketMs(endedAt - startedAt) });
269
+ if (Number.isFinite(endedAt)) emit("mcp:tool_executed", { request_id: requestId, tool_category: category, tool_name: toolName, outcome: failure ?? "succeeded", duration_bucket: bucketMs(endedAt - startedAt) });
270
270
  emitApplied();
271
271
  // Never retain scene data with an asynchronous motion job.
272
272
  description = undefined;
273
273
  studioContext = undefined;
274
274
  mutation = null;
275
275
  });
276
- emit("mcp:tool_requested", { request_id: requestId, tool_category: category });
276
+ emit("mcp:tool_requested", { request_id: requestId, tool_category: category, tool_name: toolName });
277
277
  return executionObserver.run(observer, async () => {
278
278
  try {
279
279
  const value = await work();
@@ -32,6 +32,7 @@ import { MAX_COMMAND_TIMEOUT_MS } from "./live-hub.mjs";
32
32
  import { readMeshFromPath } from "./mesh-file.mjs";
33
33
  import { encodePng, readImageFromPath } from "./image-file.mjs";
34
34
  import { rankingRow, scoreSculptAgainstReference } from "../src/sculpt-compare.js";
35
+ import { scoreSculptAgainstViews } from "../src/sculpt-views.js";
35
36
  import { DEFAULT_POSE } from "../src/poses.js";
36
37
 
37
38
  import {
@@ -75,7 +76,9 @@ import { documentWithCamera, splitSavedCamera } from "./project-camera.mjs";
75
76
 
76
77
  /** The authoring state. One scene document, one camera, one project name. */
77
78
  export const state = {
78
- doc: createSceneDocument(),
79
+ // Memory mode keeps the one-character stage it always opened with; the
80
+ // studio's new project starts empty, and a connected editor reports its own.
81
+ doc: createSceneDocument({ seedCharacter: true }),
79
82
  name: "Untitled",
80
83
  /** the project's previs mode ("storyboard" | "animation"); the live editor reports it with every describe */
81
84
  previsMode: DEFAULT_PREVIS_MODE,
@@ -1009,7 +1012,6 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1009
1012
  const st = stage();
1010
1013
  const target = findCharacter(character);
1011
1014
  if (!target) return text(`No character "${character}". ${castHint()}`);
1012
- if (st.characters.length === 1) return text("The scene needs at least one character.");
1013
1015
  const letter = letterFor(target);
1014
1016
  st.characters = st.characters.filter((c) => c !== target);
1015
1017
  if (state.focus && !findCharacter(state.focus)) {
@@ -1231,17 +1233,18 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1231
1233
  "Unlike capture_frame and sculpt_object, compare_sculpt scores a sculpt's shape against a reference image instead of rendering the shot or changing the sculpt. " +
1232
1234
  "Give the sculpt's id and the path of the reference (PNG or JPEG on this machine; a flat background or transparency). It reads the reference's subject as a mask, draws the sculpt's silhouette from a view (yaw: the camera's azimuth, 0 in front, positive toward the subject's own left; elevation: degrees above), " +
1233
1235
  "fits it to the subject's height and position, and returns IoU, the width-to-height delta, which height band is missing area (top, middle, bottom) and which parts stick out, plus a sheet: reference | overlay (grey both, red missing, blue extra). " +
1234
- "Omit yaw to try -45..45 and keep the best. Pass candidates (up to 12 labelled edit sets) to score several fixes in one call without applying any; the answer ranks them against the current sculpt. Shape only: colour is not scored. Works without an editor.",
1236
+ "Omit yaw to try -45..45 and keep the best. Instead of reference, views [{ yaw, reference, weight? }] scores several views of the subject (a blueprint's front 0, side 90, back 180) and blends their IoUs by weight (default front 0.6, side 0.25, back 0.15; a view whose height:width differs from the front's by more than 35% gets 0), with one row per view. Pass candidates (up to 12 labelled edit sets) to score several fixes in one call without applying any; the answer ranks them against the current sculpt. Shape only: colour is not scored. Works without an editor.",
1235
1237
  inputSchema: {
1236
1238
  id: z.string().min(1).describe("the sculpt's object id"),
1237
- reference: z.string().min(1).describe("filesystem path to the reference PNG or JPEG"),
1239
+ reference: z.string().min(1).optional().describe("filesystem path to the reference PNG or JPEG; give this or views"),
1240
+ views: z.array(z.object({ yaw: z.number().min(-180).max(180), reference: z.string().min(1), weight: z.number().min(0).max(1).optional() })).min(1).max(4).optional().describe("several views of the subject (path + camera yaw each), scored together by weight; give this or reference"),
1238
1241
  yaw: z.number().min(-180).max(180).optional().describe("reference camera azimuth in degrees; omitted tries -45..45"),
1239
1242
  elevation: z.number().min(-60).max(60).optional().describe("reference camera elevation in degrees; default 0"),
1240
1243
  candidates: z.array(z.object({ label: z.string().min(1).max(60), edits: z.array(z.object({}).passthrough()).min(1).max(48) })).min(1).max(12).optional()
1241
1244
  .describe("alternative edit sets to score side by side without applying any; returns them ranked by IoU with the winner's sheet"),
1242
1245
  },
1243
1246
  },
1244
- async ({ id, reference, yaw, elevation = 0, candidates }) => {
1247
+ async ({ id, reference, views, yaw, elevation = 0, candidates }) => {
1245
1248
  const workspaceHandle = liveWorkspace.getStore();
1246
1249
  if (liveHub?.connected) {
1247
1250
  try { await refreshLiveDescription(workspaceHandle); } catch (error) { return liveError(error); }
@@ -1249,11 +1252,11 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1249
1252
  const target = scene().objects.find((o) => o.id === id);
1250
1253
  if (!target) return text(`No object "${id}". Call describe_scene for the current ids.`);
1251
1254
  if (target.renderer !== SCULPT_KIND || !target.recipe) return { content: [{ type: "text", text: `${id} is a ${target.renderer}, not a sculpt.` }], isError: true };
1252
- let image;
1253
- try { image = await readImageFromPath(reference); } catch (error) { return { content: [{ type: "text", text: error.message }], isError: true }; }
1254
- let result;
1255
+ if ((reference === undefined) === (views === undefined)) return { content: [{ type: "text", text: "Give exactly one of reference (one view) or views (several weighted views)." }], isError: true };
1256
+ let image, result;
1257
+ try { image = views ? await Promise.all(views.map(async (view) => ({ ...view, image: await readImageFromPath(view.reference) }))) : await readImageFromPath(reference); } catch (error) { return { content: [{ type: "text", text: error.message }], isError: true }; }
1255
1258
  try {
1256
- const scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates });
1259
+ const scored = views ? scoreSculptAgainstViews(target.recipe, image, { elevation, candidates }) : scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates });
1257
1260
  result = scored.result;
1258
1261
  if (candidates) {
1259
1262
  const { ranked, refused } = scored;
@@ -1267,12 +1270,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1267
1270
  return { content: [{ type: "text", text: error.message }], isError: true };
1268
1271
  }
1269
1272
  const png = encodePng(result.sheet.width, result.sheet.height, result.sheet.data);
1270
- return {
1271
- content: [
1272
- { type: "text", text: JSON.stringify({ id, ...result.report }) },
1273
- { type: "image", data: png.toString("base64"), mimeType: "image/png" },
1274
- ],
1275
- };
1273
+ return { content: [{ type: "text", text: JSON.stringify({ id, ...result.report }) }, { type: "image", data: png.toString("base64"), mimeType: "image/png" }] };
1276
1274
  },
1277
1275
  ),
1278
1276
 
@@ -1527,7 +1525,7 @@ export const createToolHandlers = ({ projectRootPromise } = {}) => {
1527
1525
  mode,
1528
1526
  model: modelById(model) ?? undefined,
1529
1527
  shot: currentShot(),
1530
- subject: framed.subject,
1528
+ subject: framed?.subject ?? "",
1531
1529
  subject2: other?.subject ?? null,
1532
1530
  posePhrase: pose_phrase,
1533
1531
  pose2Phrase: other ? pose2_phrase : "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozyclay",
3
- "version": "3.1.6",
3
+ "version": "3.2.1",
4
4
  "description": "Open source previs software in the browser: block a scene, pose characters, author camera moves and cuts, then take the same shots to an AI video model.",
5
5
  "scripts": {
6
6
  "dev": "node tools/dev-full.mjs --host 127.0.0.1 --port 5180",
@@ -41,6 +41,7 @@
41
41
  "demo:api": "npm --prefix workers/api run dev",
42
42
  "demo:api:install": "npm --prefix workers/api ci",
43
43
  "qa:browser": "node tools/qa-browser.mjs --",
44
+ "tutorial:record": "node tools/tutorial/launch.mjs",
44
45
  "test:shot-authoring": "node test/verify-shot-authoring.mjs",
45
46
  "test:camera-follow": "node test/verify-camera-follow.mjs",
46
47
  "test:sample-at": "node test/verify-sample-at.mjs",
@@ -75,6 +76,7 @@
75
76
  "eslint": "^10.12.0",
76
77
  "eslint-plugin-react-hooks": "^7.1.1",
77
78
  "globals": "^17.13.0",
79
+ "jpeg-js": "0.4.4",
78
80
  "knip": "^6.41.0",
79
81
  "mediabunny": "^1.55.2",
80
82
  "posthog-js": "^1.418.12",
package/src/App.jsx CHANGED
@@ -36,10 +36,12 @@ import { noteNavSwitch } from "./thrash-friction.js";
36
36
 
37
37
  import AgentPanel from "./workflow/AgentPanel.jsx";
38
38
  import SculptPanel from "./panels/SculptPanel.jsx";
39
+ import { dropAssetAt } from "./asset-drop.js";
40
+ import { saveSculptToShelf } from "./sculpt-shelf.js";
39
41
  import { createAgentPort, createSculptRun } from "./sculpt-run.js";
40
42
  import { SculptTurntable } from "./sculpt-stage.jsx";
41
43
  import { measureReferenceOutline } from "./sculpt-compare.js";
42
- import { BLUEPRINT_LAYOUT, buildBlueprintPrompt } from "./sculpt-blueprint.js";
44
+ import { BLUEPRINT_LAYOUT, blueprintRunViews, buildBlueprintPrompt } from "./sculpt-blueprint.js";
43
45
  import { browserSculptImages } from "./commands/sculpt-images.js";
44
46
  import { sculptAgentClient, sculptModelLabel } from "./sculpt-mode.js";
45
47
  import { useSculptMode } from "./use-sculpt-mode.js";
@@ -140,7 +142,7 @@ import {
140
142
  pathMetrics,
141
143
  strokeToPathPoints,
142
144
  } from "./object-path.js";
143
- import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx";
145
+ import { carriedPointAt, sceneObjectsAt } from "./object-travel.js"; import { objectTracksOf } from "./motion3/object-tracks.js"; import { ViewGhosts } from "./view-ghosts.jsx"; import { DraftGhost } from "./draft-ghost.jsx"; import { SculptSketch } from "./sculpt-sketch.jsx"; import { PhysicsOverlayLayer } from "./physics-overlay.jsx"; import { syncPhysicsOverlays } from "./physics-overlay.js";
144
146
  import { routeOwnerFor } from "./route-owner.js";
145
147
  import {
146
148
  exportFailureCode,
@@ -168,6 +170,8 @@ import {
168
170
  RangePinMarker,
169
171
  warmPoseThumbnails,
170
172
  } from "./posestudio.jsx";
173
+ import { FingerHandles, useFingerEdit } from "./finger-handles.jsx";
174
+ import { STORYBOARD_FONT, storyboardCaption, storyboardLine } from "./shell/storyboard-caption.js";
171
175
 
172
176
  import {
173
177
  createIkState,
@@ -297,28 +301,6 @@ const CHARACTER_SCALE_BOUNDS = elementByPath("character.scale");
297
301
  // fades the same highlight out over the same two seconds.
298
302
  const AGENT_RECEIPT_HIGHLIGHT_MS = 2000;
299
303
 
300
- // The storyboard contact sheet is drawn on a bare 2d canvas, which has no
301
- // stylesheet to inherit from: it gets the studio's own type stack explicitly
302
- // so the sheet reads like the app it came out of.
303
- const STORYBOARD_FONT = '12px Inter, "Pretendard", "Noto Sans KR", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif';
304
- // One unwrapped caption line at 12px inside a 480px cell holds about 64
305
- // characters before it runs into the next column.
306
- const STORYBOARD_CAPTION_CHARS = 64;
307
-
308
- /** Cut a caption to what one unwrapped cell line holds. */
309
- function storyboardLine(text) {
310
- return text.length > STORYBOARD_CAPTION_CHARS ? `${text.slice(0, STORYBOARD_CAPTION_CHARS - 1)}\u2026` : text;
311
- }
312
-
313
- /** Fold a labelled shot prompt into the one line a board cell can hold. */
314
- function storyboardCaption(prompt) {
315
- return storyboardLine(prompt
316
- .split("\n")
317
- .filter((line) => line.startsWith("SHOT:") || line.startsWith("LENS:"))
318
- .map((line) => line.slice(line.indexOf(":") + 1).trim())
319
- .join(" · "));
320
- }
321
-
322
304
  /**
323
305
  * Pose ONE cast member's rig at an absolute timeline frame: its own clip first,
324
306
  * then its own IK correction layer on top. This is the single description of
@@ -1012,21 +994,8 @@ export default function App() {
1012
994
  raycaster.setFromCamera(pointer, cam);
1013
995
  const hit = new THREE.Vector3();
1014
996
  if (!raycaster.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0), hit)) return;
1015
- // Dispatch on the payload kind: same ray, four spawners. Characters
1016
- // keep their tighter stage clamp (the rig walks, a prop does not);
1017
- // objects, cutouts and meshes take the same ROOM_LIMIT clamp their
1018
- // creators already apply.
1019
- if (payload.kind === "character") {
1020
- spawnCharacter(payload.id,
1021
- THREE.MathUtils.clamp(hit.x, CHARACTER_POSITION_BOUNDS.min.x, CHARACTER_POSITION_BOUNDS.max.x),
1022
- THREE.MathUtils.clamp(hit.z, CHARACTER_POSITION_BOUNDS.min.z, CHARACTER_POSITION_BOUNDS.max.z));
1023
- } else if (payload.kind === "object") {
1024
- addSceneObject(payload.objectKind, { x: hit.x, z: hit.z });
1025
- } else if (payload.kind === "image") {
1026
- spawnCutoutAt(payload.assetId, { x: hit.x, z: hit.z });
1027
- } else if (payload.kind === "mesh") {
1028
- spawnMeshAt(payload.assetId, { x: hit.x, z: hit.z });
1029
- }
997
+ // Dispatch on the payload kind (asset-drop.js): same ray, one spawner per kind.
998
+ dropAssetAt(payload, hit, { spawnCharacter, addSceneObject, spawnCutoutAt, spawnMeshAt, runStudioAction, select: setSelectedHierarchyId });
1030
999
  };
1031
1000
  window.addEventListener("pointermove", onMove);
1032
1001
  window.addEventListener("pointerup", onUp);
@@ -1121,6 +1090,7 @@ export default function App() {
1121
1090
  ikDragEnd, ikAddKeyframe,
1122
1091
  ikDeleteKeyframe, ikApplyPoseAsKey,
1123
1092
  } = motionDomain;
1093
+ const { handFingers, fingerEditing, setFingerEdit, exposureProbeRef: fingerExposureRef } = useFingerEdit(ikFocus, ikFkJoints);
1124
1094
  const [partColoursEnabled, setPartColoursEnabled] = useState(false);
1125
1095
  const [partColoursMode, setPartColoursMode] = useState("shaded");
1126
1096
 
@@ -1239,7 +1209,7 @@ export default function App() {
1239
1209
  attachTargetForRow, attachTargetLabel, attachSceneObject,
1240
1210
  } = objectsDomain;
1241
1211
 
1242
- const { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint } = useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId });
1212
+ const { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptChatgpt, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint, sculptParts } = useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId, appContext });
1243
1213
  async function chooseSculptReference(file) {
1244
1214
  try {
1245
1215
  // A new reference makes the old sheet a drawing of something else.
@@ -1257,6 +1227,8 @@ export default function App() {
1257
1227
  bus: { subscribe: (listener) => sculptTap.subscribe(listener, studioBindingRef.current?.bus) },
1258
1228
  agent: createAgentPort(sculptAgentClient(sculptTransport, () => buildStudioAgentContext())),
1259
1229
  reference: sculptReference,
1230
+ // The blueprint's side and back crops, when a sheet was drawn (#970).
1231
+ views: blueprintRunViews(sculptBlueprint.sheet),
1260
1232
  sculptId: sculptTarget?.id ?? null,
1261
1233
  score: (args) => appContext.bus.run("sculpt.compare", args, { origin: "ui" }),
1262
1234
  measure: async (reference) => measureReferenceOutline(await browserSculptImages.decode(reference)),
@@ -1629,10 +1601,10 @@ export default function App() {
1629
1601
  if (modeKey !== workflowMode && selectWorkflowMode(modeKey) === false) noteShortcutIgnored("mode", "workflow_mode");
1630
1602
  return;
1631
1603
  }
1632
- // Sculpt mode's W/E/R swing the turntable to front, side and back.
1604
+ // Sculpt mode's W/E/R swing the turntable to front, side and back, or drive a selected part's gizmo.
1633
1605
  if (workflowMode === "sculpt" && GIZMO_HOTKEYS[event.code]) {
1634
1606
  event.preventDefault();
1635
- requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[event.code]);
1607
+ sculptParts.onKey(event.code);
1636
1608
  return;
1637
1609
  }
1638
1610
  // Pose mode's W/R are the IK tools, gated like their Rig Control
@@ -1683,7 +1655,7 @@ export default function App() {
1683
1655
  runStudioAction("object.duplicate");
1684
1656
  return;
1685
1657
  }
1686
- if (event.key === "Escape" && selectedSceneObjectId) {
1658
+ if (event.key === "Escape" && selectedSceneObjectId && !event.defaultPrevented) {
1687
1659
  setSelectedHierarchyId("props");
1688
1660
  return;
1689
1661
  }
@@ -2343,7 +2315,7 @@ export default function App() {
2343
2315
  // patchable state, so every reader sees the body the save path writes.
2344
2316
  stage: { shotAspect: shotAspectKey, cameraPresetId, sensorId, hasCharSheet, environmentImage, environment, style, hasEnvSheet, keyLight, lighting },
2345
2317
  timeline: { currentFrame: tlFrame, frameCount: tlFrameCount, fps: tlFps },
2346
- activeCharacterId,
2318
+ activeCharacterId: characters.length ? activeCharacterId : null,
2347
2319
  partColours: partColoursEnabled ? PART_COLOURS : null,
2348
2320
  waypoints,
2349
2321
  characters,
@@ -2874,7 +2846,7 @@ export default function App() {
2874
2846
  // pass is the recorder's stand-in for the useFrame the preview gets.
2875
2847
  // Riding characters move with their object first, then the props.
2876
2848
  syncCarriers();
2877
- propSyncRef.current?.();
2849
+ propSyncRef.current?.(); syncPhysicsOverlays(frame);
2878
2850
  const sampled = sampleAt(context?.playbackScene ?? playbackScene, shotAtFrame(context?.shots ?? shots, frame), frame);
2879
2851
  const framing = sampled.camera ?? context?.framing;
2880
2852
  const cam = shotCamRef.current;
@@ -2959,7 +2931,7 @@ export default function App() {
2959
2931
  look.current.pitch = cameraSnapshot.pitch;
2960
2932
  propFrameRef.current = propFrame;
2961
2933
  syncCarriers();
2962
- propSyncRef.current?.();
2934
+ propSyncRef.current?.(); syncPhysicsOverlays();
2963
2935
  }
2964
2936
  }
2965
2937
 
@@ -3657,7 +3629,7 @@ export default function App() {
3657
3629
  const point = new THREE.Vector3(); bone.getWorldPosition(point);
3658
3630
  return point.toArray();
3659
3631
  },
3660
- committedIkEdits, waypoints,
3632
+ committedIkEdits, waypoints, rigs, characters, // rigs/characters: every cast member (QA reads a second rig)
3661
3633
  // Scene props as the stage sees them, for QA that places a box and
3662
3634
  // waits for its transform to land before fitting to it.
3663
3635
  objects: sceneObjects,
@@ -3702,22 +3674,14 @@ export default function App() {
3702
3674
  // instead of driving a file dialog. Same liveStateRef reasoning as
3703
3675
  // captureMeta — the effect does not depend on the shot list.
3704
3676
  exportKeyframePack: async (shotId) => {
3705
- const attempt = startExportAttempt({ export_kind: "keyframe_pack", format: "zip", surface: embedMode ? "embed" : "studio" });
3706
- try {
3707
- const live = appContext.live.state;
3708
- const index = live.shotIndexForPack(shotId ?? null);
3709
- const pack = await live.buildShotKeyframePack(live.shots[index], index);
3710
- // base64: a pack holds an MP4, and a megabyte-scale JS number array
3711
- // is not something to hand a CDP evaluate.
3712
- let binary = "";
3713
- for (const byte of pack.bytes) binary += String.fromCharCode(byte);
3714
- const result = { name: pack.name, entries: pack.entries.map((entry) => entry.name), byteLength: pack.bytes.byteLength, bytes: btoa(binary) };
3715
- attempt.succeed();
3716
- return result;
3717
- } catch (error) {
3718
- attempt.fail(error);
3719
- throw error;
3720
- }
3677
+ const live = appContext.live.state;
3678
+ const index = live.shotIndexForPack(shotId ?? null);
3679
+ const pack = await live.buildShotKeyframePack(live.shots[index], index);
3680
+ // base64: a pack holds an MP4, and a megabyte-scale JS number array
3681
+ // is not something to hand a CDP evaluate.
3682
+ let binary = "";
3683
+ for (const byte of pack.bytes) binary += String.fromCharCode(byte);
3684
+ return { name: pack.name, entries: pack.entries.map((entry) => entry.name), byteLength: pack.bytes.byteLength, bytes: btoa(binary) };
3721
3685
  },
3722
3686
  renderPass: (kind) => appContext.live.state.renderPassDataUrls([kind])[kind],
3723
3687
  // QA-only video export (#193): the Export menu's Video item without the
@@ -4205,7 +4169,7 @@ export default function App() {
4205
4169
  preserveAuthoredMotion: Boolean(c.layer?.waypoints?.length) }]));
4206
4170
  return { host: { workspaceId: liveWorkspaceIdRef.current, documentEpoch: studioDocumentEpochRef.current,
4207
4171
  sceneId: activeSceneIdRef.current, sceneEpoch: studioSceneEpochRef.current }, workspaceHandle: liveWorkspaceHandleRef.current,
4208
- sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", previsMode: scenesDomain.previsMode, aspect: live.stage.shotAspect, stage: live.stage,
4172
+ sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", previsMode: scenesDomain.previsMode, castStyle: scenesDomain.castStyle, aspect: live.stage.shotAspect, stage: live.stage,
4209
4173
  objects: storeRef.current.objects, characters: list, targets, shots: live.shots, frameCount: live.timeline.frameCount,
4210
4174
  selection: live.studioSelection, activeCharacterId: live.activeCharacterId, selectedShotId: live.studioShotId,
4211
4175
  view: live.studioView, camera: live.studioCamera ?? readStudioCamera(), filmback: live.filmback, manual: manualCameraOverrideRef.current,
@@ -4400,7 +4364,7 @@ export default function App() {
4400
4364
  // an action sees its own edit before React renders it.
4401
4365
  state: () => ({
4402
4366
  shots: appContext.live.state.shots, objects: storeRef.current.objects, characters: appContext.live.characters, frame: tlFrame, frameCount: tlFrameCount,
4403
- selectedObjectId: selectedSceneObjectId, activeCharacterId: activeChar?.id ?? null,
4367
+ selectedObjectId: selectedSceneObjectId, activeCharacterId: characters.length ? activeChar.id : null,
4404
4368
 
4405
4369
  generating: false,
4406
4370
  motionReady: false,
@@ -4476,19 +4440,20 @@ export default function App() {
4476
4440
  // Sculpt mode's panel heads the Details column (DetailsSlot mounts it).
4477
4441
  const sculptPanel = sculptMode && !embedMode ? (
4478
4442
  <SculptPanel
4479
- run={sculptRun}
4443
+ run={sculptRun} parts={sculptParts}
4480
4444
  reference={sculptReference}
4481
4445
  onReference={(file) => void chooseSculptReference(file)}
4482
4446
  onStart={startSculptRun}
4483
4447
  onCancel={() => sculptRunRef.current?.cancel()}
4484
4448
  onPlaceInScene={placeSculptInScene}
4449
+ onSaveToShelf={() => void saveSculptToShelf(sculptTarget).then(setToast)}
4485
4450
  canStart={Boolean(sculptReference) && sculptAgentStatus === "ready" && Boolean(liveWorkspaceHandle) && Boolean(sculptModelPick.model)}
4486
4451
  blueprint={{
4487
4452
  ...sculptBlueprint,
4488
4453
  prompt: buildBlueprintPrompt({ pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }),
4489
4454
  layout: BLUEPRINT_LAYOUT,
4490
- // The image model is ChatGPT-only (the sidecar refuses others with a reason).
4491
- refusal: sculptAgentStatus === "signed-out" ? ko("Sign in with ChatGPT in the Agent panel to generate a blueprint.", "도면을 생성하려면 에이전트 패널에서 ChatGPT로 로그인하세요.") : null,
4455
+ // The image model is ChatGPT-only: without a ChatGPT token the button is hidden and this says why (#970).
4456
+ refusal: !sculptChatgpt ? ko("Sign in with ChatGPT in the Agent panel to generate a blueprint.", "도면을 생성하려면 에이전트 패널에서 ChatGPT로 로그인하세요.") : null,
4492
4457
  onPose: (pose) => setSculptBlueprint((current) => ({ ...current, pose })),
4493
4458
  onNotes: (notes) => setSculptBlueprint((current) => ({ ...current, notes })),
4494
4459
  onGenerate: () => void generateSculptBlueprint(),
@@ -4515,7 +4480,7 @@ export default function App() {
4515
4480
  // The single shell snapshot keeps subsequent region work out of App.
4516
4481
  const shellContext = {
4517
4482
  sculptPanel,
4518
- projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, previsMode, projectStartupOpen,
4483
+ projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, previsMode, castStyle: scenesDomain.castStyle, projectStartupOpen,
4519
4484
  requestNewProject, setProjectStartupOpen, setProjectBrowserOpen, runStudioAction, saveProject,
4520
4485
  projectManifest, projectSaveState, recState, exportMenuTriggerRef, exportMenuOpen,
4521
4486
  exportShotIdRef, setExportMenuAnchor, setExportMenuOpen, exportPhaseLabel,
@@ -4529,7 +4494,7 @@ export default function App() {
4529
4494
  activeSceneId, selectSceneDocument, createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
4530
4495
  deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest,
4531
4496
  toggleHierarchyHidden, propsDrop, hierarchyReparent, agentTouchedRows, workflowMode,
4532
- selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView,
4497
+ selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView, sculptParts,
4533
4498
  preset, applyPreset, cameraPresetId, shotAspectKey,
4534
4499
  fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
4535
4500
  viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
@@ -4546,7 +4511,7 @@ export default function App() {
4546
4511
  setStudioPick, appContext, removePose, activeRig, saveCurrentPose,
4547
4512
  setTlFrame, tlFrameCount,
4548
4513
  tlFrame, isRigSelection, ikChains, ikFocus, footSnap, poseObjectOpacity, setPoseObjectOpacity, capturePoseImage,
4549
- ikEditTool, setIkEditTool,
4514
+ ikEditTool, setIkEditTool, ikSolve, ikDragEnd, ikTick, handFingers, fingerEditing, setFingerEdit,
4550
4515
  pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
4551
4516
  rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
4552
4517
  rangePinPreview, setRangePinSelection, setRangePinPartPick, previewRangePinDraft, applyRangePinDraft,
@@ -4724,7 +4689,7 @@ export default function App() {
4724
4689
  {sculptMode && (
4725
4690
  <SculptTurntable
4726
4691
  camRef={sculptCamRef}
4727
- objects={sceneObjects}
4692
+ objects={sceneObjects} parts={sculptParts}
4728
4693
  target={sculptTarget}
4729
4694
  size={sculptTarget ? objectSize(sculptTarget) : null}
4730
4695
  view={sculptView}
@@ -4762,7 +4727,7 @@ export default function App() {
4762
4727
  />
4763
4728
  </ObjectCarrier>
4764
4729
  ))}
4765
- <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} />
4730
+ <ViewGhosts rig={rigs[activeChar.id]} take={motion} frame={tlFrame} objects={sceneObjects} propTake={{ frameCount: tlFrameCount, fps: tlFps, objectTracks }} /><DraftGhost rigs={rigs} objects={sceneObjects} /><SculptSketch objects={sceneObjects} /><PhysicsOverlayLayer rig={rigs[activeChar.id]} take={motion} objects={sceneObjects} />
4766
4731
 
4767
4732
  {/* Selection marker: XYZ tripod + ring on the picked cast
4768
4733
  member; the X/Z arrows drag it across the deck. */}
@@ -4882,7 +4847,9 @@ export default function App() {
4882
4847
  onFocus={focusIkHandle}
4883
4848
  onSolve={ikSolve}
4884
4849
  onDragEnd={ikDragEnd}
4850
+ focusToolsHidden={fingerEditing} exposureProbeRef={fingerExposureRef}
4885
4851
  />
4852
+ <FingerHandles fingers={handFingers} enabled={fingerEditing && ikMode && ikEditTool === "ik" && !posing && !playMode} onSolve={ikSolve} onDragEnd={ikDragEnd} exposureProbeRef={fingerExposureRef} />
4886
4853
  <RangePinMarker
4887
4854
  chains={ikChains}
4888
4855
  track={rangePinPreview?.track}
@@ -5268,9 +5235,9 @@ export default function App() {
5268
5235
  openProject();
5269
5236
  }}
5270
5237
  starters={STARTER_SCENES}
5271
- onStarter={(id, name) => {
5238
+ onStarter={(id, name, options) => {
5272
5239
  setProjectBrowserOpen(false);
5273
- void openStarterScene(id, "starter", name);
5240
+ void openStarterScene(id, "starter", name, options?.castStyle);
5274
5241
  }}
5275
5242
  onNew={(name, options) => {
5276
5243
  setProjectBrowserOpen(false);