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,5 +1,5 @@
1
1
  // The closed list of primary Studio controls that carry `data-fid`, for
2
- // ui:rage_click and ui:dead_click. A click on any other control reports "other".
2
+ // ui:rage_click and ui:dead_click. A click on any other control reports its area (AREA_IDS) or "other".
3
3
  // Ids name a control, never a scene object, a name or any text.
4
4
  export const CONTROL_IDS = Object.freeze([
5
5
  "dock_tab_board", "dock_tab_animation", "dock_tab_assets",
@@ -13,5 +13,27 @@ export const CONTROL_IDS = Object.freeze([
13
13
  "board_export", "board_stylize",
14
14
  ]);
15
15
 
16
- /** The closed control id of a `data-fid` value, else "other". */
17
- export const controlOf = (fid) => (CONTROL_IDS.includes(fid) ? fid : "other");
16
+ // A control with no listed `data-fid` reports the Studio area it sits in, by
17
+ // the area's own class; the first match wins, so nested areas come first.
18
+ const AREAS = [
19
+ ["other_guide", ".first-success-guide"],
20
+ ["other_agent", ".agent-panel"],
21
+ ["other_menu", ".menubar, [role='menu']"],
22
+ ["other_sculpt", ".sculpt-panel"],
23
+ ["other_assets", ".content-browser"],
24
+ ["other_board", ".dock-board"],
25
+ ["other_timeline", ".bottom-timeline, .studio-dock-slot"],
26
+ ["other_inspector", ".inspector-pane, .studio-right-column"],
27
+ ["other_viewport", ".studio-left-column"],
28
+ ];
29
+ export const AREA_IDS = Object.freeze(AREAS.map(([id]) => id));
30
+
31
+ /** The closed control id of a `data-fid` value; else the area of `element`, else "other". */
32
+ export function controlOf(fid, element = null) {
33
+ if (CONTROL_IDS.includes(fid)) return fid;
34
+ for (const [id, selector] of AREAS) if (element?.closest?.(selector)) return id;
35
+ return "other";
36
+ }
37
+
38
+ /** An area id or "other": a control the Studio does not name. */
39
+ export const isUnnamedControl = (control) => control === "other" || AREA_IDS.includes(control);
@@ -4,7 +4,7 @@
4
4
  import { STUDIO_ERROR_CODES } from "./studio-error-codes.js";
5
5
  import { ERROR_TYPE_VALUES, sessionEndedPropAllowed } from "./analytics-values.js";
6
6
  import { FRICTION_SUMMARY_KEYS, frictionPropAllowed } from "./analytics-friction.js";
7
- import { CONTROL_IDS } from "./analytics-controls.js";
7
+ import { AREA_IDS, CONTROL_IDS } from "./analytics-controls.js";
8
8
 
9
9
  const TURN_ID_PATTERN = /^[a-f0-9]{32}$/;
10
10
  // The friction count vocabulary (analytics-friction.js bucketFrictionCount).
@@ -18,6 +18,9 @@ const REFUSAL_CODES = new Set([...STUDIO_ERROR_CODES, "other"]);
18
18
  export const CONNECT_PROVIDERS = Object.freeze(["openai-codex", "anthropic", "anthropic-subscription", "openai", "google",
19
19
  "minimax", "minimax-cn", "openrouter", "cliproxy", "openai-compatible"]);
20
20
 
21
+ /** landing:cta_clicked targets: the landing page's links out and copy buttons. */
22
+ export const LANDING_CTA_TARGETS = Object.freeze(["app", "github", "discord", "npm", "copy_npx", "copy_clone", "copy_mcp", "copy_terminal", "agent_install_doc"]);
23
+
21
24
  /** A Studio refusal code as sent: one of STUDIO_ERROR_CODES, else "other". */
22
25
  export const refusalCodeOf = (code) => (STUDIO_ERROR_CODES.includes(code) ? code : "other");
23
26
 
@@ -51,6 +54,8 @@ const SCHEMAS = Object.freeze({
51
54
  "studio:refused": {
52
55
  code: inSet(REFUSAL_CODES), origin: oneOf("ui", "agent"), phase: oneOf("admission", "commit"),
53
56
  group: oneOf("object", "shot", "cast", "scene", "motion", "edit", "task", "agent", "other"),
57
+ // A registered Studio action id (command-bus-telemetry.js sends only those): code-defined, never args (#975).
58
+ command: (value) => typeof value === "string" && value.length <= 48 && /^[a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9]+){1,3}$/.test(value),
54
59
  },
55
60
  // error_type: the app:error label set (analytics.js describeError); never a message or stack.
56
61
  "app:render_crash": { error_type: inSet(ERROR_TYPE_VALUES) },
@@ -69,6 +74,10 @@ const SCHEMAS = Object.freeze({
69
74
  "guide:dismissed": { mode: oneOf("animation", "storyboard"), reached: oneOf("1", "2", "3", "4") },
70
75
  "guide:completed": { mode: oneOf("animation", "storyboard") },
71
76
  "agent:panel_toggled": { open: bool, via: oneOf("key", "button") },
77
+ // The Export flyout opening (export-menu-telemetry.js); shots is analytics.js bucketCount.
78
+ "export:menu_opened": {
79
+ surface: oneOf("studio", "embed"), mode: oneOf("animation", "storyboard"), webcodecs: bool, shots: oneOf("0", "1-3", "4-10", "gte11"),
80
+ },
72
81
  "object:deleted_soon": { age: inSet(AGE_BUCKETS) },
73
82
  "object:delete_undone": { age: inSet(AGE_BUCKETS) },
74
83
  "edit:undone": {
@@ -94,6 +103,8 @@ const SCHEMAS = Object.freeze({
94
103
  },
95
104
  "shortcut:unbound": { combo: oneOf("copy", "paste", "cut", "select_all", "group", "find", "rename", "help") },
96
105
  "help:shortcuts_opened": {},
106
+ // The landing page (src/site/landing-analytics.js): which call to action was pressed, never a URL or copied text.
107
+ "landing:cta_clicked": { target: oneOf(...LANDING_CTA_TARGETS) },
97
108
  "search:no_results": { surface: oneOf("assets", "outliner"), query_len: oneOf("1", "2-3", "4-8", "gte9") },
98
109
  "nav:thrash": { surface: oneOf("dock_tab", "mode", "agent_panel"), switches: oneOf("4-5", "6-10", "gte11"), window_s: oneOf(10) },
99
110
  "camera:thrash": { gestures: oneOf("6-10", "gte11"), kind_mix: oneOf("orbit", "fly", "pan", "mixed"), window_s: oneOf(20) },
@@ -110,11 +121,12 @@ const SCHEMAS = Object.freeze({
110
121
  gap: (value) => value === "first" || long(value), prev_outcome: oneOf("succeeded", "failed", "cancelled", "none"), turn_index: count,
111
122
  attached: bool, task_mode: bool,
112
123
  },
113
- "ui:rage_click": { control: oneOf(...CONTROL_IDS, "other"), clicks: oneOf("3", "4-5", "gte6"), changed: bool },
114
- "ui:dead_click": { control: oneOf(...CONTROL_IDS, "other"), reason: oneOf("aria_disabled", "refused", "no_effect") },
124
+ "ui:rage_click": { control: oneOf(...CONTROL_IDS, ...AREA_IDS, "other"), clicks: oneOf("3", "4-5", "gte6"), changed: bool },
125
+ "ui:dead_click": { control: oneOf(...CONTROL_IDS, ...AREA_IDS, "other"), reason: oneOf("aria_disabled", "refused", "no_effect") },
115
126
  // The update notice (src/update/update-telemetry.js): an install kind and buckets, never a version string or npm output.
116
127
  "update:notice_shown": { kind: oneOf("global", "npx"), behind: oneOf("patch", "minor", "major") },
117
- "update:clicked": { action: oneOf("update_now", "copy_command", "later", "dismiss", "release_notes"), kind: oneOf("global", "npx") },
128
+ "update:clicked": { action: oneOf("update_now", "copy_command", "later", "dismiss", "release_notes", "restart"), kind: oneOf("global", "npx") },
129
+ "update:restarted": { outcome: oneOf("ok", "timeout") },
118
130
  "update:finished": {
119
131
  outcome: oneOf("ok", "failed"), error_class: oneOf("none", "permission", "network", "node_version", "npm_missing", "other"), duration: long,
120
132
  },
package/src/analytics.js CHANGED
@@ -1,17 +1,16 @@
1
+ import { appVersion } from "./app-version.js";
1
2
  import { FIRST_EDIT_KINDS, FIRST_EDIT_VERSION } from "./semantic-edit.js";
2
3
  import { ERROR_TYPE_VALUES, EXPORT_FAILURE_CODES, exportPropAllowed } from "./analytics-values.js";
3
4
  import { bump as bumpFriction, observeLongTasks, snapshot as frictionSnapshot } from "./analytics-friction.js";
4
5
  import { STRICT_EVENT_KEYS, strictPropValidator } from "./analytics-strict.js";
5
- export const MCP_TOOL_CATEGORIES = Object.freeze([
6
- "read",
7
- "camera",
8
- "scene_write",
9
- "prompt_authoring",
10
- "frame_capture",
11
- "motion_generate",
12
- "motion_apply",
13
- "project_io",
14
- "other",
6
+ export const MCP_TOOL_CATEGORIES = Object.freeze(["read", "camera", "scene_write", "prompt_authoring", "frame_capture", "motion_generate", "motion_apply", "project_io", "other"]);
7
+ // The live MCP tools (mcp/tool-handlers.mjs liveWorkspaceTools) plus "other".
8
+ // Any other name is sent as "other"; test/verify-analytics.mjs holds the two lists equal.
9
+ export const MCP_TOOL_NAMES = Object.freeze([
10
+ "describe_scene", "describe_shot", "render_prompt", "mark_camera_move", "describe_camera_move", "save_project", "set_camera",
11
+ "frame_shot", "add_character", "place_character", "remove_character", "focus_character", "place_object", "import_mesh",
12
+ "sculpt_object", "compare_sculpt", "group_objects", "update_object", "remove_object", "apply_batch", "add_scene",
13
+ "switch_scene", "open_project", "capture_frame", "studio_commands", "studio_run", "other",
15
14
  ]);
16
15
  export const AGENT_TOOL_CATEGORIES = Object.freeze([
17
16
  "workflow_read",
@@ -52,8 +51,8 @@ export const EXECUTION_TELEMETRY_PROPERTY_KEYS = Object.freeze({
52
51
  "agent:turn_failed": ["turn_id", "duration_bucket", "failure_code"],
53
52
  "agent:turn_cancelled": ["turn_id", "duration_bucket", "failure_code"],
54
53
  "agent:result_applied": ["turn_id"],
55
- "mcp:tool_requested": ["tool_category", "request_id"],
56
- "mcp:tool_executed": ["tool_category", "outcome", "duration_bucket", "request_id"],
54
+ "mcp:tool_requested": ["tool_category", "tool_name", "request_id"],
55
+ "mcp:tool_executed": ["tool_category", "tool_name", "outcome", "duration_bucket", "request_id"],
57
56
  "mcp:result_applied": ["request_id"],
58
57
  });
59
58
 
@@ -62,6 +61,7 @@ export const EXECUTION_TELEMETRY_VALUES = Object.freeze({
62
61
  node_count_bucket: new Set(["0", "1-3", "4-10", "gte11"]),
63
62
  duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
64
63
  tool_category: new Set([...MCP_TOOL_CATEGORIES, ...AGENT_TOOL_CATEGORIES]),
64
+ tool_name: new Set(MCP_TOOL_NAMES),
65
65
  outcome: new Set(["succeeded", "failed", "uncertain", "cancelled"]),
66
66
  });
67
67
 
@@ -90,8 +90,8 @@ export const EDIT_GROUPS = Object.freeze({
90
90
  export const EDIT_BUCKET_KEYS = Object.freeze(Object.keys(EDIT_GROUPS).map((group) => `${group}_edit_bucket`));
91
91
  export const USE_CASE_VALUES = Object.freeze(["animation", "film", "game", "ad_mv", "personal", "other", "skip"]);
92
92
  export const TEAM_VALUES = Object.freeze(["team", "solo", "skip"]);
93
- // Mirrors PREVIS_MODES in project.js; kept local so analytics stays import-light.
94
- export const PREVIS_MODE_VALUES = new Set(["storyboard", "animation"]);
93
+ // Mirror PREVIS_MODES and CAST_STYLES in project.js; kept local so analytics stays import-light.
94
+ export const PREVIS_MODE_VALUES = new Set(["storyboard", "animation"]), CAST_STYLE_VALUES = new Set(["rig", "capsule", "hybrid"]);
95
95
  export const UPDATE_STATUS_VALUES = Object.freeze(["latest", "outdated", "unknown"]);
96
96
  const DEFAULT_ALLOWED_ORIGINS = Object.freeze([
97
97
  "https://cozyclay.org",
@@ -112,7 +112,7 @@ const EVENT_PROPERTIES = Object.freeze({
112
112
  "hosted:ticket_created": [],
113
113
  "hosted:result_opened": [],
114
114
  "hosted:opened_in_studio": [],
115
- "scene:created": ["scene_source", "previs_mode"],
115
+ "scene:created": ["scene_source", "previs_mode", "cast_style"],
116
116
  "storyboard:panel_created": ["source"],
117
117
  "storyboard:panel_stylized": ["outcome"],
118
118
  "cast:proxy_added": ["surface"],
@@ -154,7 +154,7 @@ const SCULPT_RUN_MAX_ROUNDS = 10;
154
154
  const SCULPT_RUN_MAX_DURATION_MS = 86_400_000;
155
155
  const EXECUTION_FAILURE_CODES = Object.freeze({
156
156
  workflow: new Set(["aborted", "capture_failed", "generation_failed", "unknown"]),
157
- agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown", "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]),
157
+ agent: new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "network", "unknown", "key_refused", "stale_scene", "capability_missing", "busy", "invalid_request"]),
158
158
  });
159
159
  // Only a type label and a same-origin script location cross the boundary; the
160
160
  // error message, stack text, URLs and anything a user typed never do.
@@ -282,6 +282,8 @@ export function sanitizeProps(event, props) {
282
282
  else if (key === "tool_category") {
283
283
  const categories = event.startsWith("agent:") ? AGENT_TOOL_CATEGORIES : MCP_TOOL_CATEGORIES;
284
284
  if (!categories.includes(props[key])) continue;
285
+ } else if (key === "tool_name") {
286
+ if (!event.startsWith("mcp:") || !EXECUTION_TELEMETRY_VALUES.tool_name.has(props[key])) continue;
285
287
  } else if (key === "outcome") {
286
288
  const outcomes = event === "mcp:tool_executed" ? ["succeeded", "failed", "uncertain", "cancelled"] : ["succeeded", "failed", "cancelled"];
287
289
  if (!outcomes.includes(props[key])) continue;
@@ -311,7 +313,7 @@ export function sanitizeProps(event, props) {
311
313
  else if (key === "had_target" && typeof value === "boolean") sanitized[key] = value;
312
314
  continue;
313
315
  }
314
- if (key === "previs_mode" && !PREVIS_MODE_VALUES.has(props[key])) continue;
316
+ if ((key === "previs_mode" && !PREVIS_MODE_VALUES.has(props[key])) || (key === "cast_style" && !CAST_STYLE_VALUES.has(props[key]))) continue;
315
317
  if ((event.startsWith("storyboard:") || event.startsWith("cast:")) && !PREVIS_PROPERTY_VALUES[key]?.has(props[key])) continue;
316
318
  if (event === "survey:use_case") {
317
319
  if (key === "use_case" && !USE_CASE_VALUES.includes(props[key])) continue;
@@ -668,7 +670,10 @@ function runtimeConfig() {
668
670
  function isLoopbackOrigin(origin) {
669
671
  try {
670
672
  const parsed = new URL(origin);
671
- return parsed.protocol === "http:" && ["127.0.0.1", "localhost"].includes(parsed.hostname);
673
+ if (parsed.protocol !== "http:") return false;
674
+ // Loopback and LAN hosts both mean the studio is self-served, not hosted.
675
+ if (["127.0.0.1", "localhost"].includes(parsed.hostname)) return true;
676
+ return /^(10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(parsed.hostname);
672
677
  } catch {
673
678
  return false;
674
679
  }
@@ -715,7 +720,8 @@ export function resolveAnalyticsRuntime({
715
720
  distribution: "hosted",
716
721
  apiKey: env.VITE_POSTHOG_KEY,
717
722
  apiHost: env.VITE_POSTHOG_HOST || "https://us.i.posthog.com",
718
- appVersion: env.VITE_APP_VERSION || null,
723
+ // The deploy sets no VITE_APP_VERSION, so the bundled package version stands in.
724
+ appVersion: env.VITE_APP_VERSION || appVersion(),
719
725
  installationId: null,
720
726
  firstLaunch: false,
721
727
  firstLaunchHeardFrom: null,
@@ -743,7 +749,8 @@ function disabledReason(env) {
743
749
  return runtime.kind === "disabled" ? runtime.reason : getAnalyticsOptOut() ? "opted out" : null;
744
750
  }
745
751
 
746
- export async function initAnalytics() {
752
+ /** `lite` (the landing page): $pageview and explicit events only; no session, error, long-task or device signals. */
753
+ export async function initAnalytics({ lite = false } = {}) {
747
754
  if (initialized || initPromise) return initPromise;
748
755
  // Explicit hosted opt-in only. This flag never changes consent or build
749
756
  // policy; npm installations use the CLI-owned state instead.
@@ -781,7 +788,9 @@ export async function initAnalytics() {
781
788
  // Hosted visits persist in the browser. Official npm sessions
782
789
  // bootstrap from the CLI-owned installation id instead.
783
790
  persistence: resolved.distribution === "npm" ? "memory" : "localStorage",
784
- respect_dnt: true,
791
+ // Browser DNT is set by extensions and OS defaults, not by a deliberate
792
+ // per-site choice; the preference toggle remains the explicit signal.
793
+ respect_dnt: false,
785
794
  disable_session_recording: true,
786
795
  capture_dead_clicks: false,
787
796
  capture_performance: false,
@@ -809,11 +818,8 @@ export async function initAnalytics() {
809
818
  globalThis.__cozyclayAnalytics = { instance: posthog };
810
819
  posthog.capture("$pageview");
811
820
  sessionStartedAt = Date.now();
812
- installSessionEndListeners(resolved, globalProperties);
813
- installErrorListeners();
814
- observeLongTasks();
815
- void recordDeviceProfile();
816
- if (resolved.distribution === "npm") {
821
+ if (!lite) { installSessionEndListeners(resolved, globalProperties); installErrorListeners(); observeLongTasks(); void recordDeviceProfile(); }
822
+ if (resolved.distribution === "npm" && !lite) {
817
823
  track("app:session_started");
818
824
  if (resolved.firstLaunch) {
819
825
  const heardFrom = resolved.firstLaunchHeardFrom;
@@ -0,0 +1,13 @@
1
+ // The version Help › About names. The npm launcher injects the running
2
+ // package's version as window.__COZYCLAY_RUNTIME__.appVersion; the hosted
3
+ // site and Vite dev have no runtime, so they fall back to package.json the
4
+ // way the start screen does (src/project-browser.jsx).
5
+ import packageInfo from "../package.json" with { type: "json" };
6
+
7
+ export function appVersion(win = globalThis.window, fallback = packageInfo.version) {
8
+ try {
9
+ const runtime = win?.__COZYCLAY_RUNTIME__?.appVersion;
10
+ if (typeof runtime === "string" && runtime.trim()) return runtime.trim();
11
+ } catch { /* a throwing getter reads as no runtime */ }
12
+ return fallback;
13
+ }
@@ -0,0 +1,39 @@
1
+ // Where a Content tile lands: one spawner per payload kind, the same table for
2
+ // the drag onto the stage (App's floor raycast) and the double-click placement
3
+ // at the origin (LibrarySlot). Characters keep their tighter stage clamp (the
4
+ // rig walks, a prop does not); objects, cutouts, meshes and sculpts take the
5
+ // ROOM_LIMIT clamp their creators already apply.
6
+ import { elementByPath } from "./studio-elements.js";
7
+
8
+ const CHARACTER_BOUNDS = elementByPath("character.position").gizmo;
9
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
10
+
11
+ const DROPS = {
12
+ character: (payload, at, spawn) => spawn.spawnCharacter(payload.id,
13
+ clamp(at.x, CHARACTER_BOUNDS.min.x, CHARACTER_BOUNDS.max.x),
14
+ clamp(at.z, CHARACTER_BOUNDS.min.z, CHARACTER_BOUNDS.max.z)),
15
+ object: (payload, at, spawn) => spawn.addSceneObject(payload.objectKind, at),
16
+ image: (payload, at, spawn) => spawn.spawnCutoutAt(payload.assetId, at),
17
+ mesh: (payload, at, spawn) => spawn.spawnMeshAt(payload.assetId, at),
18
+ // A shelf card carries its recipe: object.sculpt makes it through
19
+ // createSculptObject as one undo step, and the new sculpt is selected.
20
+ sculpt: (payload, at, spawn) => {
21
+ const receipt = spawn.runStudioAction("object.sculpt", { recipe: payload.recipe, ...(payload.name ? { name: payload.name } : {}), placement: at });
22
+ const id = receipt?.affectedIds?.[0];
23
+ if (id) spawn.select?.(`object:${id}`);
24
+ return receipt;
25
+ },
26
+ };
27
+
28
+ /**
29
+ * Spawn `payload` at the floor point `hit` ({ x, z }; y is ignored). `spawn`
30
+ * names the creators: spawnCharacter, addSceneObject, spawnCutoutAt,
31
+ * spawnMeshAt, runStudioAction and select. An unknown kind does nothing and
32
+ * answers false.
33
+ */
34
+ export function dropAssetAt(payload, hit, spawn) {
35
+ const drop = DROPS[payload?.kind];
36
+ if (!drop) return false;
37
+ drop(payload, { x: hit.x, z: hit.z }, spawn);
38
+ return true;
39
+ }
@@ -460,3 +460,37 @@
460
460
  scrollbar-color: var(--surface-active, #26262a) transparent;
461
461
  }
462
462
  }
463
+
464
+ @layer regions {
465
+ /* #740: the Sculpts folder's import / export / delete row above its grid. */
466
+ .content-browser .sculpt-shelf-actions {
467
+ display: flex;
468
+ flex-wrap: wrap;
469
+ align-items: center;
470
+ gap: var(--sp-1, 8px);
471
+ margin-bottom: var(--sp-1, 8px);
472
+ }
473
+
474
+ .content-browser .sculpt-shelf-actions button {
475
+ padding: 3px 10px;
476
+ border: 1px solid var(--line2, #2c2c31);
477
+ border-radius: var(--r-1, 4px);
478
+ background: var(--surface-raised, #1e1e21);
479
+ color: var(--text-row, #c9c9ce);
480
+ font-size: var(--content-font-secondary);
481
+ cursor: pointer;
482
+ }
483
+
484
+ .content-browser .sculpt-shelf-actions button:hover {
485
+ border-color: var(--select, #e8a33d);
486
+ }
487
+
488
+ .content-browser .sculpt-shelf-note {
489
+ color: var(--text-3, #636369);
490
+ font-size: var(--content-font-secondary);
491
+ }
492
+
493
+ .content-browser .sculpt-shelf-delete {
494
+ color: var(--danger, #e5484d);
495
+ }
496
+ }
@@ -7,6 +7,8 @@ import { assetAspect, isImageAssetId, isMeshAssetId, isSupportedMeshType } from
7
7
  import { assetKind, formatAssetBytes } from "./asset-shelf.js";
8
8
  import { assetRecord } from "./scene-asset-cache.js";
9
9
  import ResourceStatus from "./resource-status.jsx";
10
+ import { ContentTile, grabProps } from "./content-tile.jsx";
11
+ import SculptShelf from "./sculpt-shelf-tiles.jsx";
10
12
  import { logStore } from "./shell/log-store.js";
11
13
  import { SearchEmpty } from "./search-friction.js";
12
14
  import "./asset-pane.css";
@@ -130,18 +132,6 @@ function loadThumb(id) {
130
132
  return thumbCache.get(id);
131
133
  }
132
134
 
133
- /** The grab wire every card shares: left button only, App owns the rest. */
134
- function grabProps(onAssetGrab, payload) {
135
- return {
136
- onPointerDown: (event) => {
137
- if (event.button !== 0) return;
138
- event.preventDefault();
139
- onAssetGrab?.(payload, event);
140
- },
141
- };
142
- }
143
-
144
-
145
135
  function StorageAssetRow({ id, onDelete, deleting, usageCount = 0, graphSignature }) {
146
136
  const [thumb, setThumb] = useState(null);
147
137
  // Capture the graph observed when the explicit confirmation opens. A render
@@ -245,29 +235,6 @@ function StorageManager({ unusedAssetIds, usedAssetIds, usageCounts, graphSignat
245
235
  }
246
236
 
247
237
 
248
- /** The Content grid's one tile grammar: a square thumbnail, an 11px name and
249
- * a 10px type line. Click selects (amber ring), a left-button press starts the
250
- * App-owned drag, double-click places the asset at the origin. */
251
- function ContentTile({ assetKey, label, type, title, preview, selected, onSelect, grab, onPlace, failed = false }) {
252
- return (
253
- <button
254
- type="button"
255
- className={"content-tile" + (failed ? " is-failed" : "")}
256
- data-testid="content-asset"
257
- data-asset-key={assetKey}
258
- aria-pressed={selected}
259
- title={title}
260
- {...(grab ?? {})}
261
- onClick={() => onSelect(assetKey)}
262
- onDoubleClick={onPlace ? () => onPlace() : undefined}
263
- >
264
- <span className="content-tile-thumb">{preview}</span>
265
- <span className="content-tile-name">{label}</span>
266
- <span className="content-tile-type">{type}</span>
267
- </button>
268
- );
269
- }
270
-
271
238
  function useThumb(id) {
272
239
  // null = decoding (skeleton), undefined = record gone, object = ready.
273
240
  const [thumb, setThumb] = useState(null);
@@ -375,6 +342,7 @@ const FOLDERS = [
375
342
  { id: "cameras", label: () => ko("Cameras", "카메라") },
376
343
  { id: "motions", label: () => ko("Motions", "모션") },
377
344
  { id: "poses", label: () => ko("Poses", "포즈") },
345
+ { id: "sculpts", label: () => ko("Sculpts", "조형물", "雕塑") },
378
346
  ];
379
347
 
380
348
  function catalogueTile(entry, props) {
@@ -403,6 +371,7 @@ export function FolderGrid({ folder, query, ...props }) {
403
371
  const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
404
372
  let tiles = [];
405
373
  let empty = null;
374
+ if (folder === "sculpts") return <SculptShelf query={query} {...props} />;
406
375
  if (folder === "basic") {
407
376
  tiles = OBJECT_LIBRARY
408
377
  .filter((entry) => entry.group === "Primitives" && matchesQuery(displayObjectLabel(entry.label), query))
package/src/bus-tasks.js CHANGED
@@ -167,7 +167,7 @@ export function createBusTasks({ ports, emit }) {
167
167
  task.edits.push({ receiptId: recorded.receiptId, values: point.values, keys: point.keys, mark: point.mark });
168
168
  task.affectedIds = [...new Set([...task.affectedIds, ...recorded.affectedIds])];
169
169
  if (recorded.undo?.historyEntryId) entryTask.set(recorded.undo.historyEntryId, task.taskId);
170
- for (const other of open.values()) for (const key of point.keys) if (other !== task && other.reads.has(key)) {
170
+ for (const other of open.values()) for (const key of point.keys) if (other !== task && [...other.reads].some(read => keysOverlap(read, key))) {
171
171
  other.notices.push({ code: 'DEPENDENCY_CHANGED', message: `${key}, which this task reads, was changed by task ${task.taskId}.` });
172
172
  emit?.({ type: 'task.dependencyChanged', taskId: other.taskId, key, by: task.taskId });
173
173
  }
@@ -5,25 +5,27 @@
5
5
  import { isProxyFigure } from "./scenes.js";
6
6
  import { isKo, isZh } from "./locale.js";
7
7
  import { StudioProtocolError } from "./studio-agent-protocol.js";
8
+ import {
9
+ PROXY_BODY_HEIGHT, PROXY_BODY_RADIUS, PROXY_HEAD_LIFT, PROXY_HEAD_RADIUS, PROXY_SIT_LEG_REACH, PROXY_SIT_TORSO_HEIGHT,
10
+ } from "./proxy-figure-shape.js";
8
11
 
9
12
  export const CHARACTER_CAPABILITIES = Object.freeze(["rig", "ik", "pose", "motion"]);
10
- const PROXY_BODY_RADIUS = 0.22;
11
- const PROXY_BODY_HEIGHT = 1.45;
12
- const PROXY_HEAD_RADIUS = 0.12;
13
13
 
14
14
  /** Return a capsule figure's rendered world-space bounds without a rig. */
15
15
  export function proxyFigureBounds(character, placement = character) {
16
16
  const posture = character?.posture === "sit" || character?.posture === "lie" ? character.posture : "stand";
17
- const bodyHeight = posture === "sit" ? PROXY_BODY_HEIGHT * 0.6 : PROXY_BODY_HEIGHT;
17
+ const bodyHeight = posture === "sit" ? PROXY_SIT_TORSO_HEIGHT : PROXY_BODY_HEIGHT;
18
+ const top = bodyHeight + (PROXY_HEAD_LIFT + PROXY_HEAD_RADIUS);
18
19
  const stature = Number.isFinite(placement?.scale) ? placement.scale : Number.isFinite(character?.scale) ? character.scale : 1;
19
20
  const x = Number.isFinite(placement?.x) ? placement.x : character?.x ?? 0;
20
21
  const y = Number.isFinite(placement?.y) ? placement.y : character?.y ?? 0;
21
22
  const z = Number.isFinite(placement?.z) ? placement.z : character?.z ?? 0;
22
23
  const yaw = ((Number.isFinite(placement?.rot) ? placement.rot : character?.rot ?? 0) + (posture === "lie" ? 180 : 0)) * Math.PI / 180;
23
24
  const [minCorner, maxCorner] = posture === "lie"
24
- ? [[-PROXY_BODY_RADIUS, PROXY_BODY_RADIUS - PROXY_HEAD_RADIUS, -(bodyHeight + 0.25)],
25
+ ? [[-PROXY_BODY_RADIUS, PROXY_BODY_RADIUS - PROXY_HEAD_RADIUS, -top],
25
26
  [PROXY_BODY_RADIUS, PROXY_BODY_RADIUS + PROXY_HEAD_RADIUS, 0]]
26
- : [[-PROXY_BODY_RADIUS, 0, -PROXY_BODY_RADIUS], [PROXY_BODY_RADIUS, bodyHeight + 0.25, PROXY_BODY_RADIUS]];
27
+ // Sit reaches forward (local +z, the facing direction) with its legs.
28
+ : [[-PROXY_BODY_RADIUS, 0, -PROXY_BODY_RADIUS], [PROXY_BODY_RADIUS, top, posture === "sit" ? PROXY_SIT_LEG_REACH : PROXY_BODY_RADIUS]];
27
29
  const min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
28
30
  for (const lx of [minCorner[0], maxCorner[0]]) for (const ly of [minCorner[1], maxCorner[1]]) for (const lz of [minCorner[2], maxCorner[2]]) {
29
31
  const sx = lx * stature, sy = ly * stature, sz = lz * stature;
@@ -8,7 +8,8 @@
8
8
  // or element ids. Typing targets and IME composition are ignored outright.
9
9
  import { track } from "./analytics.js";
10
10
  import { admit, bump } from "./analytics-friction.js";
11
- import { controlOf } from "./analytics-controls.js";
11
+ import { controlOf, isUnnamedControl } from "./analytics-controls.js";
12
+ import { hintDisabledClick } from "./friction-hint.js";
12
13
 
13
14
  const RAGE_WINDOW_MS = 1000, RAGE_RADIUS_PX = 24, RAGE_MIN = 3, RAGE_COOLDOWN_MS = 10_000, EFFECT_MS = 500;
14
15
  const RAGE_PER_CONTROL = 2, DEAD_PER_CONTROL = 2;
@@ -55,7 +56,7 @@ export function notePointerDown({ target, x, y, event, observe = () => () => {}
55
56
  const element = target?.closest?.(CONTROL);
56
57
  if (!element) return;
57
58
  const fid = element.closest("[data-fid]")?.getAttribute("data-fid");
58
- const control = controlOf(fid);
59
+ const control = controlOf(fid, element);
59
60
  const at = now();
60
61
  // A rage burst: the same control, within 1 s of its first click and 24 px of where it began.
61
62
  if (burst && burst.control === control && burst.element === element && at - burst.at <= RAGE_WINDOW_MS && Math.hypot(x - burst.x, y - burst.y) <= RAGE_RADIUS_PX) {
@@ -66,9 +67,9 @@ export function notePointerDown({ target, x, y, event, observe = () => () => {}
66
67
  burst = { control, element, at, x, y, count: 1, changed: () => lastReceiptAt >= since || lastMutationAt >= since };
67
68
  burstTimer = setTimeout(finishBurst, RAGE_WINDOW_MS);
68
69
  }
69
- if (disabledOf(element)) { dead(control, "aria_disabled"); return; }
70
+ if (disabledOf(element)) { dead(control, "aria_disabled"); hintDisabledClick(element, control); return; }
70
71
  // refused / no_effect: watched only on the closed controls, whose clicks are meant to do something.
71
- if (control === "other") return;
72
+ if (isUnnamedControl(control)) return;
72
73
  const stop = observe(() => { lastMutationAt = now(); });
73
74
  setTimeout(() => {
74
75
  try { stop(); } catch { /* the observer is advisory */ }
@@ -39,10 +39,8 @@ const EDIT_KINDS = [
39
39
  // The take's fields carry its prompt (motion.take.prompt).
40
40
  [/^motion\.set$/, 'prompt_block_edit'],
41
41
  ];
42
- // Built on first use: this module loads inside an import cycle (analytics.js -> semantic-edit.js ->
43
- // command-bus.js -> here), where EDIT_GROUPS is not initialized yet.
44
- let groups = null;
45
- const groupOf = kind => (groups ??= new Map(Object.entries(EDIT_GROUPS).flatMap(([group, kinds]) => kinds.map(entry => [entry, group])))).get(kind);
42
+ const GROUP_BY_KIND = new Map(Object.entries(EDIT_GROUPS).flatMap(([group, kinds]) => kinds.map(entry => [entry, group])));
43
+ const groupOf = kind => GROUP_BY_KIND.get(kind);
46
44
  /** A command's closed edit kind, or null. `hint`: asset.import's closed `placeAs`, and `rail` when a
47
45
  * shot.setCamera patch names a rail field (key names only; values are never read). */
48
46
  export function firstEditKindOf(action, { placeAs, rail = false } = {}) {
@@ -83,15 +81,18 @@ const emit = (event, props) => {
83
81
  try { send(event, props); } catch { /* telemetry is advisory */ }
84
82
  };
85
83
 
86
- /** A refusal receipt: the session counter always moves; the event, at most 3 times per (code, group, origin). */
87
- export function noteRefusal(value, origin, id) {
84
+ /** A refusal receipt: the session counter always moves; the event, at most 3 times per (code, command, origin).
85
+ * `registered`: `id` is an action the registry declares, so it may ride along as `command`; an id the caller
86
+ * made up (an unknown action) is reported by its group alone. */
87
+ export function noteRefusal(value, origin, id, registered = false) {
88
88
  // A cancel the caller asked for is not a refusal. A UI one tells a click it was refused (click-friction.js);
89
89
  // an agent's or MCP client's refusal at the same moment is not the user's click.
90
90
  if (value.code === 'CANCELLED') return;
91
91
  if (origin === 'ui') noteBusReceipt(false);
92
92
  const by = origin === 'ui' ? 'ui' : 'agent', code = refusalCodeOf(value.code), group = commandGroup(id);
93
93
  bump(by === 'ui' ? 'refusals_ui' : 'refusals_agent');
94
- if (admit(`studio:refused:${code}:${group}:${by}`, REFUSALS_PER_KEY)) emit('studio:refused', { code, origin: by, phase: value.phase, group });
94
+ const command = registered ? String(id) : undefined;
95
+ if (admit(`studio:refused:${code}:${command ?? group}:${by}`, REFUSALS_PER_KEY)) emit('studio:refused', { code, origin: by, phase: value.phase, group, ...(command ? { command } : {}) });
95
96
  }
96
97
 
97
98
  // receiptId -> { at, kind }: when a history receipt was recorded and what it did.
@@ -12,8 +12,6 @@ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
12
12
  const fail = (code, message) => { throw new StudioProtocolError(code, message); };
13
13
  const mapResult = (value, success, failure) => value?.then ? value.then(success, failure) : success(value);
14
14
  const toastWarnings = toasts => toasts.slice(-12).map(toast => ({ code: 'STUDIO_TOAST', message: [...toast.message].slice(0, 120).join('') }));
15
- let activeRunDepth = 0;
16
- export const isBusRunActive = () => activeRunDepth > 0;
17
15
 
18
16
  export function createCommandBus({ registry, ports }) {
19
17
  const pending = new Map(), transactions = new Map(), jobs = new Map(), listeners = new Set();
@@ -211,7 +209,7 @@ export function createCommandBus({ registry, ports }) {
211
209
  catch (error) { if (level >= 2 || error?.code !== 'INVALID_RECEIPT' || !/8 KiB/.test(error.message)) throw error; }
212
210
  }
213
211
  }
214
- function executeRun(id, args = {}, options = {}) {
212
+ function run(id, args = {}, options = {}) {
215
213
  const request = { origin: 'ui', commandId: crypto.randomUUID(), ...options };
216
214
  if (request.origin === 'ui' && (id === 'edit.undo' || id === 'edit.redo')) ports.finishHistoryGesture?.();
217
215
  const before = ports.read(), journal = ports.journal();
@@ -251,7 +249,7 @@ export function createCommandBus({ registry, ports }) {
251
249
  if (request.origin === 'ui' && error.uiMessage) ports.showRefusal?.(error.uiMessage);
252
250
  const value = refusal(request, before, error, job ? applied : undefined);
253
251
  // Session friction and studio:refused, by who asked and what (command-bus-telemetry.js).
254
- noteRefusal(value, request.origin, action ?? id);
252
+ noteRefusal(value, request.origin, action ?? id, registry.ids?.().includes(action ?? id) === true);
255
253
  const recorded = begun ? remember(value) : value;
256
254
  if (job && !job.completion) { job.outcome = recorded; job.completion = Promise.resolve(recorded); emit({ type: 'job.completed', jobId: job.id, receipt: recorded }); }
257
255
  return recorded;
@@ -368,18 +366,6 @@ export function createCommandBus({ registry, ports }) {
368
366
  return answer;
369
367
  } catch (error) { return rejected(error); }
370
368
  }
371
- function run(...args) {
372
- activeRunDepth++;
373
- try {
374
- const value = executeRun(...args);
375
- if (value?.then) return value.finally(() => { activeRunDepth--; });
376
- activeRunDepth--;
377
- return value;
378
- } catch (error) {
379
- activeRunDepth--;
380
- throw error;
381
- }
382
- }
383
369
  // noteUndone: edit:undone for the user's own undo (domains/scene-undo.js), which reaches it through the bus.
384
370
  return { run, noteUndone,
385
371
  // Trusted UI adapter only: wire callers can consume, never mint a token.
@@ -64,7 +64,8 @@ export function register(registry, ports) {
64
64
  if (owner().read().some(entry => entry.id === row.id)) fail('INVALID_ARGUMENT', 'Character id already exists.');
65
65
  owner().write(rows => [...rows, row]);
66
66
  },
67
- 'character.remove': ({ characterId }) => { character(characterId); if (owner().read().length <= 1) fail('INVALID_ARGUMENT', 'Cannot remove the final character.'); owner().write(rows => rows.filter(row => row.id !== characterId)); },
67
+ // An empty stage is legal: a new project starts on one.
68
+ 'character.remove': ({ characterId }) => { character(characterId); owner().write(rows => rows.filter(row => row.id !== characterId)); },
68
69
  'character.update': ({ characterId, patch: value }) => {
69
70
  // Rig <-> capsule figure is a different kind of cast member; rig-to-rig model swaps stay.
70
71
  if (Object.hasOwn(value, 'model') && (value.model === PROXY_FIGURE_MODEL) !== isProxyFigure(character(characterId))) fail('INVALID_ARGUMENT', KIND_FIXED_REFUSAL.en);
@@ -17,8 +17,9 @@ import * as exporting from "./export.js";
17
17
  import * as ai from "./ai.js";
18
18
  import * as stage from "./stage.js";
19
19
  import * as kits from "./kits.js";
20
+ import * as physics from "./physics.js";
20
21
 
21
- export const COMMAND_MODULES = Object.freeze({ shot, cast, motion, motion3, objects, kits, view, scene, project, export: exporting, ai, stage });
22
+ export const COMMAND_MODULES = Object.freeze({ shot, cast, motion, motion3, physics, objects, kits, view, scene, project, export: exporting, ai, stage });
22
23
 
23
24
  export function commandDeclarations(modules = COMMAND_MODULES) {
24
25
  return Object.values(modules).flatMap(module => module.declarations.map(declaration => ({
@@ -26,6 +26,7 @@ import { normalizeCompileArgs } from "../motion3/lenient.js";
26
26
  import { createCompileRunner } from "./motion3-compile.js";
27
27
  import { createRepairWatch } from "../motion3/repair-watch.js";
28
28
  import { peopleOf } from "../motion3/people.js";
29
+ import { hybridCapsuleHint } from "../motion3/hybrid-hint.js";
29
30
 
30
31
  const TIMELINE_FPS = 24;
31
32
  const id = { type: "string", minLength: 1 };
@@ -181,7 +182,8 @@ export function register(registry, ports) {
181
182
  if (best) timing = put(character, best.take, context) ?? timing;
182
183
  const self = best?.specId === own, how = kept ? "; already installed, unchanged" : self ? "; installed as the best take so far"
183
184
  : best ? `; not installed; the best take so far, ${best.specId}, installed` : install === "ifClean" ? (installs ? "; installed" : "; not installed") : "";
184
- const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: Boolean(wanted) || self, ...(stuck ? { stuck } : {}), ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
185
+ const hint = hybridCapsuleHint(ports.storeDomain?.("scenes")?.metadata?.().castStyle, { stuck, qa });
186
+ const output = report(r, { frames: r.frameCount, fps: TIMELINE_FPS, installed: Boolean(wanted) || self, ...(stuck ? { stuck } : {}), ...(hint ? { hint } : {}), ...adjustedOf(notes) }, { ...delta, ...(r.repair ? { repair: r.repair } : {}), ...(timing ? { timing } : {}) });
185
187
  // agent-progress.js: viewport ghost, timeline beats, agent panel
186
188
  const draft = publishDraft(characterId, { take, installed: installs || kept || self, specId: output.specId, label: r.label, qa, timeline: r.timeline });
187
189
  noteDraftCompiled(draft, r, { installed: installs || kept || self, stuck, repaired: Boolean(r.repair), adjusted: notes.length > 0 });