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,4 +1,4 @@
1
- // The Studio task board in the agent panel (parallel tasks, P3). Task mode is a per-browser switch (the panel's Tasks
1
+ // The Studio task board in the agent panel (parallel tasks, P3). Task mode is a per-browser switch, on by default (the panel's Tasks
2
2
  // toggle) or the server's COZYCLAY_STUDIO_TASKS flag: the chat then talks to the main agent (POST /agent/main), which
3
3
  // answers at once and dispatches tasks; this board shows them. Pure parts here (the reducer, labels, the switch's
4
4
  // storage); the network side is createTaskBoardClient; the view is TaskBoard.jsx.
@@ -13,7 +13,7 @@ import { parseSseChunk } from "./agent-client.js";
13
13
 
14
14
  const TASKS_KEY = "cozyclay.agent.tasks";
15
15
  export function readStoredTaskMode(storage = globalThis.localStorage) {
16
- try { return storage?.getItem(TASKS_KEY) === "on"; } catch { return false; }
16
+ try { return storage?.getItem(TASKS_KEY) !== "off"; } catch { return true; } // on unless the author turned it off
17
17
  }
18
18
  export function storeTaskMode(enabled, storage = globalThis.localStorage) {
19
19
  try { storage?.setItem(TASKS_KEY, enabled ? "on" : "off"); } catch { /* storage may be unavailable */ }
@@ -12,7 +12,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
12
12
  PAGES_DIR = os.path.join(os.path.dirname(__file__), "pages")
13
13
 
14
14
  NAV = """ <nav>
15
- <a href="/#try">Try it</a>
15
+ <a href="/app/">Try it</a>
16
16
  <a href="/greybox-to-video/">Greybox to video</a>
17
17
  <a href="/previs-software/">Previs software</a>
18
18
  <a href="/ai-camera-control/">Camera control</a>
@@ -72,6 +72,25 @@
72
72
  </div>
73
73
  </section>
74
74
 
75
+ <section>
76
+ <h2>What the models actually accept</h2>
77
+ <p>
78
+ Seedance 2.5's product page names this input: "Provides advanced capabilities such as white-model control
79
+ and green-screen editing, combined with professional camera movement and performance blocking." Its showcase
80
+ prompt directs the model to "Use the white-model reference video as the sole guide for camera movement,
81
+ pacing, framing, subject motion, and blocking." That white-model reference video is a plain RGB blockout
82
+ clip, which is exactly what the keyframe pack exports. MiniMax H3 reference-to-video, the Wan 3.0 hosted
83
+ <code>reference_video</code>, LTX Desktop's "3D Render to Photoreal" ("keeping the exact composition, camera
84
+ move and layout of the input") and fal render-to-real all consume the same clip.
85
+ </p>
86
+ <p>
87
+ The caveat is Kling Motion Control: "Avoid cuts, camera movement, etc.; otherwise, the video may be
88
+ trimmed." So send it a single character, one continuous take.
89
+ </p>
90
+ <p class="note">None of the top hosted APIs (Seedance, Kling, Veo 3.1, Runway Act-Two, MiniMax)
91
+ documents a depth or pose control-video input.</p>
92
+ </section>
93
+
75
94
  <section>
76
95
  <h2>The block-then-generate loop</h2>
77
96
  <ol>
@@ -107,11 +126,11 @@
107
126
  <h2>Do it in the browser</h2>
108
127
  <p>
109
128
  CozyClay is open source previs that runs in a Chromium tab. It exists for exactly this loop: block,
110
- camera, export, generate. The <a href="/#try">playground on the home page</a> runs the studio in the page, and
129
+ camera, export, generate. The <a href="/app/">browser studio</a> runs it in a tab, and
111
130
  <code>npx cozyclay</code> runs it on your machine.
112
131
  </p>
113
132
  <div class="cta">
114
- <a class="btn primary" href="/#try">Try it in your browser</a>
133
+ <a class="btn primary" href="/app/">Try it in your browser</a>
115
134
  <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">View on GitHub</a>
116
135
  </div>
117
136
  </section>
@@ -64,8 +64,8 @@
64
64
  <p>
65
65
  The honest split is time, not money. Blender can do everything on this page and will take an
66
66
  afternoon to learn just the camera. CozyClay does one thing (block, camera, cut, export) and expects
67
- you to be productive in the first ten minutes; the <a href="/#try">playground on the home page</a>
68
- runs it in the page.
67
+ you to be productive in the first ten minutes; the <a href="/app/">browser studio</a>
68
+ runs it in a tab.
69
69
  </p>
70
70
  </section>
71
71
 
@@ -115,7 +115,7 @@
115
115
  <li><strong>Everything, given time:</strong> Blender.</li>
116
116
  </ul>
117
117
  <div class="cta">
118
- <a class="btn primary" href="/#try">Try CozyClay in your browser</a>
118
+ <a class="btn primary" href="/app/">Try CozyClay in your browser</a>
119
119
  <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">Source on GitHub</a>
120
120
  </div>
121
121
  </section>
@@ -87,7 +87,7 @@ cclay telemetry on</code></pre>
87
87
  <tbody>
88
88
  <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>
89
89
  <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>
90
- <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>
90
+ <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>
91
91
  <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>
92
92
  <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>
93
93
  <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>
@@ -103,7 +103,7 @@ cclay telemetry on</code></pre>
103
103
  <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>
104
104
  <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>
105
105
  <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>
106
- <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>
106
+ <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>
107
107
  <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>
108
108
  <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>
109
109
  <tr><td>$pageview, scene:created, scene:loaded, craft:first_action, activation:completed</td><td>Funnel and drop-off analysis</td></tr>
@@ -115,14 +115,15 @@ cclay telemetry on</code></pre>
115
115
  <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>
116
116
  <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>
117
117
  <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>
118
- <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>
118
+ <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>
119
119
  <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>
120
- <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>
121
- <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>
120
+ <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>
121
+ <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>
122
122
  <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>
123
123
  <tr><td>export:blocking_frame_succeeded, export:video_succeeded</td><td>Export reliability</td></tr>
124
124
  <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>
125
125
  <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>
126
+ <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>
126
127
  <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>
127
128
  <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>
128
129
  <tr><td>playground:opened, playground:first_action</td><td>Whether the home-page playground gets used</td></tr>
@@ -131,6 +132,7 @@ cclay telemetry on</code></pre>
131
132
  <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>
132
133
  <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>
133
134
  <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>
135
+ <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>
134
136
  <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>
135
137
  <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>
136
138
  <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>
@@ -139,8 +141,9 @@ cclay telemetry on</code></pre>
139
141
  <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>
140
142
  <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>
141
143
  <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>
142
- <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>
144
+ <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>
143
145
  <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>
146
+ <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>
144
147
  <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>
145
148
  <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>
146
149
  <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>
@@ -87,11 +87,11 @@
87
87
  Any 3D tool that renders flat can make the reference. Blender works with a clay material and a
88
88
  workbench render. CozyClay, open source and in the browser, exports the flat clip directly with the
89
89
  delivery ratio and a camera timeline built in; the reel at the top of this page came out of it.
90
- The <a href="/#try">home-page playground</a> runs the studio in the page, and
90
+ The <a href="/app/">browser studio</a> runs it in a tab, and
91
91
  <code>npx cozyclay</code> runs it locally.
92
92
  </p>
93
93
  <div class="cta">
94
- <a class="btn primary" href="/#try">Try it in your browser</a>
94
+ <a class="btn primary" href="/app/">Try it in your browser</a>
95
95
  <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">View on GitHub</a>
96
96
  </div>
97
97
  </section>
@@ -5,7 +5,7 @@
5
5
  "bin/agent/session-store.mjs": 324,
6
6
  "bin/agent/supervisor.mjs": 283,
7
7
  "bin/agent/video-adapters.mjs": 334,
8
- "bin/cozyclay.mjs": 351,
8
+ "bin/cozyclay.mjs": 348,
9
9
  "bin/live/cli.mjs": 456,
10
10
  "mcp/live-hub.mjs": 529,
11
11
  "mcp/tool-handlers.mjs": 1730,
@@ -15,11 +15,11 @@
15
15
  "mcp/verify-live-p0.mjs": 264,
16
16
  "mcp/verify-live.mjs": 416,
17
17
  "src/analytics.js": 948,
18
- "src/App.jsx": 4434,
19
- "src/asset-pane.jsx": 642,
18
+ "src/App.jsx": 4418,
19
+ "src/asset-pane.jsx": 614,
20
20
  "src/camera-follow.js": 505,
21
21
  "src/camera-move.js": 289,
22
- "src/command-bus.js": 355,
22
+ "src/command-bus.js": 341,
23
23
  "src/controls.jsx": 330,
24
24
  "src/domains/cast.js": 717,
25
25
  "src/domains/motion.js": 806,
@@ -33,21 +33,21 @@
33
33
  "src/matte.js": 472,
34
34
  "src/motion/collision-blockers.js": 273,
35
35
  "src/motion/fix-collisions.js": 748,
36
- "src/motion/ik.js": 1135,
36
+ "src/motion/ik.js": 1128,
37
37
  "src/motion/range-pin.js": 277,
38
38
  "src/motion/timeline.jsx": 2042,
39
39
  "src/object-gizmo.jsx": 740,
40
40
  "src/object-path.js": 410,
41
41
  "src/panels/ObjectTransformPanel.jsx": 545,
42
- "src/panels/SculptPanel.jsx": 370,
42
+ "src/panels/SculptPanel.jsx": 369,
43
43
  "src/planview.jsx": 887,
44
44
  "src/poses.js": 321,
45
- "src/posestudio.jsx": 1240,
45
+ "src/posestudio.jsx": 1226,
46
46
  "src/project-browser.jsx": 371,
47
47
  "src/project.js": 603,
48
48
  "src/props.jsx": 414,
49
49
  "src/range-pin-panel.jsx": 305,
50
- "src/scene-assets.js": 400,
50
+ "src/scene-assets.js": 399,
51
51
  "src/scene-mesh.js": 350,
52
52
  "src/scene-objects.js": 1134,
53
53
  "src/scenes.js": 385,
@@ -56,7 +56,7 @@
56
56
  "src/sculpt-run.js": 309,
57
57
  "src/shell/BoardSlot.jsx": 316,
58
58
  "src/shell/BottomDock.jsx": 416,
59
- "src/shell/DetailsSlot.jsx": 334,
59
+ "src/shell/DetailsSlot.jsx": 333,
60
60
  "src/shell/MenuBar.jsx": 632,
61
61
  "src/shell/ViewportToolbar.jsx": 567,
62
62
  "src/shot-authoring.js": 345,
@@ -70,5 +70,5 @@
70
70
  "src/workflow/AgentPanel.jsx": 807,
71
71
  "src/workflow/WorkflowBuilder.jsx": 592,
72
72
  "tools/qa/css-rule-usage.mjs": 251,
73
- "tools/run-tests.mjs": 441
73
+ "tools/run-tests.mjs": 407
74
74
  }
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { mkdtempSync, readdirSync, rmSync } from "node:fs";
4
- import { createRequire } from "node:module";
3
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
5
4
  import { availableParallelism, tmpdir } from "node:os";
6
5
  import { join, relative } from "node:path";
6
+ import { assignShards, parseShard, readDurations, readSelection, reportDurations, slowestFirst } from "./test-shards.mjs";
7
7
 
8
8
  // Whatever a suite drives through the agent sidecar lands in a scratch
9
9
  // session dir for the whole run, never in the author's ~/.config (#375).
@@ -14,22 +14,22 @@ if (!process.env.COZYCLAY_AGENT_SESSIONS_DIR) {
14
14
  }
15
15
 
16
16
  const NODE_FILES = [
17
- "test/motion3/verify-motion3-core.mjs", "test/motion3/verify-playback-frame-store.mjs", "test/verify-playback-render-isolation.mjs", "test/motion3/verify-motion3-idle.mjs", "test/motion3/verify-motion3-life.mjs", "test/motion3/verify-motion3-headlead.mjs", "test/motion3/verify-motion3-gait.mjs", "test/motion3/verify-motion3-overlap.mjs", "test/motion3/verify-motion3-flip.mjs", "test/motion3/verify-motion3-timing.mjs", "test/motion3/verify-motion3-self.mjs", "test/motion3/verify-motion3-commands.mjs", "test/motion3/verify-motion3-exitcar.mjs", "test/motion3/verify-motion3-agentloop.mjs", "test/motion3/verify-motion3-search.mjs", "test/motion3/verify-motion3-skills.mjs", "test/motion3/verify-motion3-carried.mjs", "test/motion3/verify-motion3-people.mjs", "test/motion3/verify-motion3-hit.mjs", "test/motion3/verify-motion3-socket.mjs", "test/motion3/verify-motion3-shapes.mjs", "test/motion3/verify-motion3-grasp-fields.mjs", "test/motion3/verify-motion3-finger-contact.mjs", "test/motion3/verify-motion3-finger-pose.mjs", "test/motion3/verify-motion3-grasp-qa.mjs", "test/motion3/verify-motion3-load.mjs", "test/motion3/verify-motion3-throw.mjs", "test/motion3/verify-motion3-catch.mjs", "test/motion3/verify-motion3-grasp-hints.mjs", "test/motion3/verify-motion3-reach-human.mjs", "test/motion3/verify-motion3-stance-grasp.mjs", "test/motion3/verify-motion3-grasp-friction.mjs", "test/motion3/verify-motion3-grasp-measure.mjs", "test/motion3/verify-motion3-contracts.mjs", "test/motion3/verify-motion3-describe.mjs", "test/motion3/verify-motion3-timeline.mjs", "test/motion3/verify-motion3-probes.mjs", "test/motion3/verify-motion3-grab.mjs", "test/motion3/verify-motion3-grab-blend.mjs", "test/motion3/verify-motion3-patch.mjs", "test/motion3/verify-motion3-patch-keys.mjs", "test/motion3/verify-motion3-stuck.mjs", "test/motion3/verify-motion3-repair.mjs", "test/motion3/verify-motion3-specstore.mjs", "test/motion3/verify-motion3-visual.mjs", "test/motion3/verify-motion3-goals.mjs", "test/motion3/verify-motion3-prompt.mjs", "test/motion3/verify-motion3-cup.mjs", "test/motion3/verify-motion3-arm.mjs", "test/verify-proxy-figure.mjs", "test/verify-text-object.mjs",
17
+ "test/motion3/verify-motion3-core.mjs", "test/motion3/verify-playback-frame-store.mjs", "test/verify-playback-render-isolation.mjs", "test/motion3/verify-motion3-idle.mjs", "test/motion3/verify-motion3-life.mjs", "test/motion3/verify-motion3-headlead.mjs", "test/motion3/verify-motion3-gait.mjs", "test/motion3/verify-motion3-overlap.mjs", "test/motion3/verify-motion3-flip.mjs", "test/motion3/verify-motion3-float-fix.mjs", "test/motion3/verify-motion3-timing.mjs", "test/motion3/verify-motion3-self.mjs", "test/motion3/verify-motion3-commands.mjs", "test/motion3/verify-motion3-exitcar.mjs", "test/motion3/verify-motion3-agentloop.mjs", "test/motion3/verify-motion3-search.mjs", "test/motion3/verify-motion3-skills.mjs", "test/motion3/verify-motion3-carried.mjs", "test/motion3/verify-motion3-people.mjs", "test/motion3/verify-motion3-hit.mjs", "test/motion3/verify-motion3-person-fix.mjs", "test/motion3/verify-motion3-socket.mjs", "test/motion3/verify-motion3-shapes.mjs", "test/motion3/verify-motion3-grasp-fields.mjs", "test/motion3/verify-motion3-finger-contact.mjs", "test/motion3/verify-motion3-finger-pose.mjs", "test/motion3/verify-motion3-grasp-qa.mjs", "test/motion3/verify-motion3-load.mjs", "test/motion3/verify-motion3-load-faces.mjs", "test/motion3/verify-motion3-throw.mjs", "test/motion3/verify-motion3-catch.mjs", "test/motion3/verify-motion3-spring.mjs", "test/motion3/verify-motion3-grasp-hints.mjs", "test/motion3/verify-motion3-ball-pickup-repair.mjs", "test/motion3/verify-motion3-reach-human.mjs", "test/motion3/verify-motion3-stance-grasp.mjs", "test/motion3/verify-motion3-grasp-friction.mjs", "test/motion3/verify-motion3-grasp-measure.mjs", "test/motion3/verify-motion3-contracts.mjs", "test/motion3/verify-motion3-describe.mjs", "test/motion3/verify-motion3-timeline.mjs", "test/motion3/verify-motion3-probes.mjs", "test/motion3/verify-motion3-grab.mjs", "test/motion3/verify-motion3-grab-blend.mjs", "test/motion3/verify-motion3-patch.mjs", "test/motion3/verify-motion3-patch-keys.mjs", "test/motion3/verify-motion3-stuck.mjs", "test/motion3/verify-motion3-repair.mjs", "test/motion3/verify-motion3-specstore.mjs", "test/motion3/verify-motion3-visual.mjs", "test/motion3/verify-motion3-goals.mjs", "test/motion3/verify-motion3-prompt.mjs", "test/motion3/verify-motion3-cup.mjs", "test/motion3/verify-motion3-arm.mjs", "test/motion3/verify-motion3-balance.mjs", "test/motion3/verify-physics-overlay.mjs", "test/verify-proxy-figure.mjs", "test/verify-text-object.mjs",
18
18
  "test/verify-proxy-figure-bounds.mjs",
19
- "test/verify-character-capabilities.mjs",
20
- "test/verify-root-path.mjs",
19
+ "test/verify-character-capabilities.mjs", "test/verify-test-shards.mjs", "test/verify-select-tests.mjs",
20
+ "test/verify-root-path.mjs", "test/verify-tutorial-recorder.mjs",
21
21
  "test/verify-studio-elements.mjs",
22
22
  "test/bus/verify-view-mode-pose.mjs",
23
23
  "test/verify-studio-actions.mjs",
24
24
  "test/ardy/verify-collision-blockers.mjs",
25
25
  "test/verify-pose-fade.mjs",
26
- "test/verify-pose-gizmo.mjs",
26
+ "test/verify-pose-gizmo.mjs", "test/verify-fingertip-pick.mjs",
27
27
  "test/ardy/verify-key-runs.mjs",
28
28
  "test/ardy/verify-playback-clock.mjs",
29
29
  "test/ardy/verify-timeline-coordinates.mjs",
30
30
  "test/verify-tokens.mjs", "test/verify-lens-scrub.mjs",
31
31
  "test/demo/verify-api-routes.mjs",
32
- "test/ik/verify-collision-capsules.mjs", "test/ik/verify-ik-pick.mjs",
32
+ "test/ik/verify-collision-capsules.mjs", "test/ik/verify-ik-pick.mjs", "test/ik/verify-contact-project.mjs",
33
33
  "test/ik/verify-ground.mjs",
34
34
  "test/ik/verify-physics-review.mjs",
35
35
  "test/ik/verify-physics-support.mjs",
@@ -65,7 +65,7 @@ const NODE_FILES = [
65
65
  "test/verify-agent-sse-golden.mjs",
66
66
  "test/verify-agent-context-budget.mjs", "test/verify-agent-context-trim.mjs",
67
67
  "test/verify-agent-supervisor.mjs", "test/verify-studio-tasks.mjs", "test/verify-task-board.mjs",
68
- "test/verify-studio-agent-protocol.mjs",
68
+ "test/verify-studio-agent-protocol.mjs", "test/verify-studio-prompt-cast-style.mjs",
69
69
  "test/verify-studio-agent-sampled-framing.mjs",
70
70
  "test/verify-studio-agent-tokens.mjs",
71
71
  "test/verify-agent-execution.mjs",
@@ -132,7 +132,7 @@ const NODE_FILES = [
132
132
  "test/verify-project.mjs",
133
133
  "test/verify-workflow-send.mjs",
134
134
  "test/verify-animation-export.mjs",
135
- "test/verify-project-previs-mode.mjs",
135
+ "test/verify-project-previs-mode.mjs", "test/verify-project-cast-style.mjs",
136
136
  "test/verify-pwa.mjs",
137
137
  "test/verify-package-signature.mjs",
138
138
  "test/verify-resilience.mjs",
@@ -211,11 +211,11 @@ const NODE_FILES = [
211
211
 
212
212
  const BROWSER_FILES = [
213
213
  "test/qa-proxy-entry-browser.mjs",
214
- "test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs",
215
- "test/qa-storyboard-board-browser.mjs",
214
+ "test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs", "test/qa-ik-contact-browser.mjs",
215
+ "test/qa-storyboard-board-browser.mjs", "test/qa-sculpt-shelf-browser.mjs",
216
216
  "test/qa-storyboard-export-browser.mjs", "test/qa-playback-guide-browser.mjs",
217
- "test/qa-proxy-figure-browser.mjs", "test/qa-click-friction-browser.mjs", "test/qa-update-notice-browser.mjs",
218
- "test/qa-rail-lean-browser.mjs",
217
+ "test/qa-proxy-figure-browser.mjs", "test/qa-click-friction-browser.mjs", "test/qa-update-notice-browser.mjs", "test/qa-export-menu-browser.mjs",
218
+ "test/qa-rail-lean-browser.mjs", "test/qa-sculpt-parts-browser.mjs",
219
219
  "test/qa-export-recovery-browser.mjs", "test/qa-export-workspace-browser.mjs",
220
220
  "test/verify-camera-mode-browser.mjs",
221
221
  "test/verify-camera-rail-browser.mjs",
@@ -258,9 +258,9 @@ const BROWSER_FILES = [
258
258
  const EXTRA_INVENTORY = ["test/ik/translation-step.mjs", "test/qa-scenes-v5-migration-browser.mjs", "test/qa-agent-scenarios-browser.mjs", "test/qa-execution-outcomes-browser.mjs", "test/qa-export-recovery-browser.mjs", "test/qa-agent-view-toggle-browser.mjs", "test/qa-camera-pointer-lock-browser.mjs", "test/qa-first-edit-browser.mjs", "test/qa-ia-tail-browser.mjs", "test/qa-keyframe-pack-browser.mjs", "test/qa-preview-browser.mjs", "test/qa-reference-slots-browser.mjs", "test/qa-scene-playback-browser.mjs", "test/qa-scene-switcher-browser.mjs", "test/qa-send-to-ai-browser.mjs", "test/qa-view-menu-browser.mjs", "test/qa-live-reconnect-browser.mjs", "test/qa-clipboard-paste-browser.mjs", "test/qa-rail-lean-browser.mjs", "test/qa-rail-reach-browser.mjs", "test/qa-rail-ring-browser.mjs", "test/qa-speed-graph-browser.mjs", "test/qa-export-workspace-browser.mjs"];
259
259
 
260
260
  EXTRA_INVENTORY.push("test/qa-proxy-figure-browser.mjs", "test/qa-click-friction-browser.mjs", "test/qa-update-notice-browser.mjs");
261
- EXTRA_INVENTORY.push("test/qa-proxy-entry-browser.mjs");
262
- EXTRA_INVENTORY.push("test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs");
263
- EXTRA_INVENTORY.push("test/qa-storyboard-board-browser.mjs");
261
+ EXTRA_INVENTORY.push("test/qa-proxy-entry-browser.mjs", "test/qa-export-menu-browser.mjs");
262
+ EXTRA_INVENTORY.push("test/qa-previs-start-browser.mjs", "test/qa-shortcut-friction-browser.mjs", "test/qa-ik-contact-browser.mjs");
263
+ EXTRA_INVENTORY.push("test/qa-storyboard-board-browser.mjs", "test/qa-sculpt-parts-browser.mjs", "test/qa-sculpt-shelf-browser.mjs");
264
264
  EXTRA_INVENTORY.push("test/qa-storyboard-export-browser.mjs", "test/qa-playback-guide-browser.mjs");
265
265
 
266
266
  function verificationFiles(directory) {
@@ -276,56 +276,38 @@ function verificationFiles(directory) {
276
276
  // Every suite reserves its own ports and writes under a temp dir, so files
277
277
  // are independent of each other; running them serially cost CI five minutes
278
278
  // (#413). `--jobs 1` restores the old interleaved live output for debugging.
279
- // `--shard INDEX/TOTAL` selects a deterministic round-robin slice for CI
280
- // runners. Shards are disjoint over the sorted inventory and together cover
281
- // the same runnable set as an unsharded invocation.
279
+ // `--shard INDEX/TOTAL` selects a deterministic slice for CI runners, balanced
280
+ // by the recorded seconds in tools/test-durations.json (tools/test-shards.mjs).
281
+ // Shards are disjoint and together cover the same runnable set as an unsharded
282
+ // invocation. `--durations-out PATH` writes each file's wall-clock seconds.
283
+ // `--only-from SELECTION.json` (tools/select-tests.mjs) narrows the runnable
284
+ // set before sharding; a shard left with no files passes.
282
285
  function parseArguments(arguments_) {
283
- let listOnly = false;
284
- let scope = "all";
285
- let jobs = Number(process.env.COZYCLAY_TEST_JOBS) || availableParallelism();
286
- let shard = null;
286
+ const options = { listOnly: false, scope: "all", jobs: Number(process.env.COZYCLAY_TEST_JOBS) || availableParallelism(), shard: null, durationsOut: null, onlyFrom: null };
287
+ const valued = { "--scope": "scope", "--jobs": "jobs", "--shard": "shard", "--durations-out": "durationsOut", "--only-from": "onlyFrom" };
287
288
  for (let index = 0; index < arguments_.length; index += 1) {
288
289
  const argument = arguments_[index];
289
- if (argument === "--list") {
290
- listOnly = true;
291
- continue;
292
- }
293
- if (argument === "--scope") {
294
- scope = arguments_[index + 1] ?? "";
295
- index += 1;
296
- continue;
297
- }
298
- if (argument === "--jobs") {
299
- jobs = Number(arguments_[index + 1]);
300
- index += 1;
301
- continue;
302
- }
303
- if (argument === "--shard") {
304
- const value = arguments_[index + 1] ?? "";
305
- const match = /^(\d+)\/(\d+)$/.exec(value);
306
- if (!match) throw new Error(`--shard must be INDEX/TOTAL with 1 <= INDEX <= TOTAL, got ${value}`);
307
- const current = Number(match[1]);
308
- const total = Number(match[2]);
309
- if (!Number.isInteger(current) || !Number.isInteger(total) || current < 1 || total < 1 || current > total) {
310
- throw new Error(`--shard must be INDEX/TOTAL with 1 <= INDEX <= TOTAL, got ${value}`);
311
- }
312
- shard = { current, total };
313
- index += 1;
314
- continue;
315
- }
316
- throw new Error(`unknown argument: ${argument}`);
290
+ if (argument === "--list") options.listOnly = true;
291
+ else if (Object.hasOwn(valued, argument)) options[valued[argument]] = arguments_[(index += 1)] ?? "";
292
+ else throw new Error(`unknown argument: ${argument}`);
317
293
  }
318
- if (scope !== "all" && scope !== "ardy") throw new Error(`unknown test scope: ${scope}`);
319
- if (!Number.isInteger(jobs) || jobs < 1) throw new Error(`--jobs must be a positive integer, got ${jobs}`);
320
- return { listOnly, scope, jobs, shard };
294
+ options.jobs = Number(options.jobs);
295
+ if (options.shard !== null) options.shard = parseShard(options.shard);
296
+ if (options.scope !== "all" && options.scope !== "ardy") throw new Error(`unknown test scope: ${options.scope}`);
297
+ if (!Number.isInteger(options.jobs) || options.jobs < 1) throw new Error(`--jobs must be a positive integer, got ${options.jobs}`);
298
+ if (options.durationsOut === "" || options.onlyFrom === "") throw new Error("--durations-out and --only-from need a file path");
299
+ return options;
321
300
  }
322
301
 
323
302
  // One file per child. With more than one job the output is buffered per file
324
303
  // and replayed as a block when the file finishes, so PASS/FAIL lines of
325
- // different suites never interleave.
304
+ // different suites never interleave. Each file's wall-clock seconds land in
305
+ // `timings` for --durations-out and the CI step summary.
306
+ const timings = new Map();
326
307
  function run(file, { buffered }) {
327
308
  return new Promise((resolve, reject) => {
328
309
  const chunks = [];
310
+ const started = performance.now();
329
311
  const child = spawn(process.execPath, [file], { stdio: buffered ? ["ignore", "pipe", "pipe"] : "inherit" });
330
312
  if (buffered) {
331
313
  child.stdout.on("data", (chunk) => chunks.push(chunk));
@@ -333,6 +315,7 @@ function run(file, { buffered }) {
333
315
  }
334
316
  child.once("error", reject);
335
317
  child.once("exit", (code, signal) => {
318
+ timings.set(file, (performance.now() - started) / 1000);
336
319
  if (buffered) process.stdout.write(`\nRUNNING ${file}\n${Buffer.concat(chunks)}`);
337
320
  if (code === 0) resolve();
338
321
  else reject(new Error(`${file} failed${signal ? ` with ${signal}` : ` with exit code ${code}`}`));
@@ -353,17 +336,8 @@ const SERIAL_ONLY = new Set([
353
336
  "mcp/verify-live-routing.mjs",
354
337
  ]);
355
338
 
356
- // Longest files first so the tail of the run is not one slow suite on its own.
357
- const SLOW_FIRST = [
358
- "test/verify-studio-agent-motion.mjs",
359
- "test/verify-studio-agent-binding.mjs",
360
- "test/verify-part-colours.mjs",
361
- "test/verify-agent-runner-errors.mjs",
362
- ];
363
-
364
339
  async function runAll(files, jobs) {
365
- const parallel = files.filter((file) => !SERIAL_ONLY.has(file));
366
- const queue = [...SLOW_FIRST.filter((file) => parallel.includes(file)), ...parallel.filter((file) => !SLOW_FIRST.includes(file))];
340
+ const queue = slowestFirst(files.filter((file) => !SERIAL_ONLY.has(file)), durations);
367
341
  let failure = null;
368
342
  const worker = async () => {
369
343
  while (queue.length > 0 && !failure) {
@@ -401,17 +375,11 @@ const hasNodeSqlite = meetsMinimumNodeVersion(NODE_SQLITE_MIN);
401
375
  // fresh clone those suites must degrade to an actionable EXCLUDE instead of
402
376
  // a startup timeout that swallows the real ERR_MODULE_NOT_FOUND (CI installs
403
377
  // them explicitly with `npm --prefix mcp ci`, so its coverage is unchanged).
404
- // Probes the same dependency set bin/mcp-runtime.mjs verifies before running.
378
+ // Every mcp/package.json dependency must be in mcp/node_modules itself: most of
379
+ // them also resolve from the root tree, which would hide a missing mcp install.
405
380
  function hasMcpRuntimeDeps() {
406
- try {
407
- const requireFromMcp = createRequire(new URL("../mcp/package.json", import.meta.url));
408
- for (const dependency of ["@modelcontextprotocol/sdk/server/mcp.js", "three", "ws", "zod"]) {
409
- requireFromMcp.resolve(dependency);
410
- }
411
- return true;
412
- } catch {
413
- return false;
414
- }
381
+ const { dependencies = {} } = JSON.parse(readFileSync(new URL("../mcp/package.json", import.meta.url), "utf8"));
382
+ return Object.keys(dependencies).every((name) => existsSync(new URL(`../mcp/node_modules/${name}/package.json`, import.meta.url)));
415
383
  }
416
384
 
417
385
  const mcpDepsInstalled = hasMcpRuntimeDeps();
@@ -447,7 +415,8 @@ if (!mcpDepsInstalled) {
447
415
  }
448
416
  }
449
417
 
450
- const { listOnly, scope, jobs, shard } = parseArguments(process.argv.slice(2));
418
+ const { listOnly, scope, jobs, shard, durationsOut, onlyFrom } = parseArguments(process.argv.slice(2));
419
+ const durations = readDurations();
451
420
  const inventory = [...verificationFiles("test"), ...verificationFiles("mcp"), ...EXTRA_INVENTORY].sort();
452
421
  for (const file of inventory) {
453
422
  if (/^test\/bus\/verify-.*\.mjs$/.test(file)) categories.set(file, { kind: "node", reason: "command bus contract verification" });
@@ -462,24 +431,24 @@ if (unclassified.length > 0 || stale.length > 0) {
462
431
  }
463
432
 
464
433
  const scoped = inventory.filter((file) => scope === "all" || file.startsWith("test/ardy/") || file.startsWith("test/ik/"));
465
- const runnable = scoped.filter((file) => categories.get(file).kind === "node");
466
- const selected = shard ? runnable.filter((_, index) => index % shard.total === shard.current - 1) : runnable;
434
+ const only = readSelection(onlyFrom);
435
+ const runnable = scoped.filter((file) => categories.get(file).kind === "node" && (!only || only.has(file)));
436
+ const { files: selected, predicted } = assignShards(runnable, durations, shard?.total ?? 1)[(shard?.current ?? 1) - 1];
467
437
  const selectedSet = new Set(selected);
468
438
  console.log(
469
439
  `TEST MANIFEST scope=${scope} runnable=${runnable.length} selected=${selected.length} total=${scoped.length} jobs=${jobs}${
470
440
  shard ? ` shard=${shard.current}/${shard.total}` : ""
471
- }`,
441
+ } predicted=${predicted.toFixed(1)}s`,
472
442
  );
473
443
  for (const file of scoped) {
474
444
  const { kind, reason } = categories.get(file);
475
- if (kind === "node" && shard && !selectedSet.has(file)) {
476
- console.log(`SHARD node ${file} - assigned to another shard`);
477
- continue;
478
- }
479
- console.log(`${kind === "node" ? "RUN" : "EXCLUDE"} ${kind} ${file} - ${reason}`);
445
+ if (kind === "node" && only && !only.has(file)) console.log(`SKIP node ${file} - not selected by ${onlyFrom}`);
446
+ else if (kind === "node" && shard && !selectedSet.has(file)) console.log(`SHARD node ${file} - assigned to another shard`);
447
+ else console.log(`${kind === "node" ? "RUN" : "EXCLUDE"} ${kind} ${file} - ${reason}`);
480
448
  }
481
449
 
482
450
  if (!listOnly) {
451
+ process.on("exit", () => reportDurations(timings, { out: durationsOut, selected: selected.length, predicted }));
483
452
  if (jobs === 1) {
484
453
  for (const file of selected) {
485
454
  console.log(`\nRUNNING ${file}`);