cozyclay 1.9.0 → 2.0.0

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 (286) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/agent/agent-routes.mjs +74 -70
  3. package/bin/agent/agent-runner.mjs +10 -3
  4. package/bin/agent/motion-runtime.mjs +32 -260
  5. package/bin/agent/providers.mjs +46 -3
  6. package/bin/agent/session-store.mjs +41 -5
  7. package/bin/agent/studio-prompt.mjs +1 -1
  8. package/bin/agent/studio-tools.mjs +48 -11
  9. package/bin/cozyclay.mjs +26 -8
  10. package/bin/live/cli.mjs +22 -3
  11. package/bin/mcp-runtime.mjs +35 -20
  12. package/bin/telemetry-state.mjs +5 -2
  13. package/bin/update-check.mjs +15 -0
  14. package/dist/app/index.html +6 -6
  15. package/dist/assets/analytics-MVZOEW8O.js +1 -0
  16. package/dist/assets/app-C1sdVbg4.css +1 -0
  17. package/dist/assets/app-CA55tcI3.js +4876 -0
  18. package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
  19. package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
  20. package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
  21. package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
  22. package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
  23. package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
  24. package/dist/assets/workflow-DRdwoDBT.css +1 -0
  25. package/dist/assets/workflow-dNPGY9xz.js +179 -0
  26. package/dist/cozyclay-package.json +1 -1
  27. package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
  28. package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
  29. package/dist/fonts/README.md +13 -9
  30. package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
  31. package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
  32. package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
  33. package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
  34. package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
  35. package/dist/index.html +7 -7
  36. package/dist/privacy/index.html +13 -8
  37. package/dist/sitemap.xml +6 -6
  38. package/dist/workflow/index.html +5 -5
  39. package/mcp/LIVE-PROTOCOL.md +1 -1
  40. package/mcp/live-hub.mjs +35 -93
  41. package/mcp/mesh-file.mjs +48 -0
  42. package/mcp/server.mjs +6 -77
  43. package/mcp/tool-handlers.mjs +224 -377
  44. package/package.json +2 -1
  45. package/src/App.jsx +1273 -9075
  46. package/src/analytics.js +393 -9
  47. package/src/app-context.js +211 -0
  48. package/src/app-stage.jsx +25 -24
  49. package/src/ardy/auto-fix-panel.css +108 -0
  50. package/src/ardy/collision-blockers.js +9 -3
  51. package/src/ardy/fix-collisions.js +10 -66
  52. package/src/ardy/ground.js +4 -4
  53. package/src/ardy/ik-drag.js +101 -0
  54. package/src/ardy/ik-key-json.js +43 -0
  55. package/src/ardy/ik.js +122 -76
  56. package/src/ardy/physics-panel.css +2 -0
  57. package/src/ardy/physics-panel.jsx +17 -5
  58. package/src/ardy/platform-fit-panel.css +64 -0
  59. package/src/ardy/platform-fit-panel.jsx +40 -0
  60. package/src/ardy/platform-fit.js +326 -0
  61. package/src/ardy/playback.js +166 -20
  62. package/src/ardy/range-pin.js +299 -0
  63. package/src/ardy/timeline-coordinates.js +26 -0
  64. package/src/ardy/timeline.css +1199 -0
  65. package/src/ardy/timeline.jsx +149 -41
  66. package/src/ardy/waypoints.js +2 -2
  67. package/src/asset-pane.css +462 -0
  68. package/src/asset-pane.jsx +461 -232
  69. package/src/command-bus.js +349 -0
  70. package/src/commands/ai.js +31 -0
  71. package/src/commands/cast.js +140 -0
  72. package/src/commands/elements/character.js +13 -0
  73. package/src/commands/elements/motion.js +14 -0
  74. package/src/commands/elements/object.js +22 -0
  75. package/src/commands/elements/scene.js +16 -0
  76. package/src/commands/elements/shot.js +9 -0
  77. package/src/commands/elements/stage.js +11 -0
  78. package/src/commands/elements.js +155 -0
  79. package/src/commands/export.js +20 -0
  80. package/src/commands/index.js +40 -0
  81. package/src/commands/motion.js +231 -0
  82. package/src/commands/objects.js +174 -0
  83. package/src/commands/project.js +53 -0
  84. package/src/commands/scene.js +81 -0
  85. package/src/commands/shared.js +19 -0
  86. package/src/commands/shot.js +103 -0
  87. package/src/commands/stage.js +17 -0
  88. package/src/commands/view.js +42 -0
  89. package/src/document-store.js +144 -0
  90. package/src/domains/cast.js +1007 -0
  91. package/src/domains/motion.js +3219 -0
  92. package/src/domains/objects.js +932 -0
  93. package/src/domains/scenes.js +817 -0
  94. package/src/domains/shots.js +404 -0
  95. package/src/domains/stage.js +93 -0
  96. package/src/dualview.jsx +113 -57
  97. package/src/facing-marks.js +3 -0
  98. package/src/first-success-guide.jsx +14 -14
  99. package/src/grid-view.js +18 -5
  100. package/src/hierarchy-model.js +24 -16
  101. package/src/hierarchy-panel.css +386 -0
  102. package/src/hierarchy-panel.jsx +150 -21
  103. package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
  104. package/src/i2v-motion-studio.jsx +180 -0
  105. package/src/ik-camera.js +3 -0
  106. package/src/main.jsx +6 -0
  107. package/src/motion/generation.js +105 -0
  108. package/src/motion-readiness-ui.jsx +4 -4
  109. package/src/motion-readiness.js +11 -0
  110. package/src/motion-trail.js +366 -9
  111. package/src/object-gizmo.jsx +7 -1
  112. package/src/otio.js +2 -1
  113. package/src/panels/CameraPanel.jsx +60 -0
  114. package/src/panels/CharacterTransformPanel.jsx +48 -0
  115. package/src/panels/EnvironmentPanel.jsx +40 -0
  116. package/src/panels/Foldout.jsx +32 -0
  117. package/src/panels/LightPanel.jsx +24 -0
  118. package/src/panels/ObjectTransformPanel.jsx +554 -0
  119. package/src/panels/PosePanel.jsx +97 -0
  120. package/src/panels/ProjectPanel.jsx +33 -0
  121. package/src/panels/PromptBlocksPanel.jsx +442 -0
  122. package/src/panels/PropsPanel.jsx +70 -0
  123. package/src/panels/ReferenceImageField.jsx +91 -0
  124. package/src/panels/RigControlPanel.jsx +78 -0
  125. package/src/panels/RigPanel.jsx +37 -0
  126. package/src/panels/SubjectBox.jsx +47 -0
  127. package/src/panels/SubjectsPanel.jsx +39 -0
  128. package/src/panels/VideoCapturePanel.jsx +180 -0
  129. package/src/panels/details.css +1171 -0
  130. package/src/panels/motion.css +197 -0
  131. package/src/panels/pose.css +39 -0
  132. package/src/planview.jsx +60 -31
  133. package/src/posestudio.jsx +59 -4
  134. package/src/project-browser.css +1073 -0
  135. package/src/project-browser.jsx +284 -123
  136. package/src/range-pin-object-transform.js +19 -0
  137. package/src/range-pin-panel.css +518 -0
  138. package/src/range-pin-panel.jsx +344 -0
  139. package/src/result-modal.jsx +5 -5
  140. package/src/room.jsx +79 -51
  141. package/src/scene-objects.js +37 -1
  142. package/src/scenes.js +5 -0
  143. package/src/semantic-edit.js +2 -0
  144. package/src/settings-menu.jsx +39 -105
  145. package/src/shell/BottomDock.jsx +450 -0
  146. package/src/shell/DetailsSlot.jsx +513 -0
  147. package/src/shell/LibrarySlot.jsx +86 -0
  148. package/src/shell/MenuBar.jsx +601 -0
  149. package/src/shell/OutlinerSlot.jsx +50 -0
  150. package/src/shell/PreferencesDialog.jsx +538 -0
  151. package/src/shell/PreferencesSlot.jsx +40 -0
  152. package/src/shell/StatusBar.jsx +125 -0
  153. package/src/shell/StudioShell.jsx +92 -0
  154. package/src/shell/TopBar.jsx +129 -0
  155. package/src/shell/ViewportToolbar.jsx +656 -0
  156. package/src/shell/agent-glass.css +296 -0
  157. package/src/shell/dock.css +93 -0
  158. package/src/shell/glass-regions.css +985 -0
  159. package/src/shell/glass.css +687 -0
  160. package/src/shell/log-store.js +52 -0
  161. package/src/shell/mode.css +9 -0
  162. package/src/shell/preferences.css +651 -0
  163. package/src/shell/shell.css +282 -0
  164. package/src/shell/studio-shell-context.js +19 -0
  165. package/src/shell/topbar.css +435 -0
  166. package/src/shell/viewport.css +451 -0
  167. package/src/store/authored-intent.js +22 -0
  168. package/src/store/runtime-adapters.js +92 -0
  169. package/src/store/scene-stage.js +21 -0
  170. package/src/store/use-document-store.js +14 -0
  171. package/src/studio-actions.js +210 -0
  172. package/src/studio-agent-commands.js +41 -271
  173. package/src/studio-agent-context.js +39 -10
  174. package/src/studio-agent-motion.js +122 -373
  175. package/src/studio-agent-protocol.js +67 -31
  176. package/src/studio-app-binding.js +379 -0
  177. package/src/studio-contact-sheet.js +73 -0
  178. package/src/studio-elements.js +58 -51
  179. package/src/styles/themes.css +200 -0
  180. package/src/styles/tokens.css +69 -0
  181. package/src/styles.css +460 -1742
  182. package/src/theme.js +44 -0
  183. package/src/timeline-extent.js +16 -0
  184. package/src/trail-key-conflicts.js +39 -0
  185. package/src/trail-pick.js +59 -0
  186. package/src/ui.jsx +8 -8
  187. package/src/use-case-question.jsx +66 -0
  188. package/src/workflow/AgentPanel.jsx +82 -69
  189. package/src/workflow/agent-client.js +12 -2
  190. package/src/workflow/agent-panel.css +34 -32
  191. package/src/workflow/cozy-scene-node.css +2 -0
  192. package/src/workflow/workflow.css +2 -0
  193. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  194. package/tools/ardy/bridge.mjs +164 -146
  195. package/tools/ardy/visual-qa.mjs +5 -5
  196. package/tools/bench/EXP3.md +61 -0
  197. package/tools/bench/cclay_bench_extract_incam.py +125 -0
  198. package/tools/bench/cclay_bench_extract_obs.py +204 -0
  199. package/tools/bench/cclay_bench_runner.py +42 -0
  200. package/tools/bench/cube-contact.mjs +162 -0
  201. package/tools/bench/exp3.mjs +152 -0
  202. package/tools/bench/extract-bench-lib.mjs +216 -0
  203. package/tools/bench/extract-bench.mjs +302 -0
  204. package/tools/bench/fal-generate.mjs +88 -0
  205. package/tools/bench/fit/README.md +189 -0
  206. package/tools/bench/fit/camera.mjs +59 -0
  207. package/tools/bench/fit/contact.mjs +419 -0
  208. package/tools/bench/fit/footlock.mjs +234 -0
  209. package/tools/bench/fit/motion.mjs +85 -0
  210. package/tools/bench/fit/pin.mjs +27 -0
  211. package/tools/bench/fit/remote.mjs +105 -0
  212. package/tools/bench/fit-bench.mjs +126 -0
  213. package/tools/bench/fit-sanity.mjs +45 -0
  214. package/tools/bench/metrics.mjs +313 -0
  215. package/tools/bench/obs/depth.mjs +258 -0
  216. package/tools/bench/obs/extrinsics.mjs +175 -0
  217. package/tools/bench/obs/fit_mannequin_betas.py +340 -0
  218. package/tools/bench/obs/ground.mjs +294 -0
  219. package/tools/bench/obs/heading.mjs +151 -0
  220. package/tools/bench/obs/ladder.mjs +555 -0
  221. package/tools/bench/obs/mannequin-betas.json +124 -0
  222. package/tools/bench/obs/remote.mjs +83 -0
  223. package/tools/bench/obs/rest_joints.py +52 -0
  224. package/tools/bench/obs/ybot-targets.mjs +49 -0
  225. package/tools/bench/obs-bench.mjs +520 -0
  226. package/tools/bench/score.mjs +343 -0
  227. package/tools/bench/summarize.mjs +86 -0
  228. package/tools/dev/pages/privacy.html +13 -8
  229. package/tools/gt-render/browser.mjs +159 -0
  230. package/tools/gt-render/camera-math.mjs +276 -0
  231. package/tools/gt-render/page.mjs +267 -0
  232. package/tools/gt-render/render.mjs +514 -0
  233. package/tools/gt-render/scene-box.mjs +31 -0
  234. package/tools/gt-render/take-transform.mjs +87 -0
  235. package/tools/morphgs/assets/gen_truth.py +39 -0
  236. package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
  237. package/tools/morphgs/assets/playback-check.mjs +30 -0
  238. package/tools/morphgs/demo-gate.sh +26 -0
  239. package/tools/morphgs/fbx2morphgs.mjs +68 -0
  240. package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
  241. package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
  242. package/tools/morphgs/setup-on-cluster.sh +126 -0
  243. package/tools/qa/css-rule-usage.mjs +283 -0
  244. package/tools/qa/studio-control-count.mjs +70 -32
  245. package/tools/run-tests.mjs +134 -12
  246. package/tools/track/DESIGN.md +513 -0
  247. package/tools/track/backfill-provenance.mjs +116 -0
  248. package/tools/track/budget.mjs +8 -0
  249. package/tools/track/check-rig.mjs +209 -0
  250. package/tools/track/diagnostics.schema.json +60 -0
  251. package/tools/track/export-rig.mjs +256 -0
  252. package/tools/track/fallback.mjs +7 -0
  253. package/tools/track/fk-parity-fixture.mjs +161 -0
  254. package/tools/track/gate.mjs +349 -0
  255. package/tools/track/masks.mjs +318 -0
  256. package/tools/track/metrics.mjs +201 -0
  257. package/tools/track/publish-obs.mjs +108 -0
  258. package/tools/track/py/check_env.py +61 -0
  259. package/tools/track/py/eval_lr.py +229 -0
  260. package/tools/track/py/lr_viterbi.py +294 -0
  261. package/tools/track/py/masks.py +405 -0
  262. package/tools/track/py/objective.py +382 -0
  263. package/tools/track/py/rig.py +508 -0
  264. package/tools/track/py/scene.py +326 -0
  265. package/tools/track/py/test_joint_indices.py +123 -0
  266. package/tools/track/py/test_lr_viterbi.py +195 -0
  267. package/tools/track/py/test_masks.py +229 -0
  268. package/tools/track/py/test_rig.py +288 -0
  269. package/tools/track/py/test_scene.py +354 -0
  270. package/tools/track/py/test_track.py +328 -0
  271. package/tools/track/py/track.py +411 -0
  272. package/tools/track/remote.mjs +140 -0
  273. package/tools/track/rig-dump.mjs +227 -0
  274. package/tools/track/run-box-tests.mjs +14 -0
  275. package/tools/track/run-box.mjs +168 -0
  276. package/tools/track/setup-box.sh +59 -0
  277. package/tools/track/study-2d.mjs +728 -0
  278. package/dist/assets/analytics-B1hnH66c.js +0 -1
  279. package/dist/assets/app-BWusbqgO.js +0 -4861
  280. package/dist/assets/app-qKDo4PBX.css +0 -1
  281. package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
  282. package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
  283. package/dist/assets/workflow-CVwzMhz2.js +0 -179
  284. package/dist/assets/workflow-DsudxKHF.css +0 -1
  285. package/src/fal-motion-studio.jsx +0 -180
  286. package/src/scene-history.js +0 -129
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ // Which src/styles.css rules does the Studio still use? Drives /app/ through
3
+ // every v2 surface (modes 1-4 with a character and then a prop selected, the
4
+ // File/Edit/Window/Help menus with Export, the Generate Motion caret, the
5
+ // viewport pills, Preferences on every page, Content and Log, and the start
6
+ // screen) at 1600x1000 and 1280x800 while CDP CSS.startRuleUsageTracking
7
+ // records every style rule that matched. Rules that never matched AND whose
8
+ // class/id names appear in no src/**/*.{js,jsx} file are pruning candidates.
9
+ //
10
+ // QA_URL=http://127.0.0.1:5733/app/?motion=/demo/walk-then-stop.npz CDP_PORT=9733 \
11
+ // OUT=/tmp/css-usage.json node tools/qa-browser.mjs -- node tools/qa/css-rule-usage.mjs
12
+ //
13
+ // Writes OUT (default /tmp/css-rule-usage.json): { states, rules, used,
14
+ // candidates: [{ index, selector, line, endLine }] }. Rule indexes follow
15
+ // postcss walkRules order over src/styles.css.
16
+ import { readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
17
+ import { join, relative } from "node:path";
18
+ import postcss from "postcss";
19
+
20
+ const root = new URL("../..", import.meta.url).pathname;
21
+ const SHEET = "src/styles.css";
22
+ const out = process.env.OUT || "/tmp/css-rule-usage.json";
23
+ const port = Number(process.env.CDP_PORT || 9222);
24
+ const appUrl = new URL(process.env.QA_URL || "http://127.0.0.1:5180/app/");
25
+
26
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
27
+ const page = targets.find((target) => target.type === "page" && target.webSocketDebuggerUrl);
28
+ if (!page) throw new Error("no page target on the QA browser");
29
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
30
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
31
+ let nextId = 1;
32
+ const pending = new Map();
33
+ const listeners = new Map();
34
+ ws.onmessage = (event) => {
35
+ const message = JSON.parse(event.data);
36
+ if (message.method) for (const listener of listeners.get(message.method) ?? []) listener(message.params);
37
+ if (!message.id || !pending.has(message.id)) return;
38
+ const { resolve, reject } = pending.get(message.id);
39
+ pending.delete(message.id);
40
+ if (message.error) reject(new Error(JSON.stringify(message.error)));
41
+ else resolve(message.result);
42
+ };
43
+ const send = (method, params = {}) => new Promise((resolve, reject) => {
44
+ const id = nextId++;
45
+ pending.set(id, { resolve, reject });
46
+ ws.send(JSON.stringify({ id, method, params }));
47
+ });
48
+ const on = (method, listener) => { const set = listeners.get(method) ?? new Set(); set.add(listener); listeners.set(method, set); };
49
+ const once = (method, timeoutMs = 60000) => new Promise((resolve, reject) => {
50
+ const set = listeners.get(method) ?? new Set();
51
+ listeners.set(method, set);
52
+ const timer = setTimeout(() => { set.delete(handler); reject(new Error(`Missing CDP event ${method}`)); }, timeoutMs);
53
+ const handler = (params) => { set.delete(handler); clearTimeout(timer); resolve(params); };
54
+ set.add(handler);
55
+ });
56
+ const evaluate = async (expression) => {
57
+ const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
58
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || "evaluate failed");
59
+ return result.result.value;
60
+ };
61
+ const waitFor = (expression, timeoutMs = 20000) => evaluate(`new Promise((resolve, reject) => {
62
+ const test = () => { try { return Boolean(${expression}); } catch { return false; } };
63
+ if (test()) { resolve(true); return; }
64
+ const observer = new MutationObserver(() => { if (!test()) return; observer.disconnect(); clearTimeout(timer); resolve(true); });
65
+ observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
66
+ const timer = setTimeout(() => { observer.disconnect(); reject(new Error(${JSON.stringify(`Timed out waiting for: ${expression}`)})); }, ${timeoutMs});
67
+ })`);
68
+ // Two frames guarantee a style recalc (and so rule matching) for the new DOM.
69
+ const settle = () => evaluate("new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))");
70
+ const click = async (selector) => {
71
+ const found = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.click(); return true; })()`);
72
+ if (!found) throw new Error(`missing control ${selector}`);
73
+ await settle();
74
+ };
75
+ const escape = async () => {
76
+ for (const type of ["keyDown", "keyUp"]) await send("Input.dispatchKeyEvent", { type, key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
77
+ await settle();
78
+ };
79
+
80
+ // ---- stylesheet bookkeeping: find the <style> Vite injects for styles.css ----
81
+ const sheets = new Map();
82
+ on("CSS.styleSheetAdded", ({ header }) => sheets.set(header.styleSheetId, header));
83
+ const sheetSource = new Map(); // styleSheetId -> { file, text }
84
+ const sourceOf = async (styleSheetId) => {
85
+ if (sheetSource.has(styleSheetId)) return sheetSource.get(styleSheetId);
86
+ const header = sheets.get(styleSheetId);
87
+ let file = header?.sourceURL ? new URL(header.sourceURL, appUrl).pathname : "";
88
+ if (header?.ownerNode) {
89
+ const { node } = await send("DOM.describeNode", { backendNodeId: header.ownerNode }).catch(() => ({ node: null }));
90
+ const attrs = node?.attributes ?? [];
91
+ const devId = attrs[attrs.indexOf("data-vite-dev-id") + 1];
92
+ if (attrs.includes("data-vite-dev-id") && devId) file = devId;
93
+ }
94
+ const { text } = await send("CSS.getStyleSheetText", { styleSheetId });
95
+ const entry = { file, text };
96
+ sheetSource.set(styleSheetId, entry);
97
+ return entry;
98
+ };
99
+
100
+ const sourceText = readFileSync(join(root, SHEET), "utf8");
101
+ const sourceRules = [];
102
+ postcss.parse(sourceText).walkRules((rule) => {
103
+ if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) return;
104
+ sourceRules.push(rule);
105
+ });
106
+ const servedIndex = new Map(); // served text -> Map(startOffset -> rule index)
107
+ const indexServed = (text) => {
108
+ if (servedIndex.has(text)) return servedIndex.get(text);
109
+ const byOffset = new Map();
110
+ let index = 0;
111
+ postcss.parse(text).walkRules((rule) => {
112
+ if (rule.parent?.type === "atrule" && /keyframes$/i.test(rule.parent.name)) return;
113
+ if (rule.selector !== sourceRules[index]?.selector) throw new Error(`served ${SHEET} diverges from the file at rule ${index}: ${rule.selector}`);
114
+ byOffset.set(rule.source.start.offset, index);
115
+ index += 1;
116
+ });
117
+ if (index !== sourceRules.length) throw new Error(`served ${SHEET} has ${index} rules, the file ${sourceRules.length}`);
118
+ servedIndex.set(text, byOffset);
119
+ return byOffset;
120
+ };
121
+
122
+ const used = new Set();
123
+ const states = [];
124
+ const collect = async (state) => {
125
+ await settle();
126
+ const { coverage } = await send("CSS.takeCoverageDelta");
127
+ let count = 0;
128
+ for (const usage of coverage) {
129
+ if (!usage.used) continue;
130
+ const { file, text } = await sourceOf(usage.styleSheetId);
131
+ if (!file.endsWith(`/${SHEET}`)) continue;
132
+ const byOffset = indexServed(text);
133
+ // The usage range starts at the selector; allow for leading whitespace.
134
+ let index = byOffset.get(usage.startOffset);
135
+ if (index === undefined) {
136
+ const trimmed = usage.startOffset + (text.slice(usage.startOffset).length - text.slice(usage.startOffset).trimStart().length);
137
+ index = byOffset.get(trimmed);
138
+ }
139
+ // Grouping rules (@layer legacy, @media, @supports) report usage too.
140
+ if (index === undefined && /@[\w-]+\s*$/.test(text.slice(Math.max(0, usage.startOffset - 40), usage.startOffset))) continue;
141
+ if (index === undefined) throw new Error(`no ${SHEET} rule at offset ${usage.startOffset}: ${text.slice(usage.startOffset, usage.startOffset + 60)}`);
142
+ if (!used.has(index)) count += 1;
143
+ used.add(index);
144
+ }
145
+ states.push({ state, newlyUsed: count, totalUsed: used.size });
146
+ console.log(`${state}: +${count} (${used.size}/${sourceRules.length})`);
147
+ };
148
+
149
+ const startTracking = async () => {
150
+ await send("DOM.enable");
151
+ await send("CSS.enable");
152
+ await send("CSS.startRuleUsageTracking").catch(() => {});
153
+ };
154
+
155
+ async function load({ session = true, width, height }) {
156
+ await send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false });
157
+ const seeded = once("Page.loadEventFired");
158
+ await send("Page.navigate", { url: `${appUrl.origin}/favicon.ico` });
159
+ await seeded;
160
+ await evaluate(`(() => {
161
+ localStorage.setItem("cozyclay.locale", "en");
162
+ ${session ? `localStorage.setItem("cozyclay.project-session.v1", JSON.stringify({ name: "QA", updatedAt: Date.now() }));` : `localStorage.removeItem("cozyclay.project-session.v1");`}
163
+ return true;
164
+ })()`);
165
+ sheets.clear();
166
+ sheetSource.clear();
167
+ const loaded = once("Page.loadEventFired");
168
+ await send("Page.navigate", { url: appUrl.href });
169
+ await loaded;
170
+ await startTracking();
171
+ }
172
+
173
+ const modeKeys = ["1", "2", "3", "4"];
174
+ async function walkStudio(label) {
175
+ await waitFor("!!window.__cozyclay?.rigA && window.__cozyclay?.motion?.frames > 0 && !!document.querySelector('[data-node-id=characterA] .hierarchy-row')", 90000);
176
+ await evaluate("window.__cozyclay.pause?.(); true");
177
+ await collect(`${label} loaded`);
178
+ for (const key of modeKeys) {
179
+ await click(`[data-mode-key="${key}"]`);
180
+ await click("[data-node-id=characterA] .hierarchy-row");
181
+ await collect(`${label} mode ${key} + character`);
182
+ }
183
+ // A prop: viewport + Add › Cube selects the new object.
184
+ await click("[data-testid=viewport-add]");
185
+ await waitFor("!!document.querySelector('.viewport-titlebar .add-object-swatch.cube')");
186
+ await collect(`${label} viewport Add menu`);
187
+ await evaluate("document.querySelector('.viewport-titlebar .add-object-swatch.cube').closest('button').click(); true");
188
+ await waitFor("document.querySelector('.inspector-sidebar')?.dataset.inspector?.startsWith('object:')");
189
+ const inspector = await evaluate("document.querySelector('.inspector-sidebar').dataset.inspector");
190
+ for (const key of modeKeys) {
191
+ await click(`[data-mode-key="${key}"]`);
192
+ // Re-select the prop through the Outliner in every mode.
193
+ await evaluate(`(() => { const id = ${JSON.stringify(inspector.slice("object:".length))}; const row = document.querySelector('[data-node-id="' + id + '"] .hierarchy-row') || document.querySelector('[data-node-id="object:' + id + '"] .hierarchy-row'); row?.click(); return !!row; })()`);
194
+ await settle();
195
+ await collect(`${label} mode ${key} + prop`);
196
+ }
197
+ await click('[data-mode-key="1"]');
198
+ await click("[data-node-id=characterA] .hierarchy-row");
199
+ for (const trigger of ["view-camera-trigger", "shading-trigger", "view-menu-trigger"]) {
200
+ if (!await evaluate(`!!document.querySelector('[data-testid=${trigger}]')`)) continue;
201
+ await click(`[data-testid=${trigger}]`);
202
+ await collect(`${label} ${trigger} open`);
203
+ await escape();
204
+ }
205
+ for (const menu of ["file", "edit", "window", "help"]) {
206
+ await click(`[data-testid=menu-${menu}]`);
207
+ await waitFor(`!!document.querySelector('.menubar-menu[data-menu=${menu}]')`);
208
+ if (menu === "file") {
209
+ await click("#export-menu-trigger");
210
+ await waitFor("!!document.querySelector('.export-menu')");
211
+ }
212
+ await collect(`${label} ${menu} menu`);
213
+ await escape();
214
+ if (await evaluate("!!document.querySelector('.menubar-menu')")) await click(".topbar-swatch");
215
+ }
216
+ await click("[data-testid=topbar-generate-menu]");
217
+ await waitFor("!!document.querySelector('[data-generate-action]')");
218
+ await collect(`${label} Generate Motion caret`);
219
+ await escape();
220
+ if (await evaluate("!!document.querySelector('[data-generate-action]')")) await click("[data-testid=topbar-generate-menu]");
221
+ // Preferences through Edit › Preferences…, then every section.
222
+ await click("[data-testid=menu-edit]");
223
+ await waitFor("!!document.querySelector('[data-testid=menu-preferences]')");
224
+ await click("[data-testid=menu-preferences]");
225
+ await waitFor("!!document.querySelector('[data-testid=preferences-nav-item]')");
226
+ const sections = await evaluate("[...document.querySelectorAll('[data-testid=preferences-nav-item]')].map((item) => item.textContent.trim())");
227
+ for (const section of sections) {
228
+ await evaluate(`[...document.querySelectorAll('[data-testid=preferences-nav-item]')].find((item) => item.textContent.trim() === ${JSON.stringify(section)})?.click(); true`);
229
+ await settle();
230
+ await collect(`${label} Preferences › ${section}`);
231
+ }
232
+ await click("[data-testid=preferences-close]");
233
+ for (const tab of ["log", "content"]) {
234
+ await click(`[data-testid=content-tab-${tab}]`);
235
+ await collect(`${label} ${tab} tab`);
236
+ }
237
+ await evaluate("[...document.querySelectorAll('.content-folders [data-testid^=content-folder-]')].map((folder) => folder.dataset.testid)").then(async (folders) => {
238
+ for (const folder of folders) { await click(`[data-testid=${folder}]`); }
239
+ await collect(`${label} Content folders`);
240
+ });
241
+ }
242
+
243
+ await send("Runtime.enable");
244
+ await send("Page.enable");
245
+ await send("Network.enable");
246
+ await send("Network.setBlockedURLs", { urls: ["*cloudflareinsights.com*"] });
247
+
248
+ await load({ width: 1600, height: 1000 });
249
+ await walkStudio("1600");
250
+ await load({ width: 1280, height: 800 });
251
+ await walkStudio("1280");
252
+ await load({ session: false, width: 1440, height: 900 });
253
+ await waitFor("!!document.querySelector('.v2-start-screen')", 60000);
254
+ await collect("start screen");
255
+
256
+ const sourceFiles = [];
257
+ const walk = (dir) => {
258
+ for (const name of readdirSync(dir)) {
259
+ const path = join(dir, name);
260
+ if (statSync(path).isDirectory()) walk(path);
261
+ else if (/\.(jsx?|mjs)$/.test(name)) sourceFiles.push(readFileSync(path, "utf8"));
262
+ }
263
+ };
264
+ walk(join(root, "src"));
265
+ const sourceBlob = sourceFiles.join("\n");
266
+ const nameUsed = new Map();
267
+ const referenced = (name) => {
268
+ if (!nameUsed.has(name)) nameUsed.set(name, new RegExp(`(?<![\\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`).test(sourceBlob));
269
+ return nameUsed.get(name);
270
+ };
271
+ const lineOf = (offset) => sourceText.slice(0, offset).split("\n").length;
272
+ const candidates = [];
273
+ sourceRules.forEach((rule, index) => {
274
+ if (used.has(index)) return;
275
+ const names = [...new Set([...rule.selector.matchAll(/[.#](-?[_a-zA-Z][\w-]*)/g)].map((match) => match[1]))];
276
+ if (!names.length) return; // element-only rules are never pruned by this pass
277
+ if (names.some(referenced)) return;
278
+ candidates.push({ index, selector: rule.selector.replace(/\s+/g, " "), line: lineOf(rule.source.start.offset), endLine: lineOf(rule.source.end.offset) });
279
+ });
280
+ writeFileSync(out, JSON.stringify({ sheet: SHEET, rules: sourceRules.length, used: used.size, states, candidates }, null, 2));
281
+ console.log(`${SHEET}: ${sourceRules.length} rules, ${used.size} used, ${candidates.length} unused with no src reference -> ${relative(process.cwd(), out)}`);
282
+ ws.close();
283
+ process.exit(0);
@@ -1,13 +1,26 @@
1
- // Count the Studio's simultaneously visible controls per workflow mode and
2
- // screenshot each state. This is the before/after metric behind
3
- // docs/studio-ui-ia.md §1 (R6 mode budget: Scene <=35 / Camera <=38 / Motion <=52).
1
+ // Count what the Studio shows at once per workflow mode, screenshot each
2
+ // state, and hold the "controls" count to the v2 budgets (docs/studio-ui-ia.md §1).
4
3
  //
5
- // QA_URL=http://127.0.0.1:5180/app/ CDP_PORT=9241 OUT=/tmp/studio-count \
4
+ // QA_URL=http://127.0.0.1:5180/app/?motion=/demo/walk-then-stop.npz CDP_PORT=9241 OUT=/tmp/studio-count \
6
5
  // node tools/qa-browser.mjs -- node tools/qa/studio-control-count.mjs
7
6
  //
8
- // Writes <OUT>-<state>.png and <OUT>-counts.json. A "control" is a rendered
9
- // button/select/range/checkbox/a.topbar-action with a non-zero box; CSS
10
- // display:none does not count, hover-revealed chevrons do.
7
+ // Writes <OUT>-<state>.png and <OUT>-counts.json (default OUT /tmp/studio-count)
8
+ // and exits 1 when any state has more controls than its budget.
9
+ //
10
+ // Every rendered button/select/range/checkbox/a.topbar-action with a non-zero
11
+ // box is counted (CSS display:none does not count, hover-revealed chevrons do)
12
+ // and split in two:
13
+ // items - data, which grows with the project: Outliner tree rows and their
14
+ // disclosure carets, Content asset tiles and Content category rows;
15
+ // controls - everything else (chrome). Only controls have a budget.
16
+ // "total" (= controls + items) is the pre-v2 metric the R6 targets used.
17
+
18
+ // v2 baseline measured on 40767ed (motion 55 -> 52 after #550 moved generate to the top bar); the pre-v2 R6 targets were 35/45/38/52 (docs/studio-ui-ia.md §1).
19
+ const BUDGETS = { "stage-none": 37, "stage-char": 45, pose: 58, camera: 39, motion: 52 };
20
+
21
+ import { writeFileSync } from "node:fs";
22
+
23
+ const OUT = process.env.OUT || "/tmp/studio-count";
11
24
  const port = Number(process.env.CDP_PORT || 9222);
12
25
  const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
13
26
  const page = targets.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
@@ -17,47 +30,72 @@ let id = 0; const pending = new Map();
17
30
  ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m); pending.delete(m.id); } };
18
31
  const send = (method, params = {}) => new Promise((r) => { id += 1; pending.set(id, r); ws.send(JSON.stringify({ id, method, params })); });
19
32
  const ev = async (x) => { const r = await send("Runtime.evaluate", { expression: x, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description); return r.result?.value; };
20
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
- const fs = await import("node:fs");
33
+ // A MutationObserver armed before the check resolves on the exact DOM state.
34
+ const waitFor = (expression, timeoutMs = 20000) => ev(`new Promise((resolve, reject) => {
35
+ const test = () => { try { return Boolean(${expression}); } catch { return false; } };
36
+ if (test()) { resolve(true); return; }
37
+ const observer = new MutationObserver(() => { if (!test()) return; observer.disconnect(); clearTimeout(timer); resolve(true); });
38
+ observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
39
+ const timer = setTimeout(() => { observer.disconnect(); reject(new Error(${JSON.stringify(`Timed out waiting for: ${expression}`)})); }, ${timeoutMs});
40
+ })`);
41
+ const settle = () => ev("new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(true))))");
22
42
 
23
43
  await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
24
44
  if (process.env.QA_URL) await send("Page.navigate", { url: process.env.QA_URL });
25
- const t0 = Date.now();
26
- while (Date.now() - t0 < 60000) { if (await ev("!!(window.__cozyclay&&window.__cozyclay.rigA&&window.__cozyclay.editorCam)").catch(() => false)) break; await sleep(250); }
27
- await sleep(1500);
28
- // hide the project chooser if present
29
- await ev(`(()=>{for(const el of document.querySelectorAll("body *")){const text=el.textContent||"";const style=getComputedStyle(el);if(style.position==="fixed"&&/Choose a project to begin|Project name|시작할 프로젝트/i.test(text))el.style.display="none";}return true;})()`);
45
+ await waitFor("!!(window.__cozyclay && window.__cozyclay.rigA && window.__cozyclay.editorCam) && !!document.querySelector('[data-mode-key]') && !!document.querySelector('[data-node-id=characterA] .hierarchy-row')", 60000);
46
+ await ev("window.__cozyclay.pause?.(); true");
30
47
 
31
48
  const COUNT = `(()=>{
32
49
  const vis=(el)=>{const r=el.getBoundingClientRect();if(r.width<2||r.height<2)return false;const s=getComputedStyle(el);return s.visibility!=="hidden"&&s.display!=="none"&&s.opacity!=="0";};
33
50
  const all=[...document.querySelectorAll("button, select, input[type=range], input[type=checkbox], a.topbar-action")].filter(vis);
51
+ const isItem=(el)=>(!!el.closest("[role=treeitem]")&&(el.classList.contains("hierarchy-row")||el.classList.contains("hierarchy-toggle")))
52
+ ||el.matches("[data-testid=content-asset], [data-testid^=content-folder-]");
34
53
  const region=(el)=>{
35
54
  if(el.closest("header.topbar"))return "topbar";
36
- if(el.closest(".viewport-titlebar"))return "viewport-bar";
37
- if(el.closest(".hierarchy-left"))return "hierarchy";
38
- if(el.closest(".inspector, .panel.right, aside.right, .inspector-panel"))return "inspector";
39
- if(el.closest(".timeline, .tl-surface, .take-bar, footer"))return "timeline";
40
- if(el.closest(".asset-pane, .assets"))return "assets";
55
+ if(el.closest(".v2-outliner, .hierarchy-left"))return "outliner";
56
+ if(el.closest(".inspector-sidebar, .inspector"))return "details";
57
+ if(el.closest(".v2-sequencer, .timeline"))return "sequencer";
58
+ if(el.closest("[data-testid=content-browser], .bottom-dock"))return "content";
59
+ if(el.closest(".v2-statusbar, .brandbar"))return "statusbar";
60
+ if(el.closest(".viewport"))return "viewport";
41
61
  return "other:"+(el.closest("aside, section, div[class]")?.className||"").toString().slice(0,30);
42
62
  };
43
- const by={};for(const el of all){const k=region(el);(by[k]??=[]).push((el.getAttribute("aria-label")||el.textContent||el.tagName).trim().replace(/\\s+/g," ").slice(0,24));}
44
- return {total:all.length,by};
63
+ const name=(el)=>(el.getAttribute("aria-label")||el.textContent||el.tagName).trim().replace(/\\s+/g," ").slice(0,24);
64
+ const by={controls:{},items:{}};let controls=0,items=0;
65
+ for(const el of all){const kind=isItem(el)?"items":"controls";if(kind==="items")items+=1;else controls+=1;(by[kind][region(el)]??=[]).push(name(el));}
66
+ const pose=document.querySelector(".pose-details");
67
+ const poseDetails=pose?[...pose.querySelectorAll("button,select,input[type=range],input[type=checkbox]")].filter(vis).length:null;
68
+ return {controls,items,total:all.length,poseDetails,by};
45
69
  })()`;
46
70
 
47
- const clickTab = async (label) => ev(`(()=>{const b=[...document.querySelectorAll(".workflow-mode-switch button")].find(b=>b.textContent.trim()==="${label}");if(!b)return false;b.click();return true;})()`);
71
+ const mode = async (key) => {
72
+ await ev(`document.querySelector('[data-mode-key="${key}"]').click(); true`);
73
+ await waitFor(`document.querySelector('[data-mode-key="${key}"]')?.getAttribute('aria-selected') === 'true'`);
74
+ };
75
+ const selectCharacter = async () => {
76
+ await ev("document.querySelector('[data-node-id=characterA] .hierarchy-row').click(); true");
77
+ await waitFor("document.querySelector('[data-node-id=characterA]')?.getAttribute('aria-selected') === 'true'", 5000);
78
+ };
48
79
  const states = [
49
- ["scene-none", async () => { await clickTab("Scene"); await ev("window.__cozyclay.selectHierarchy?.(null)").catch(()=>{}); }],
50
- ["scene-char", async () => { await clickTab("Scene"); await ev(`(()=>{const el=[...document.querySelectorAll('.hierarchy-left [role=treeitem], .hierarchy-left button, .hierarchy-left li')].find(e=>/Alpha|Character|캐릭터|A\\b/.test(e.textContent));el?.click();return !!el;})()`); }],
51
- ["camera", async () => { await clickTab("Camera"); }],
52
- ["motion", async () => { await clickTab("Motion"); }],
80
+ ["stage-none", async () => { await mode("1"); await ev("window.__cozyclay.selectHierarchy?.(null); true"); }],
81
+ ["stage-char", async () => { await mode("1"); await selectCharacter(); }],
82
+ // Pose as #545 measures it: press 2, IK turns on, Pose Details renders.
83
+ ["pose", async () => { await mode("2"); await waitFor("window.__cozyclay.ikMode === true && !!document.querySelector('.pose-details')"); }],
84
+ ["camera", async () => { await mode("3"); }],
85
+ ["motion", async () => { await mode("4"); await selectCharacter(); }],
53
86
  ];
54
87
  const out = {};
88
+ const over = [];
55
89
  for (const [name, setup] of states) {
56
- await setup(); await sleep(600);
90
+ await setup(); await settle();
57
91
  const shot = await send("Page.captureScreenshot", { format: "png" });
58
- fs.writeFileSync(`${process.env.OUT}-${name}.png`, Buffer.from(shot.data, "base64"));
59
- out[name] = await ev(COUNT);
60
- console.log(name, out[name].total);
92
+ writeFileSync(`${OUT}-${name}.png`, Buffer.from(shot.data, "base64"));
93
+ out[name] = { ...await ev(COUNT), budget: BUDGETS[name] };
94
+ const ok = out[name].controls <= BUDGETS[name];
95
+ if (!ok) over.push(name);
96
+ console.log(`${ok ? "PASS" : "FAIL"} ${name} controls ${out[name].controls} <= ${BUDGETS[name]} (items ${out[name].items}, total ${out[name].total})`);
61
97
  }
62
- fs.writeFileSync(`${process.env.OUT}-counts.json`, JSON.stringify(out, null, 2));
63
- ws.close(); process.exit(0);
98
+ writeFileSync(`${OUT}-counts.json`, JSON.stringify(out, null, 2));
99
+ ws.close();
100
+ if (over.length) console.log(`over budget: ${over.join(", ")}`);
101
+ process.exit(over.length ? 1 : 0);