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
@@ -0,0 +1,75 @@
1
+ // Multi-view sculpt comparison (#970): one sculpt scored against several views
2
+ // of its subject (the blueprint's front, side and back crops), each by
3
+ // compareSculptToReference, then blended by weight into one IoU.
4
+ import { compareSculptToReference } from "./sculpt-compare.js";
5
+ import { patchSculptRecipe } from "./sculpt-recipe.js";
6
+
7
+ /** The default weight of each view: the front carries the likeness, the side the depth, the back the rest. */
8
+ export const VIEW_WEIGHTS = Object.freeze({ front: 0.6, side: 0.25, back: 0.15 });
9
+ /** A view whose subject's height:width differs from the front's by more than this is drawn at another scale or pose: weight 0. */
10
+ export const VIEW_RATIO_TOLERANCE = 0.35;
11
+
12
+ /** front (|yaw| <= 45), back (|yaw| >= 135) or side, from the camera yaw in degrees. */
13
+ export function viewOfYaw(yaw) {
14
+ const turn = Math.abs(((Number(yaw) % 360) + 540) % 360 - 180);
15
+ return turn <= 45 ? "front" : turn >= 135 ? "back" : "side";
16
+ }
17
+
18
+ /**
19
+ * The weight each view gets. `views` are [{ yaw, weight?, heightToWidth }]:
20
+ * an explicit weight replaces the table's; then any view whose height:width
21
+ * differs from the front's by more than VIEW_RATIO_TOLERANCE gets weight 0 with
22
+ * the reason. The front is the front view nearest yaw 0; without one the ratio
23
+ * rule is skipped. Returns [{ weight, excluded? }] in the views' order.
24
+ */
25
+ export function viewWeights(views) {
26
+ const fronts = views.map((view, index) => ({ index, yaw: Math.abs(Number(view.yaw)) })).filter(({ index }) => viewOfYaw(views[index].yaw) === "front");
27
+ const front = fronts.sort((a, b) => a.yaw - b.yaw)[0];
28
+ const frontRatio = front ? views[front.index].heightToWidth : null;
29
+ return views.map((view, index) => {
30
+ const weight = view.weight ?? VIEW_WEIGHTS[viewOfYaw(view.yaw)];
31
+ if (!front || index === front.index || !(frontRatio > 0)) return { weight };
32
+ const difference = Math.abs(view.heightToWidth / frontRatio - 1);
33
+ if (difference <= VIEW_RATIO_TOLERANCE) return { weight };
34
+ return { weight: 0, excluded: `height:width ${view.heightToWidth.toFixed(2)} differs from the front's ${frontRatio.toFixed(2)} by ${Math.round(difference * 100)}% (over ${VIEW_RATIO_TOLERANCE * 100}%)` };
35
+ });
36
+ }
37
+
38
+ const round = value => Number(value.toFixed(3));
39
+
40
+ /**
41
+ * Score `recipe` against every view ([{ yaw, image, weight? }], images RGBA) and
42
+ * blend the IoUs by weight. The report is the heaviest view's (its sheet too),
43
+ * with `iou` the weighted one and `views` one row per view.
44
+ */
45
+ function scoreViews(recipe, views, elevation, weights) {
46
+ const results = views.map(view => compareSculptToReference(recipe, view.image, { yaw: view.yaw, elevation }));
47
+ const used = weights ?? viewWeights(views.map((view, index) => ({ yaw: view.yaw, weight: view.weight, heightToWidth: 1 / results[index].report.widthToHeight.reference })));
48
+ const total = used.reduce((sum, row) => sum + row.weight, 0);
49
+ if (!(total > 0)) throw new Error("No view carries any weight: every view was excluded or given weight 0.");
50
+ const primary = used.reduce((best, row, index) => (row.weight > used[best].weight ? index : best), 0);
51
+ const iou = results.reduce((sum, result, index) => sum + result.report.iou * used[index].weight, 0) / total;
52
+ const rows = results.map(({ report }, index) => ({ view: viewOfYaw(views[index].yaw), yaw: views[index].yaw, weight: round(used[index].weight / total), iou: report.iou,
53
+ widthToHeight: report.widthToHeight, missingByBand: report.missingByBand, extraByPart: report.extraByPart.slice(0, 3), ...(used[index].excluded ? { excluded: used[index].excluded } : {}) }));
54
+ return { result: { report: { ...results[primary].report, iou: round(iou), views: rows }, sheet: results[primary].sheet }, weights: used };
55
+ }
56
+
57
+ /**
58
+ * The multi-view twin of scoreSculptAgainstReference: same return shape
59
+ * ({ result, ranked?, refused? }), so sculpt.compare and compare_sculpt answer
60
+ * one way. Candidates are scored on the same views with the same weights.
61
+ */
62
+ export function scoreSculptAgainstViews(recipe, views, { elevation = 0, candidates } = {}) {
63
+ if (!Array.isArray(views) || !views.length) throw new Error("views needs at least one { yaw, reference }.");
64
+ const { result, weights } = scoreViews(recipe, views, elevation);
65
+ if (!candidates) return { result };
66
+ const ranked = [{ label: "current", result }];
67
+ const refused = [];
68
+ for (const candidate of candidates) {
69
+ const patched = patchSculptRecipe(recipe, candidate.edits);
70
+ if (!patched.ok) refused.push({ label: candidate.label, error: patched.error });
71
+ else ranked.push({ label: candidate.label, result: scoreViews(patched.recipe, views, elevation, weights).result });
72
+ }
73
+ ranked.sort((a, b) => b.result.report.iou - a.result.report.iou);
74
+ return { result, ranked, refused };
75
+ }
@@ -1,6 +1,3 @@
1
- import { createCameraBlock } from "./camera-block.js";
2
- import { isBusRunActive } from "./command-bus.js";
3
-
4
1
  // 2: counted from the user's own command-bus receipts (#922); 1 diffed domain values and
5
2
  // missed shot, camera and object edits in the real Studio.
6
3
  export const FIRST_EDIT_VERSION = 2;
@@ -10,62 +7,6 @@ export const FIRST_EDIT_KINDS = Object.freeze([
10
7
  "prompt_block_add", "prompt_block_edit",
11
8
  ]);
12
9
 
13
- // Compare authored values, not object identities. Solvers and pointer callbacks
14
- // can reconstruct the same pose with floating-point roundoff. Values stay local;
15
- // only the closed edit-kind vocabulary ever crosses the analytics boundary.
16
- function equal(a, b) {
17
- if (a === b) return true;
18
- if (typeof a === "number" && typeof b === "number") return Math.abs(a - b) < 1e-8;
19
- if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
20
- if (a instanceof Map || b instanceof Map) {
21
- return a instanceof Map && b instanceof Map && a.size === b.size
22
- && [...a].every(([key, value]) => b.has(key) && equal(value, b.get(key)));
23
- }
24
- const keys = Object.keys(a);
25
- return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && equal(a[key], b[key]));
26
- }
27
- const poseValue = (pose) => ({ bones: pose?.bones ?? {}, rootY: pose?.rootY ?? 0 });
28
-
29
- const keyValues = (keys = []) => keys.map(({ frame, framing }) => ({ frame, framing }));
30
- const shotValue = (shot) => ({ ...shot, camera: createCameraBlock(shot.camera), cameraKeys: keyValues(shot.cameraKeys) });
31
-
32
- export function semanticEditKind(domain, before, after) {
33
- if (before === after) return null;
34
- if (domain === "pose") return equal(before, after) ? null : "pose_edit";
35
-
36
- if (domain === "objects") {
37
- const added = after.find((item) => !before.some((previous) => previous.id === item.id));
38
- if (added) return added.renderer === "cutout" ? "cutout_insert" : "object_insert";
39
- return equal(before, after) ? null : "object_transform";
40
- }
41
- if (domain === "characters") {
42
- if (after.some((item) => !before.some((previous) => previous.id === item.id))) return "object_insert";
43
- for (const item of after) {
44
- const previous = before.find((entry) => entry.id === item.id);
45
- if (previous && !equal(poseValue(previous.pose), poseValue(item.pose))) return "pose_edit";
46
- }
47
- // Motion buffers/layer synchronization are passive, not cast transforms.
48
- const placement = (items) => items.map(({ id, x, y = 0, z, rot, scale = 1, hidden = false, model, tint, subject, identityImage }) => ({ id, x, y, z, rot, scale, hidden, model, tint, subject, identityImage }));
49
- return equal(placement(before), placement(after)) ? null : "object_transform";
50
- }
51
- if (domain === "shots") {
52
- if (after.some((item) => !before.some((previous) => previous.id === item.id))) return "shot_add";
53
- for (const item of after) {
54
- const previous = before.find((entry) => entry.id === item.id);
55
- if (!previous) continue;
56
- const camera = createCameraBlock(item.camera), oldCamera = createCameraBlock(previous.camera);
57
- if (["cameraRail", "craneHeight", "railFollow", "dollyTiming"].some((key) => !equal(camera[key], oldCamera[key]))) return "rail_edit";
58
- const keys = item.cameraKeys ?? [], oldKeys = previous.cameraKeys ?? [];
59
- if (keys.length > oldKeys.length || keys.some((key) => {
60
- const old = oldKeys.find((entry) => entry.frame === key.frame);
61
- return old && !equal(old.framing, key.framing);
62
- })) return "camera_key_record";
63
- }
64
- return equal(before.map(shotValue), after.map(shotValue)) ? null : "shot_edit";
65
- }
66
- return null;
67
- }
68
-
69
10
  /** (surface, kind) -> the surface's first edit, once: `${surface}:first_edit` with a closed kind. */
70
11
  export function createFirstEditRecorder(emit) {
71
12
  // Dedupe once per editor App mount, independently for Studio and Playground.
@@ -80,31 +21,3 @@ export function createFirstEditRecorder(emit) {
80
21
  record.seen = surface => seen.has(surface);
81
22
  return record;
82
23
  }
83
-
84
- /** The same dedupe over a domain diff (semanticEditKind), for callers that hold before/after values. */
85
- export function createFirstEditTracker(emit) {
86
- const record = createFirstEditRecorder(emit);
87
- return (surface, domain, before, after) => {
88
- if (record.seen(surface)) return false;
89
- const kind = semanticEditKind(domain, before, after);
90
- return kind ? record(surface, kind) : false;
91
- };
92
- }
93
-
94
- // Synchronous mutation owner shared by UI and live commands. Keeping the before
95
- // value here avoids stale React closures and analytics inside replayable updater
96
- // functions. Passive writes (load, history, navigation) advance the same value
97
- // without reporting; a rejected reducer never publishes or reports anything.
98
- export function createSemanticState(initial, publish, observe, domain) {
99
- let current = initial;
100
- const apply = (update, authored) => {
101
- const before = current;
102
- const after = typeof update === "function" ? update(before) : update;
103
- current = after;
104
- if (authored && !isBusRunActive()) console.warn(`[bus] semantic edit for ${domain} called outside a bus run`);
105
- publish(after);
106
- if (authored) observe(domain, before, after);
107
- return after;
108
- };
109
- return { set: (update) => apply(update, false), edit: (update) => apply(update, true) };
110
- }
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from "react";
2
2
  import { ko, localeChosen } from "./locale.js";
3
3
  import PreferencesDialog from "./shell/PreferencesDialog.jsx";
4
4
 
5
- // App settings — language and analytics — behind one labelled topbar trigger
5
+ // App settings — language — behind one labelled topbar trigger
6
6
  // (#193, docs/studio-ui-ia.md R4). Neither item edits the document, so they do
7
7
  // not belong on the Save/Export row as bare toggles.
8
8
  //
@@ -38,7 +38,7 @@ export default function SettingsMenu() {
38
38
  data-testid="settings-menu-trigger"
39
39
  aria-expanded={open}
40
40
  aria-haspopup="dialog"
41
- title={ko("Language and analytics", "언어 및 사용 통계")}
41
+ title={ko("Language settings", "언어 설정", "语言设置")}
42
42
  ref={triggerRef}
43
43
  onClick={() => (open ? close() : setOpen(true))}
44
44
  >
@@ -15,7 +15,7 @@ import PropsPanel from "../panels/PropsPanel.jsx";
15
15
  import ObjectTransformPanel from "../panels/ObjectTransformPanel.jsx";
16
16
  import ObjectLookPanel from "../panels/ObjectLookPanel.jsx";
17
17
  import { PoseStudioPanel } from "../posestudio.jsx";
18
- import { DEFAULT_POSE } from "../poses.js";
18
+ import { posePanelControls } from "./pose-controls.js";
19
19
  import "../panels/details.css";
20
20
 
21
21
  export default function DetailsSlot() {
@@ -50,6 +50,7 @@ export default function DetailsSlot() {
50
50
  savePose, beginWorkspaceResize,
51
51
  } = useStudioShell();
52
52
  const selection = detailsSelection({ selectedHierarchyId, selectedSceneObject, rigSelection, characters, shot, inspectorHasContent });
53
+ const { resetPose, fingerPanel } = posePanelControls(useStudioShell());
53
54
  return (
54
55
  <aside className="panel hierarchy-sidebar inspector-sidebar" data-inspector={selectedHierarchyId}>
55
56
  {/* Save failures live above the tab content, not inside the Props
@@ -194,6 +195,8 @@ export default function DetailsSlot() {
194
195
  pendingIkEdit={pendingIkEdit}
195
196
  applyPendingIkEdit={applyPendingIkEdit}
196
197
  cancelPendingIkEdit={cancelPendingIkEdit}
198
+ onResetPose={() => resetPose(activeChar.id)}
199
+ fingerPanel={fingerPanel}
197
200
  rangePins={rangePins}
198
201
  rangePinResiduals={rangePinResiduals}
199
202
  rangePinSelection={rangePinSelection}
@@ -334,11 +337,7 @@ export default function DetailsSlot() {
334
337
  setToast(ko("Couldn't find the selected pose — pick again", "선택한 포즈를 찾지 못했어요. 다시 골라 주세요"));
335
338
  }
336
339
  }}
337
- onReset={() => {
338
- if (posingChar) castDomain.run('character.setPose', { characterId: posingChar.id, pose: DEFAULT_POSE.id, clearMotion: Boolean(motion) });
339
- setStudioPick(DEFAULT_POSE.id);
340
- setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
341
- }}
340
+ onReset={() => { if (posingChar) resetPose(posingChar.id); }}
342
341
  onSave={savePose}
343
342
  onDelete={removePose}
344
343
  onClose={closeStudio}
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
2
2
  import { useStudioShell } from "./studio-shell-context.js";
3
3
  import { ko } from "../locale.js";
4
4
  import AssetPane from "../asset-pane.jsx";
5
+ import { dropAssetAt } from "../asset-drop.js";
5
6
 
6
7
  const CONTENT_COLLAPSED_KEY = "cozyclay.dock.content-collapsed.v1";
7
8
 
@@ -29,7 +30,7 @@ export default function LibrarySlot({ active = true }) {
29
30
  manageAssetStorage, setManageAssetStorage, unusedAssetIds, usedAssetIds, usageCounts,
30
31
  projectAssetGraphSignature, assetTrash, deleteUnusedAsset, undoDeletedAsset, deletingAssetId,
31
32
  projectManifest, shots, selectTimelineShot, allPoses,
32
- spawnCharacter, addSceneObject, runStudioAction,
33
+ spawnCharacter, addSceneObject, runStudioAction, setSelectedHierarchyId,
33
34
  } = useStudioShell();
34
35
  const [collapsed, setCollapsed] = useState(readContentCollapsed);
35
36
  const [shelfShown, setShelfShown] = useState(false);
@@ -41,15 +42,12 @@ export default function LibrarySlot({ active = true }) {
41
42
  }, [active, shelfShown, setBottomTab]);
42
43
 
43
44
  // Double-click in Content: the same payloads the drag carries, placed at
44
- // the origin through the named domain actions.
45
- function placeAssetAtOrigin(payload) {
46
- const origin = { x: 0, z: 0 };
47
- if (payload.kind === "character") spawnCharacter(payload.id, origin.x, origin.z);
48
- else if (payload.kind === "object") addSceneObject(payload.objectKind, origin);
49
- else if (payload.kind === "image" || payload.kind === "mesh") {
50
- runStudioAction("asset.import", { assetId: payload.assetId, placeAs: payload.kind === "mesh" ? "mesh" : "cutout", placement: origin });
51
- }
52
- }
45
+ // the origin through the same drop table (asset-drop.js).
46
+ const placeAssetAtOrigin = (payload) => dropAssetAt(payload, { x: 0, z: 0 }, {
47
+ spawnCharacter, addSceneObject, runStudioAction, select: setSelectedHierarchyId,
48
+ spawnCutoutAt: (assetId, placement) => runStudioAction("asset.import", { assetId, placeAs: "cutout", placement }),
49
+ spawnMeshAt: (assetId, placement) => runStudioAction("asset.import", { assetId, placeAs: "mesh", placement }),
50
+ });
53
51
 
54
52
  return (
55
53
  <section className="assets-pane studio-library" aria-label={ko("Library", "라이브러리")} data-content-collapsed={collapsed || undefined} hidden={!active}>
@@ -4,6 +4,8 @@ import { useStudioShell } from "./studio-shell-context.js";
4
4
  import SourceOffer from "../source-offer.jsx";
5
5
  import { ko } from "../locale.js";
6
6
  import { noteShortcutsOpened } from "../shortcut-friction.js";
7
+ import { appVersion } from "../app-version.js";
8
+ import { useExportMenuTelemetry } from "../export-menu-telemetry.js";
7
9
 
8
10
  const MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
9
11
  const MOD = MAC ? "⌘" : "Ctrl+";
@@ -197,10 +199,9 @@ export default function MenuBar({ preferences }) {
197
199
  ? panelCount ? "" : noPanels
198
200
  : shots.length ? "" : ko("Add a shot first — a pack describes one cut", "샷을 먼저 추가하세요 — 팩은 컷 하나를 설명합니다");
199
201
 
200
- // Export's flyout lives in File, so an open Export menu opens File.
201
- useEffect(() => {
202
- if (exportMenuOpen) setOpen("file");
203
- }, [exportMenuOpen]);
202
+ // Export's flyout lives in File, so an open Export menu opens File; each open is counted.
203
+ useEffect(() => { if (exportMenuOpen) setOpen("file"); }, [exportMenuOpen]);
204
+ useExportMenuTelemetry(exportMenuOpen, shell);
204
205
 
205
206
  const closeMenus = (focusTrigger) => {
206
207
  const was = open;
@@ -632,7 +633,6 @@ function MenuDialog({ kind, onClose }) {
632
633
  document.addEventListener("keydown", onKey);
633
634
  return () => document.removeEventListener("keydown", onKey);
634
635
  }, [onClose]);
635
- const version = import.meta.env.VITE_APP_VERSION;
636
636
  return createPortal(
637
637
  <div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
638
638
  <div className="menubar-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId} data-dialog={kind}>
@@ -645,7 +645,7 @@ function MenuDialog({ kind, onClose }) {
645
645
  {kind === "about" ? (
646
646
  <div className="menubar-dialog-body">
647
647
  <p>{ko("Previs studio: stage, pose, camera and motion.", "프리비즈 스튜디오: 배치, 포즈, 카메라, 모션.")}</p>
648
- {version && <p className="menubar-dialog-meta">{ko("Version", "버전")} {version}</p>}
648
+ <p className="menubar-dialog-meta">{ko("Version", "버전")} {appVersion()}</p>
649
649
  <SourceOffer />
650
650
  </div>
651
651
  ) : (
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { ko } from "../locale.js";
4
- import { getAnalyticsOptOut, loadAutoColor, preferenceSection, readStoredGridView } from "./preference-pages.jsx";
4
+ import { loadAutoColor, preferenceSection, readStoredGridView } from "./preference-pages.jsx";
5
5
  import "./preferences.css";
6
6
 
7
7
  const NAV_GROUPS = [
@@ -24,7 +24,6 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
24
24
  closeRef.current = onClose;
25
25
  const [activeSection, setActiveSection] = useState(initialSection);
26
26
  const [query, setQuery] = useState("");
27
- const [analyticsOptOut, setAnalyticsOptOutState] = useState(getAnalyticsOptOut);
28
27
  const [autoColor, setAutoColor] = useState(() => loadAutoColor());
29
28
  const [gridView, setGridView] = useState(() => readStoredGridView(globalThis.localStorage));
30
29
 
@@ -69,7 +68,7 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
69
68
  }, [open, initialSection]);
70
69
 
71
70
  if (!open || typeof document === "undefined") return null;
72
- const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView };
71
+ const currentProps = { autoColor, setAutoColor, gridView, setGridView };
73
72
 
74
73
  return createPortal(
75
74
  <div className="v2-preferences" data-testid="preferences-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) closeRef.current?.(); }}>
@@ -6,12 +6,11 @@ import { PRESETS, SHOT_ASPECT_PRESETS } from "../app-stage.jsx";
6
6
  import { CAMERA_PRESETS } from "../camera-move.js";
7
7
  import { PRIME_SET } from "../shot.js";
8
8
  import { CatalogueEntries } from "../object-catalog.jsx";
9
- import { CHARACTER_MODEL_IDS } from "../scenes.js";
10
9
  import { elementByPath } from "../studio-elements.js";
11
10
  import { saveAutoColor } from "../auto-color.js";
12
11
  import { trackFeature } from "../analytics.js";
13
12
  import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
14
- import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js";
13
+ import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js"; import { addCharacterEntries } from "./add-character-entries.js";
15
14
  import "./viewport.css";
16
15
 
17
16
  // G1: the single mode+tool pill. Keys 1-5 and W/E/R are bound in App's
@@ -122,7 +121,7 @@ export default function ViewportToolbar() {
122
121
  autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
123
122
  agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
124
123
  ikEditTool, setIkEditTool, motion, setRangePinPartPick,
125
- previsMode, sculptView, requestSculptView, appContext, setToast,
124
+ previsMode, castStyle, sculptView, requestSculptView, sculptParts, appContext, setToast,
126
125
  } = useStudioShell();
127
126
  const menu = useOverlayMenu();
128
127
 
@@ -154,11 +153,11 @@ export default function ViewportToolbar() {
154
153
  ],
155
154
  // Motion is compiled by the agent (motion.compile); the department has no tools of its own.
156
155
  motion: [],
157
- // Sculpt mode's W/E/R swing the turntable camera to a reference view.
158
- sculpt: [
159
- { key: "W", id: "front", name: ko("Front view", "정면 보기"), active: sculptView?.view === "front", pick: () => requestSculptView("front") },
160
- { key: "E", id: "side", name: ko("Side view", "측면 보기"), active: sculptView?.view === "side", pick: () => requestSculptView("side") },
161
- { key: "R", id: "back", name: ko("Back view", "뒷면 보기"), active: sculptView?.view === "back", pick: () => requestSculptView("back") },
156
+ // Sculpt mode's W/E/R swing the turntable camera to a reference view; with a part selected they drive its gizmo.
157
+ sculpt: sculptParts?.selected ? sculptParts.tools : [
158
+ { key: "W", id: "front", name: ko("Front view (with a part selected, W moves it)", "정면 보기 (부품을 선택하면 W는 이동)"), active: sculptView?.view === "front", pick: () => requestSculptView("front") },
159
+ { key: "E", id: "side", name: ko("Side view (with a part selected, E rotates it)", "측면 보기 (부품을 선택하면 E는 회전)"), active: sculptView?.view === "side", pick: () => requestSculptView("side") },
160
+ { key: "R", id: "back", name: ko("Back view (with a part selected, R scales it)", "뒷면 보기 (부품을 선택하면 R은 크기)"), active: sculptView?.view === "back", pick: () => requestSculptView("back") },
162
161
  ],
163
162
  }[workflowMode] ?? [];
164
163
 
@@ -277,16 +276,14 @@ export default function ViewportToolbar() {
277
276
  <CatalogueEntries onPick={(kind) => { addSceneObject(kind); menu.close(); }} />
278
277
  <div className="add-object-group">
279
278
  <span className="add-object-heading">{ko("Scene", "장면")}</span>
280
- <button
281
- type="button"
282
- role="menuitem"
283
- className="add-object-item"
284
- data-add="character"
285
- onClick={() => { spawnCharacter(CHARACTER_MODEL_IDS[0], characterSpawnX(characters.length), 0); menu.close(); }}
286
- >
287
- <span className="add-object-swatch vp-add-swatch-cast" aria-hidden="true" />
288
- <span>{ko("Character", "캐릭터")}</span>
289
- </button>
279
+ {addCharacterEntries(castStyle).map((entry) => (
280
+ <button key={entry.add} type="button" role="menuitem" className="add-object-item" data-add={entry.add}
281
+ onClick={() => { spawnCharacter(entry.model, characterSpawnX(characters.length), 0); menu.close(); }}
282
+ >
283
+ <span className="add-object-swatch vp-add-swatch-cast" aria-hidden="true" />
284
+ <span>{entry.label()}</span>
285
+ </button>
286
+ ))}
290
287
  <button
291
288
  type="button"
292
289
  role="menuitem"
@@ -0,0 +1,13 @@
1
+ // Add > Character offers both bodies in every project (ViewportToolbar). The
2
+ // project's cast style only decides which one comes first: never hide either.
3
+ import { ko } from "../locale.js";
4
+ import { CHARACTER_MODEL_IDS, PROXY_FIGURE_MODEL } from "../scenes.js";
5
+
6
+ const ENTRIES = Object.freeze([
7
+ Object.freeze({ add: "character", model: CHARACTER_MODEL_IDS[0], label: () => "Y Bot" }),
8
+ Object.freeze({ add: "capsule", model: PROXY_FIGURE_MODEL, label: () => ko("Capsule figure", "캡슐 인물") }),
9
+ ]);
10
+
11
+ export function addCharacterEntries(castStyle) {
12
+ return castStyle === "capsule" ? [ENTRIES[1], ENTRIES[0]] : ENTRIES;
13
+ }
@@ -0,0 +1,32 @@
1
+ import { ko } from "../locale.js";
2
+ import { DEFAULT_POSE } from "../poses.js";
3
+ import { FINGER_PRESETS } from "../motion/fingers.js";
4
+
5
+ // The Pose panel's reset and the focused hand's finger controls, read off the studio shell.
6
+ export function posePanelControls(shell) {
7
+ const { motion, characters, runStudioAction, castDomain, setStudioPick, setToast, cancelPendingIkEdit } = shell;
8
+ // Over a take the default is the take itself: drop the IK edits and keep the motion.
9
+ // Without one, back to the default pose; clearMotion also drops the IK keys (one undo step).
10
+ const resetPose = (characterId) => {
11
+ cancelPendingIkEdit?.();
12
+ const current = characters.find((entry) => entry.id === characterId)?.pose;
13
+ // Already on the default pose: only the IK edits go. A pose write would re-apply the
14
+ // bare pose on Undo over the restored IK keys.
15
+ if (motion || !current || current.id === DEFAULT_POSE.id) {
16
+ runStudioAction("character.clearIkKeys", { characterId });
17
+ if (motion) {
18
+ setToast(ko("Pose edits cleared — the motion stays", "포즈 수정을 지웠어요 — 모션은 그대로예요"));
19
+ return;
20
+ }
21
+ } else castDomain.run('character.setPose', { characterId, pose: DEFAULT_POSE.id, clearMotion: true });
22
+ setStudioPick(DEFAULT_POSE.id);
23
+ setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
24
+ };
25
+ const { handFingers: fingers, ikTick, ikSolve, ikDragEnd, fingerEditing, setFingerEdit } = shell;
26
+ const fingerPanel = {
27
+ fingers, tick: ikTick, editing: fingerEditing, onToggleEdit: () => setFingerEdit(!fingerEditing),
28
+ onCurl: (id, curl) => ikSolve("finger", id, { curl }), onCommit: ikDragEnd,
29
+ onPreset: (name) => { for (const finger of fingers) ikSolve("finger", finger.id, { curl: FINGER_PRESETS[name][finger.name] }); ikDragEnd(); },
30
+ };
31
+ return { resetPose, fingerPanel };
32
+ }
@@ -1,4 +1,3 @@
1
- import { getAnalyticsOptOut, setAnalyticsOptOut } from "../analytics.js";
2
1
  import { loadAutoColor, saveAutoColor } from "../auto-color.js";
3
2
  import { readStoredGridView, writeStoredGridView } from "../grid-view.js";
4
3
  import { ko } from "../locale.js";
@@ -13,7 +12,7 @@ import {
13
12
  } from "./preference-controls.jsx";
14
13
 
15
14
  export function preferenceSection(section, props) {
16
- const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
15
+ const { autoColor, setAutoColor, gridView, setGridView } = props;
17
16
 
18
17
  switch (section) {
19
18
  case "Appearance":
@@ -31,14 +30,6 @@ export function preferenceSection(section, props) {
31
30
  <PreferenceRow title={ko("Auto colour", "자동 색")} description={ko("Give scene objects stable display colours.", "씬 오브젝트에 고정된 표시 색을 사용해요.")}>
32
31
  <ToggleControl pressed={autoColor} onChange={(value) => { setAutoColor(value); saveAutoColor(value); }} label={ko("Auto colour", "자동 색")} testid="preferences-auto-color" />
33
32
  </PreferenceRow>
34
- <PreferenceRow title={ko("Anonymous analytics", "익명 사용 통계")} description={ko("Help improve CozyClay with anonymous product events.", "익명 제품 이벤트로 CozyClay 개선을 도와요.")}>
35
- <ToggleControl
36
- pressed={!analyticsOptOut}
37
- onChange={async (value) => setAnalyticsOptOutState(await setAnalyticsOptOut(!value))}
38
- label={ko("Anonymous analytics", "익명 사용 통계")}
39
- testid="settings-analytics"
40
- />
41
- </PreferenceRow>
42
33
  </PreferenceSection>
43
34
  </>
44
35
  );
@@ -69,4 +60,4 @@ export function preferenceSection(section, props) {
69
60
  }
70
61
 
71
62
  // Kept here so the dialog can load a new setting's initial value lazily.
72
- export { getAnalyticsOptOut, loadAutoColor, readStoredGridView };
63
+ export { loadAutoColor, readStoredGridView };
@@ -0,0 +1,21 @@
1
+ // The storyboard contact sheet is drawn on a bare 2d canvas, which has no
2
+ // stylesheet to inherit from: it gets the studio's own type stack explicitly
3
+ // so the sheet reads like the app it came out of.
4
+ export const STORYBOARD_FONT = '12px Inter, "Pretendard", "Noto Sans KR", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif';
5
+ // One unwrapped caption line at 12px inside a 480px cell holds about 64
6
+ // characters before it runs into the next column.
7
+ const STORYBOARD_CAPTION_CHARS = 64;
8
+
9
+ /** Cut a caption to what one unwrapped cell line holds. */
10
+ export function storyboardLine(text) {
11
+ return text.length > STORYBOARD_CAPTION_CHARS ? `${text.slice(0, STORYBOARD_CAPTION_CHARS - 1)}\u2026` : text;
12
+ }
13
+
14
+ /** Fold a labelled shot prompt into the one line a board cell can hold. */
15
+ export function storyboardCaption(prompt) {
16
+ return storyboardLine(prompt
17
+ .split("\n")
18
+ .filter((line) => line.startsWith("SHOT:") || line.startsWith("LENS:"))
19
+ .map((line) => line.slice(line.indexOf(":") + 1).trim())
20
+ .join(" · "));
21
+ }
@@ -13,6 +13,7 @@ import { playbackFrame, usePlaybackSnapshot } from "../motion/playback-frame-sto
13
13
  // - agentOpen/toggleAgent, preferencesOpen/setPreferencesOpen;
14
14
  // - statusText/setStatusText, previewRanges/setPreviewRanges, liveHubStatus,
15
15
  // flySpeed (the navigation multiplier, not metres per second);
16
+ // - castStyle ("rig" | "capsule" | "hybrid"), which orders Add > Character;
16
17
  // - previsMode ("storyboard" | "animation"), sceneRevision (bumps on every
17
18
  // authored edit) and capturePanelThumbnail(shot) (the shot's first frame as
18
19
  // a PNG data URL, or null) for the Board dock tab.
@@ -6,6 +6,7 @@
6
6
  import { track } from "./analytics.js";
7
7
  import { admit, bump } from "./analytics-friction.js";
8
8
  import { isImeComposing } from "./ime.js";
9
+ import { hintNoSelection } from "./friction-hint.js";
9
10
 
10
11
  const IGNORED_PER_CLASS = 5, UNBOUND_PER_COMBO = 3, HELP_PER_SESSION = 3;
11
12
  let send = track, defer = (run) => setTimeout(run, 0), listening = false;
@@ -52,7 +53,7 @@ export function noteUnselectedKey(event) {
52
53
  : event.code === "KeyD" && mod && !event.altKey && !event.shiftKey ? "duplicate"
53
54
  : (event.key === "Delete" || event.key === "Backspace") && !mod ? "delete" : null;
54
55
  if (!keyClass) return;
55
- defer(() => { if (!event.defaultPrevented) noteShortcutIgnored(keyClass, "no_selection"); });
56
+ defer(() => { if (!event.defaultPrevented) { noteShortcutIgnored(keyClass, "no_selection"); hintNoSelection(); } });
56
57
  }
57
58
 
58
59
  const modOnly = (event) => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
@@ -0,0 +1,28 @@
1
+ // Landing page (index.html) analytics: one $pageview and landing:cta_clicked.
2
+ // Same hosted consent rules as /app/ (no cookies, DNT respected, no autocapture);
3
+ // the lite init skips session, error, long-task and device signals.
4
+ import { initAnalytics, track } from "../analytics.js";
5
+ import { LANDING_CTA_TARGETS } from "../analytics-strict.js";
6
+
7
+ const COPY_TARGETS = [["--via site", "copy_npx"], ["git clone", "copy_clone"], ["mcpServers", "copy_mcp"], ["cclay live", "copy_terminal"]];
8
+
9
+ /** The landing:cta_clicked target for a clicked element, or null when it is not a call to action. */
10
+ function ctaTarget(element) {
11
+ const copy = element.closest?.("button.copy[data-copy]");
12
+ if (copy) return COPY_TARGETS.find(([needle]) => copy.dataset.copy.includes(needle))?.[1] ?? null;
13
+ const href = element.closest?.("a[href]")?.getAttribute("href") ?? "";
14
+ if (href === "/app/" || href.startsWith("/app/?")) return "app";
15
+ if (href.includes("INSTALL_FOR_AGENTS")) return "agent_install_doc";
16
+ if (href.startsWith("https://github.com/NomaDamas/CozyClay")) return "github";
17
+ if (href.startsWith("https://discord.gg/")) return "discord";
18
+ if (href.startsWith("https://www.npmjs.com/package/cozyclay")) return "npm";
19
+ return null;
20
+ }
21
+
22
+ if (typeof document !== "undefined") {
23
+ void initAnalytics({ lite: true });
24
+ document.addEventListener("click", (event) => {
25
+ const target = event.target instanceof Element ? ctaTarget(event.target) : null;
26
+ if (target && LANDING_CTA_TARGETS.includes(target)) track("landing:cta_clicked", { target });
27
+ }, { capture: true });
28
+ }
@@ -41,7 +41,8 @@ const waypointFrame = { ...frame, minimum: 1 };
41
41
  /** The tracks an IK key stores (src/motion/ik.js IK_TRACKS and FK_TRACKS). A
42
42
  * chain track keys its three bones (upper, lower, end); a joint track one. */
43
43
  export const STUDIO_IK_CHAIN_TRACKS = freezeStudioData(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
44
- export const STUDIO_IK_JOINT_TRACKS = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "rightShoulder"]);
44
+ export const STUDIO_IK_JOINT_TRACKS = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "rightShoulder",
45
+ ...["left", "right"].flatMap(side => ["Thumb", "Index", "Middle", "Ring", "Pinky"].flatMap(finger => [1, 2, 3].map(joint => `${side}${finger}${joint}`)))]);
45
46
  const num = { type: "number" };
46
47
  const perBone = items => ({ type: "array", items, minItems: 1, maxItems: 3 });
47
48
  const quaternion = input({ x: num, y: num, z: num, w: num });
@@ -102,7 +103,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
102
103
  { id: "character.clearWaypoints", label: "Clear root path", kind: "mutation", undoDomain: "cast", input: input(characterId),
103
104
  description: "Remove every root waypoint of the character, leaving its motion unconstrained by a path." },
104
105
  { id: "character.setIkKey", label: "Set IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame, tracks: ikTracks }),
105
- description: "Key a character's IK correction layer at frame, the same key a pose drag bakes. tracks maps a track id to its key: q is the bones' LOCAL rotations as unit quaternions {x,y,z,w}, three for a chain track (leftHand, rightHand, leftFoot, rightFoot: upper, lower and end bone) and one for a joint track (hips, spine, chest, neck, head, leftShoulder, rightShoulder); p is a joint's local position {x,y,z} (the hips' height and lean). Optional baseQ/basePos give the take's own pose the key was made over, so it applies as a delta on the take; chainP gives a chain's three local bone positions; keepTranslations keeps them over the take's; blend (frames, 1-240) is how far this key's correction eases into the take around it, 6 when omitted; pin (1-64 characters) tags the key as baked by that range pin. A key needs q or p. Each named track replaces its key at frame and starts evaluating; other tracks at that frame stay. Keyed frames are in inspect_studio { scope: \"motion\" } ikKeyFrames." },
106
+ description: "Key a character's IK correction layer at frame, the same key a pose drag bakes. tracks maps a track id to its key: q is the bones' LOCAL rotations as unit quaternions {x,y,z,w}, three for a chain track (leftHand, rightHand, leftFoot, rightFoot: upper, lower and end bone) and one for a joint track (hips, spine, chest, neck, head, leftShoulder, rightShoulder, and the finger bones <side><Thumb|Index|Middle|Ring|Pinky><1-3>, e.g. leftIndex2); p is a joint's local position {x,y,z} (the hips' height and lean). Optional baseQ/basePos give the take's own pose the key was made over, so it applies as a delta on the take; chainP gives a chain's three local bone positions; keepTranslations keeps them over the take's; blend (frames, 1-240) is how far this key's correction eases into the take around it, 6 when omitted; pin (1-64 characters) tags the key as baked by that range pin. A key needs q or p. Each named track replaces its key at frame and starts evaluating; other tracks at that frame stay. Keyed frames are in inspect_studio { scope: \"motion\" } ikKeyFrames." },
106
107
  { id: "character.removeIkKey", label: "Delete IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame }),
107
108
  description: "Delete the character's whole IK key at frame (every track keyed there), like the Full-Body lane's delete." },
108
109
  { id: "character.clearIkKeys", label: "Clear IK keys", kind: "mutation", undoDomain: "motion", input: input(characterId),
@@ -4,9 +4,9 @@ import { STUDIO_ERROR_CODES, refusalCode } from "./studio-error-codes.js";
4
4
  import { EFFECT_VALUE_SCHEMA } from "./effects/effect-record.js";
5
5
  import { TEXT_VALUE_SCHEMA } from "./text/text-record.js";
6
6
  import { wrongType, unknownField, missingField, arrayRefusal, duplicateItem, stringRefusal, enumRefusal, numberRefusal, unionRefusal } from "./studio-schema-errors.js";
7
- // Mirrors PREVIS_MODES (project.js) and POSTURES (scenes.js). Neither is imported:
8
- // both pull in three.js, which the agent sidecar must not load. The protocol test pins the lists to their owners.
9
- const PREVIS_MODES = ["storyboard", "animation"], POSTURES = ["stand", "sit", "lie"];
7
+ // Mirrors PREVIS_MODES and CAST_STYLES (project.js) and POSTURES (scenes.js). Neither is imported:
8
+ // both pull in three.js, which the agent sidecar must not load. The protocol tests pin the lists to their owners.
9
+ const PREVIS_MODES = ["storyboard", "animation"], CAST_STYLES = ["rig", "capsule", "hybrid"], POSTURES = ["stand", "sit", "lie"];
10
10
 
11
11
  // Sized for the compact index of up to 400 entities (~100 bytes each) beside
12
12
  // 24 detailed rows; a small scene stays far below it.
@@ -232,7 +232,7 @@ const jobSummary = object({ id, characterId: id, state: choices(STUDIO_VARIANTS.
232
232
  const contextSchema = object({
233
233
  schema: literal("studio-context-v1"), host, revision,
234
234
  units: object({ distance: literal("m"), angle: literal("deg"), up: literal("+Y"), yawZero: literal("+Z"), yawPositiveToward: literal("+X"), pivot: literal("base"), fps: literal(24), rangeEnd: literal("exclusive") }),
235
- scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES) }),
235
+ scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES), castStyle: choices(CAST_STYLES) }),
236
236
  selection, activeCharacterId: nullable(id), view, shot: nullable(currentShot), camera: nullable(camera),
237
237
  entities: array(entity, 24), entityPage: object({ returned: integer(0, 24), total: integer(), truncated: bool, nextCursor: nullable(text(512)) }),
238
238
  shots: array(shotSummary, 8), shotsTruncated: bool, assets: array(assetSummary, STUDIO_CONTEXT_LIMITS.assets),