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
package/src/dualview.jsx CHANGED
@@ -28,6 +28,8 @@ export const SHOT_ASPECT = 16 / 9;
28
28
  * styles.css) rather than the scene's sky. Kept in sync by eye: a mismatch
29
29
  * here reads as a seam around the frame, not as a wrong colour. */
30
30
  export const LETTERBOX = new THREE.Color("#1e1e1e");
31
+ // The light theme's bars take the light panel tone, not a black slab.
32
+ export const LETTERBOX_LIGHT = new THREE.Color("#dcdde1");
31
33
  /** half-height of the plan frustum, in metres — covers the full camera throw */
32
34
  export const PLAN_EXTENT = 12.4;
33
35
 
@@ -113,7 +115,7 @@ export function fitAspect(rect, aspect) {
113
115
  return { x: rect.x + (rect.w - w) / 2, y: rect.y + (rect.h - h) / 2, w, h };
114
116
  }
115
117
 
116
- export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT }) {
118
+ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCamRef, planCamRef, poserCamRef, editorCamRef, ikMode = false, planIsMain, playMode = false, lookThrough = false, insetCollapsed = false, planZoom = 1, shotAspect = SHOT_ASPECT, editorLook = false }) {
117
119
  const invalidate = useThree((state) => state.invalidate);
118
120
  const lastPoserPose = useRef({ position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false });
119
121
  const interactivePixelRatio = useRef({ base: null, reduced: false, needsFrozenRedraw: false });
@@ -129,6 +131,22 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
129
131
  cancelAnimationFrame(f2);
130
132
  };
131
133
  }, [invalidate]);
134
+ // The theme is read per frame, but a demand-mode canvas draws nothing after
135
+ // a toggle unless something asks for a frame.
136
+ useEffect(() => {
137
+ window.addEventListener("cozyclay:theme-change", invalidate);
138
+ return () => window.removeEventListener("cozyclay:theme-change", invalidate);
139
+ }, [invalidate]);
140
+ // Likewise when the preview card is resized (the dock handle changes
141
+ // --shell-dock): the dock's glass hole grows at once, and without a new
142
+ // frame it shows whatever the canvas last drew there.
143
+ useEffect(() => {
144
+ const card = shotPreviewRef?.current;
145
+ if (!card || typeof ResizeObserver === "undefined") return undefined;
146
+ const observer = new ResizeObserver(() => invalidate());
147
+ observer.observe(card);
148
+ return () => observer.disconnect();
149
+ }, [shotPreviewRef, invalidate]);
132
150
  const { gl, scene, size } = useThree();
133
151
  const edgePass = useMemo(() => {
134
152
  const target = new THREE.WebGLRenderTarget(1, 1, {
@@ -230,7 +248,11 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
230
248
  // A collapsed inset is just its tag pill: skip the whole inset pass
231
249
  // instead of drawing a squished viewport into it.
232
250
  const insetRect = insetCollapsed ? { x: 0, y: 0, w: 0, h: 0 } : rectOf(insetRef.current);
233
- if (mainRect.w < 2 || (!playMode && !insetCollapsed && insetRect.w < 2)) return;
251
+ // The glass shell hides the plan/shot inset with CSS (display: none), which
252
+ // leaves it 0x0. That skips the inset pass like a collapsed one; it must not
253
+ // skip the main pane, or the stage stays blank.
254
+ const insetOff = insetCollapsed || (!playMode && insetRect.w < 2);
255
+ if (mainRect.w < 2) return;
234
256
 
235
257
  const planPane = planIsMain ? mainRect : insetRect;
236
258
  const shotPane = planIsMain ? insetRect : mainRect;
@@ -292,11 +314,13 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
292
314
  // rather than information. They take the editor's own tone instead, and
293
315
  // the scene draw is scissored to the image so the sky inside the frame is
294
316
  // untouched.
317
+ const uiTheme = typeof document !== "undefined" ? document.documentElement.dataset.theme : undefined;
318
+ const letterbox = uiTheme === "light" ? LETTERBOX_LIGHT : LETTERBOX;
295
319
  const draw = (camera, pane, imageRect, ink = true) => {
296
320
  const glY = size.height - (pane.y + pane.h);
297
321
  gl.setScissorTest(true);
298
322
  gl.setScissor(pane.x, glY, pane.w, pane.h);
299
- gl.setClearColor(LETTERBOX, 1);
323
+ gl.setClearColor(letterbox, 1);
300
324
  gl.clear(true, true, false);
301
325
  const img = imageRect ?? pane;
302
326
  const imgY = size.height - (img.y + img.h);
@@ -321,9 +345,14 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
321
345
  // used to trace an empty frame around every pasted image — the
322
346
  // "border that will not go away". Hiding the card from the prepass
323
347
  // leaves the wall behind it continuous, so no edge is drawn.
348
+ // The deck takes no ink either (userData.noInk, room.jsx): seen at a
349
+ // grazing angle its depth runs away fast enough that the depth
350
+ // kernel inked the whole far floor as a dark band under the
351
+ // horizon. Without it in the prepass, figures still outline
352
+ // against the cleared depth behind them.
324
353
  const cutouts = [];
325
354
  scene.traverse((node) => {
326
- if (node.userData?.cutoutTexture !== undefined && node.visible) {
355
+ if ((node.userData?.cutoutTexture !== undefined || node.userData?.noInk) && node.visible) {
327
356
  cutouts.push(node);
328
357
  node.visible = false;
329
358
  }
@@ -350,9 +379,38 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
350
379
  gl.autoClear = true;
351
380
  }
352
381
  };
382
+ // The stage is the grey workbench in every pane and in every capture
383
+ // (STAGE_BACKGROUND, room.jsx), so an editor pane is a plain draw.
384
+ const drawEditorPane = draw;
353
385
  const drawVisibleInset = (...args) => {
354
- if (!insetCollapsed) draw(...args);
386
+ if (!insetOff) draw(...args);
387
+ };
388
+ // The shot card. Every main-pane draw above covers the whole canvas,
389
+ // including the pixels under the card, so the card is repainted every
390
+ // frame — a camera drag used to leave the editor's pixels showing through
391
+ // it. Only the ink pass waits for the gesture to settle.
392
+ const drawPreviewCard = () => {
393
+ const previewEl = shotPreviewRef?.current;
394
+ if (!previewEl || previewEl.hidden) return;
395
+ const previewRect = rectOf(previewEl);
396
+ // The inset card's header strip (src/shell/viewport.css) is chrome:
397
+ // the frame is drawn into the card body below it.
398
+ const header = parseFloat(getComputedStyle(previewEl).getPropertyValue("--shot-preview-header")) || 0;
399
+ previewRect.y += header;
400
+ previewRect.h -= header;
401
+ if (previewRect.w < 2 || previewRect.h < 2) return;
402
+ const previewMask = shotCam.layers.mask;
403
+ shotCam.layers.disable(GIZMO_LAYER);
404
+ draw(shotCam, previewRect, fitAspect(previewRect, shotAspect), !navigatingCamera);
405
+ shotCam.layers.mask = previewMask;
355
406
  };
407
+ // The heads' facing marks are the exported frame's heading cue, not
408
+ // something to look at: the studio hides them in every on-screen draw and
409
+ // puts them back before the frame ends, so captures (which render outside
410
+ // this loop) still carry them.
411
+ const marks = [];
412
+ if (editorLook) scene.traverse((node) => { if (node.userData.facingMark) marks.push(node); });
413
+ for (const mark of marks) mark.visible = false;
356
414
 
357
415
  gl.autoClear = true;
358
416
  shotCam.aspect = shotAspect;
@@ -378,60 +436,58 @@ export function DualRender({ stageRef, mainRef, insetRef, shotPreviewRef, shotCa
378
436
  // tested first, so the Workflow embed and the playground rail land HERE —
379
437
  // the framed player — and never in the editing draw below that keeps the
380
438
  // gizmo layer, the plan inset, and fly controls on the shot camera.
381
- if (playMode) {
382
- // PlayView (Unity Game view): the shot camera owns the whole pane —
383
- // no plan inset, no editing chrome, just the framed output. Editor
384
- // furniture (selection cage, gizmo, grid, pins) lives on
385
- // GIZMO_LAYER and is dropped for this draw, mask restored after.
386
- const playMask = shotCam.layers.mask;
387
- shotCam.layers.disable(GIZMO_LAYER);
388
- draw(shotCam, mainRect, fitAspect(mainRect, shotAspect));
389
- shotCam.layers.mask = playMask;
390
- } else if (ikMode && poserCam) {
391
- // IK mode: the main pane is the poser working view (free navigation,
392
- // handle layer visible); the inset is the FROZEN shot camera — the
393
- // separate camera-placement screen the framing lives on.
394
- poserCam.aspect = mainRect.w / mainRect.h;
395
- poserCam.updateProjectionMatrix();
396
- draw(poserCam, mainRect, null, !navigatingCamera);
397
- if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
398
- } else if (planIsMain) {
399
- draw(planCam, planPane, null, false);
400
- if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
401
- } else if (editorCam && !lookThrough) {
402
- // Split-camera editing: the main pane is the EDITOR camera — free
403
- // navigation that never touches the recording. The shot camera only
404
- // appears in its own preview pane, framed to the export aspect and
405
- // stripped of editing chrome, exactly like an exported frame.
406
- editorCam.aspect = mainRect.w / mainRect.h;
407
- editorCam.updateProjectionMatrix();
408
- // A right-drag is a camera gesture even with IK off. Keep the main
409
- // image readable while the camera is moving by deferring the expensive
410
- // edge/ink pass until the gesture ends; the static panes stay frozen too.
411
- draw(editorCam, mainRect, null, !navigatingCamera);
412
- if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
413
- const previewEl = shotPreviewRef?.current;
414
- if (previewEl && !previewEl.hidden && (!navigatingCamera || redrawFrozenPanes)) {
415
- const previewRect = rectOf(previewEl);
416
- if (previewRect.w >= 2) {
417
- const previewMask = shotCam.layers.mask;
418
- shotCam.layers.disable(GIZMO_LAYER);
419
- draw(shotCam, previewRect, fitAspect(previewRect, shotAspect), !navigatingCamera);
420
- shotCam.layers.mask = previewMask;
421
- }
439
+ try {
440
+ if (playMode) {
441
+ // PlayView (Unity Game view): the shot camera owns the whole pane —
442
+ // no plan inset, no editing chrome, just the framed output. Editor
443
+ // furniture (selection cage, gizmo, grid, pins) lives on
444
+ // GIZMO_LAYER and is dropped for this draw, mask restored after.
445
+ const playMask = shotCam.layers.mask;
446
+ shotCam.layers.disable(GIZMO_LAYER);
447
+ draw(shotCam, mainRect, fitAspect(mainRect, shotAspect));
448
+ shotCam.layers.mask = playMask;
449
+ } else if (ikMode && poserCam) {
450
+ // IK mode: the main pane is the poser working view (free navigation,
451
+ // handle layer visible); the inset is the FROZEN shot camera — the
452
+ // separate camera-placement screen the framing lives on.
453
+ poserCam.aspect = mainRect.w / mainRect.h;
454
+ poserCam.updateProjectionMatrix();
455
+ drawEditorPane(poserCam, mainRect, null, !navigatingCamera);
456
+ if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, insetRect, fitAspect(insetRect, shotAspect), !navigatingCamera);
457
+ drawPreviewCard();
458
+ } else if (planIsMain) {
459
+ draw(planCam, planPane, null, false);
460
+ if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
461
+ drawPreviewCard();
462
+ } else if (editorCam && !lookThrough) {
463
+ // Split-camera editing: the main pane is the EDITOR camera — free
464
+ // navigation that never touches the recording. The shot camera only
465
+ // appears in its own preview pane, framed to the export aspect and
466
+ // stripped of editing chrome, exactly like an exported frame.
467
+ editorCam.aspect = mainRect.w / mainRect.h;
468
+ editorCam.updateProjectionMatrix();
469
+ // A right-drag is a camera gesture even with IK off. Keep the main
470
+ // image readable while the camera is moving by deferring the expensive
471
+ // edge/ink pass until the gesture ends; the plan inset stays frozen.
472
+ drawEditorPane(editorCam, mainRect, null, !navigatingCamera);
473
+ if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
474
+ drawPreviewCard();
475
+ } else {
476
+ // Shot camera in the main pane WITH the editing chrome. Look-through
477
+ // (the PiP expand) lands here so the operator
478
+ // can fly the recording lens with the same bindings as the free camera.
479
+ // The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
480
+ // Look-through is the recording lens: it keeps the authored stage.
481
+ draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
482
+ if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
483
+ drawPreviewCard();
422
484
  }
423
- } else {
424
- // Shot camera in the main pane WITH the editing chrome. Look-through
425
- // (the PiP expand) lands here so the operator
426
- // can fly the recording lens with the same bindings as the free camera.
427
- // The QA hook (`window.__cozyclay.setLookThrough`) draws the same path.
428
- draw(shotCam, shotPane, fitAspect(shotPane, shotAspect), !navigatingCamera);
429
- if (!navigatingCamera || redrawFrozenPanes) drawVisibleInset(planCam, planPane, null, false);
485
+ if (redrawFrozenPanes) pixelState.needsFrozenRedraw = false;
486
+ } finally {
487
+ for (const mark of marks) mark.visible = true;
488
+ gl.setScissorTest(false);
489
+ gl.setViewport(0, 0, size.width, size.height);
430
490
  }
431
- if (redrawFrozenPanes) pixelState.needsFrozenRedraw = false;
432
-
433
- gl.setScissorTest(false);
434
- gl.setViewport(0, 0, size.width, size.height);
435
491
  }, 1);
436
492
 
437
493
  return null;
@@ -66,6 +66,9 @@ export function addFacingMarks(clone) {
66
66
  if (spec.rotation) mesh.rotation.set(...spec.rotation);
67
67
  mesh.castShadow = true;
68
68
  mesh.frustumCulled = false;
69
+ // The marks are for the exported frame (the model's heading cue); the
70
+ // studio's editor panes hide them per draw (dualview.jsx) by this tag.
71
+ mesh.userData.facingMark = true;
69
72
  // The head bone's local +Z is the face direction on both rigs.
70
73
  head.add(mesh);
71
74
  }
@@ -3,8 +3,8 @@ import { ko } from "./locale.js";
3
3
 
4
4
  const STEPS = [
5
5
  {
6
- en: "Select a character in the Hierarchy panel.",
7
- ko: "계층 패널에서 캐릭터를 선택하세요.",
6
+ en: "Select a character in the Outliner.",
7
+ ko: "아웃라이너에서 캐릭터를 선택하세요.",
8
8
  hintEn: "Click Character 1 (or its row) to make it active.",
9
9
  hintKo: "캐릭터 1(또는 캐릭터 행)을 클릭하면 활성화됩니다.",
10
10
  },
@@ -28,7 +28,7 @@ const STEPS = [
28
28
  },
29
29
  ];
30
30
 
31
- /** A small, dismissible first-run checklist shown after creating a project. */
31
+ /** A compact, dismissible first-run checklist that follows the v2 shell grammar. */
32
32
  export default function FirstSuccessGuide({ open, onDismiss }) {
33
33
  const [step, setStep] = useState(0);
34
34
  useEffect(() => {
@@ -38,38 +38,38 @@ export default function FirstSuccessGuide({ open, onDismiss }) {
38
38
  const complete = step >= STEPS.length;
39
39
 
40
40
  return (
41
- <aside className="first-success-guide" role="dialog" aria-labelledby="first-success-guide-title" aria-describedby="first-success-guide-description">
42
- <div className="first-success-guide-head">
41
+ <aside className="v2-first-success-guide first-success-guide" role="dialog" aria-labelledby="first-success-guide-title" aria-describedby="first-success-guide-description">
42
+ <div className="v2-first-success-guide-head first-success-guide-head">
43
43
  <div>
44
44
  <strong id="first-success-guide-title">{ko("Your first 60 seconds", "첫 60초")}</strong>
45
45
  <span>{complete ? ko("Core loop complete", "핵심 흐름 완료") : ko(`Step ${step + 1} of ${STEPS.length}`, `${step + 1} / ${STEPS.length} 단계`)}</span>
46
46
  </div>
47
- <button type="button" className="first-success-guide-close" onClick={onDismiss} aria-label={ko("Dismiss guide", "가이드 닫기")}>×</button>
47
+ <button type="button" className="v2-first-success-guide-close first-success-guide-close" onClick={onDismiss} aria-label={ko("Dismiss guide", "가이드 닫기")}>×</button>
48
48
  </div>
49
49
  {complete ? (
50
- <div className="first-success-guide-complete" id="first-success-guide-description" role="status">
51
- <div className="first-success-guide-check">✓</div>
50
+ <div className="v2-first-success-guide-complete first-success-guide-complete" id="first-success-guide-description" role="status">
51
+ <div className="v2-first-success-guide-check first-success-guide-check">✓</div>
52
52
  <strong>{ko("You made your first shot.", "첫 샷을 만들었어요.")}</strong>
53
53
  <p>{ko("Select, move, key, and play are the core CozyClay loop. You can close this guide and keep blocking.", "선택하고, 움직이고, 키를 찍고, 재생하는 것이 CozyClay의 핵심 흐름입니다. 가이드를 닫고 계속 블로킹하세요.")}</p>
54
- <button type="button" className="btn primary" onClick={onDismiss}>{ko("Continue editing", "편집 계속하기")}</button>
54
+ <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={onDismiss}>{ko("Continue editing", "편집 계속하기")}</button>
55
55
  </div>
56
56
  ) : (
57
57
  <>
58
- <p id="first-success-guide-description" className="first-success-guide-intro">{ko("Follow these four actions to see a result quickly.", "네 가지 동작으로 빠르게 결과를 확인해 보세요.")}</p>
59
- <ol className="first-success-guide-steps">
58
+ <p id="first-success-guide-description" className="v2-first-success-guide-intro first-success-guide-intro">{ko("Follow these four actions to see a result quickly.", "네 가지 동작으로 빠르게 결과를 확인해 보세요.")}</p>
59
+ <ol className="v2-first-success-guide-steps first-success-guide-steps">
60
60
  {STEPS.map((item, index) => (
61
61
  <li key={item.en} className={index < step ? "done" : index === step ? "current" : ""}>
62
- <span className="first-success-guide-step-mark">{index < step ? "✓" : index + 1}</span>
62
+ <span className="v2-first-success-guide-step-mark first-success-guide-step-mark">{index < step ? "✓" : index + 1}</span>
63
63
  <div><strong>{ko(item.en, item.ko)}</strong>{index === step && <span>{ko(item.hintEn, item.hintKo)}</span>}</div>
64
64
  </li>
65
65
  ))}
66
66
  </ol>
67
- <button type="button" className="btn primary first-success-guide-next" onClick={() => setStep((value) => value + 1)}>
67
+ <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={() => setStep((value) => value + 1)}>
68
68
  {ko(step === STEPS.length - 1 ? "Mark complete" : "I did this", step === STEPS.length - 1 ? "완료로 표시" : "했어요")}
69
69
  </button>
70
70
  </>
71
71
  )}
72
- <button type="button" className="first-success-guide-skip" onClick={onDismiss}>{ko("Skip guide", "가이드 건너뛰기")}</button>
72
+ <button type="button" className="v2-first-success-guide-skip first-success-guide-skip" onClick={onDismiss}>{ko("Skip guide", "가이드 건너뛰기")}</button>
73
73
  </aside>
74
74
  );
75
75
  }
package/src/grid-view.js CHANGED
@@ -6,11 +6,24 @@
6
6
 
7
7
  export const GRID_VIEW_STORAGE_KEY = "cozyclay.grid-view.v1";
8
8
 
9
- // Blender's solid-mode look: a dark grey void, not true black. Fog matches
10
- // the background so distance still dissolves into the same neutral. The far
11
- // plane stays below the capture rig's CAPTURE_FOG_NEAR (55) so pushing near
12
- // past far still disables fog for exported frames, exactly as in clay mode.
13
- export const GRID_BACKGROUND = "#2c2e33";
9
+ // Blender's solid-mode look: a dark grey void, not true black. Under the dark
10
+ // UI theme the stage wears it — editor, shot preview and exports — so the set
11
+ // reads as one grey workbench; the grid view only trades the deck for the
12
+ // reference grid (and keeps the dark void under both themes: its line palette
13
+ // is tuned for it). Fog matches the background so distance still dissolves
14
+ // into the same neutral. The far plane stays below the capture rig's
15
+ // CAPTURE_FOG_NEAR (55) so pushing near past far still disables fog for
16
+ // exported frames.
17
+ export const STAGE_BACKGROUND = "#232428";
18
+ // The light UI theme's stage: a photo-studio cyclorama. A near-white sky the
19
+ // deck fades into, so the floor has no horizon line and the figures stand in
20
+ // a bright, soft room that sits in the light shell instead of punching a
21
+ // dark hole in it.
22
+ // Matched to the lit deck's far tone (room.jsx FLOOR_LIGHT after tone
23
+ // mapping), so fog blends floor into sky without a step.
24
+ export const STAGE_BACKGROUND_LIGHT = "#e7e8eb";
25
+ export const stageBackground = (theme) => (theme === "light" ? STAGE_BACKGROUND_LIGHT : STAGE_BACKGROUND);
26
+ export const GRID_BACKGROUND = STAGE_BACKGROUND;
14
27
  export const GRID_FOG = Object.freeze({ color: GRID_BACKGROUND, near: 30, far: 54 });
15
28
 
16
29
  // Line palette, tuned against the dark background the way Blender's theme
@@ -101,38 +101,45 @@ function characterRowId(entry, listIndex) {
101
101
  return listIndex === 0 ? "characterA" : listIndex === 1 ? "characterB" : `character:${entry.id}`;
102
102
  }
103
103
 
104
- /** The visible cast as `characterId → row id`; hidden entries have no row, so
105
- * anything attached to them has nowhere to nest and falls back to Props. */
106
- function visibleCharacterRows(characters) {
104
+ /** Every cast member as `characterId → row id`, including hidden ones.
105
+ * A prop attached to a hidden character stays under that row. An unknown
106
+ * characterId still has no row and falls back to Props. */
107
+ function characterRows(characters) {
107
108
  const rows = new Map();
108
109
  if (!Array.isArray(characters)) return rows;
109
110
  characters.forEach((entry, listIndex) => {
110
- if (!entry || entry.hidden) return;
111
+ if (!entry || typeof entry.id !== "string") return;
111
112
  rows.set(entry.id, characterRowId(entry, listIndex));
112
113
  });
113
114
  return rows;
114
115
  }
115
116
 
117
+ function objectRow(object, label) {
118
+ return {
119
+ id: `object:${object.id}`,
120
+ label,
121
+ kind: "object",
122
+ hidden: object.hidden === true,
123
+ };
124
+ }
125
+
116
126
  export function buildHierarchyNodes(sceneObjects = [], characters = null) {
117
127
  // An attached prop is carried BY a character, so it reads under that
118
128
  // character rather than in the flat Props list it no longer follows.
119
- const characterRows = visibleCharacterRows(characters);
129
+ const characterRowsById = characterRows(characters);
120
130
  const attachedRows = new Map(); // character row id → attached object rows
121
131
  const attachedIds = new Set();
122
132
  for (const object of sceneObjects) {
123
133
  const attach = object?.attach;
124
134
  if (!attach || typeof attach !== "object") continue;
125
- const rowId = characterRows.get(attach.characterId);
126
- // A dangling / hidden characterId keeps the object in Props: a prop that
135
+ const rowId = characterRowsById.get(attach.characterId);
136
+ // A dangling characterId keeps the object in Props: a prop that
127
137
  // vanishes from the tree is worse than one filed in the wrong place.
138
+ // A hidden character still has a row, so the prop stays under it.
128
139
  if (!rowId) continue;
129
140
  attachedIds.add(object.id);
130
141
  const boneLabel = attachBoneLabel(attach.bone);
131
- const row = {
132
- id: `object:${object.id}`,
133
- label: boneLabel ? `${object.name} · ${boneLabel}` : object.name,
134
- kind: "object",
135
- };
142
+ const row = objectRow(object, boneLabel ? `${object.name} · ${boneLabel}` : object.name);
136
143
  if (attachedRows.has(rowId)) attachedRows.get(rowId).push(row);
137
144
  else attachedRows.set(rowId, [row]);
138
145
  }
@@ -149,7 +156,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
149
156
  listed
150
157
  .filter((object) => (object.parent ?? null) === parentId)
151
158
  .map((object) => {
152
- const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
159
+ const row = objectRow(object, object.name);
153
160
  const nested = childrenOf(object.id);
154
161
  if (nested.length) row.children = nested;
155
162
  return row;
@@ -163,7 +170,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
163
170
  (object) => (object.parent ?? null) === null || !ids.has(object.parent),
164
171
  );
165
172
  next.children = rooted.map((object) => {
166
- const row = { id: `object:${object.id}`, label: object.name, kind: "object" };
173
+ const row = objectRow(object, object.name);
167
174
  const nested = childrenOf(object.id);
168
175
  if (nested.length) row.children = nested;
169
176
  return row;
@@ -174,7 +181,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
174
181
  return next;
175
182
  };
176
183
  const nodes = HIERARCHY_NODES.map(clone);
177
- // The cast is list-driven: one row per visible character. The first two
184
+ // The cast is list-driven: one row per character, hidden ones included. The first two
178
185
  // keep the legacy row ids (characterA/characterB) that selection, IK and
179
186
  // the inspector already route to; extras carry their character id.
180
187
  if (Array.isArray(characters)) {
@@ -183,7 +190,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
183
190
  // Row ids follow the entry's LIST index so they match the viewport
184
191
  // pickId (A/B/charId) stamped by the Character renderer in App.jsx.
185
192
  group.children = characters.flatMap((entry, listIndex) => {
186
- if (entry.hidden) return [];
193
+ if (!entry) return [];
187
194
  const id = characterRowId(entry, listIndex);
188
195
  // Every cast member carries its own rig subtree (#78) — ids are
189
196
  // namespaced per row (#76) and IK state is per character (#77), so
@@ -197,6 +204,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
197
204
  id,
198
205
  label: `Character ${listIndex + 1}`,
199
206
  kind: "character",
207
+ hidden: entry.hidden === true,
200
208
  ...(children.length ? { children } : {}),
201
209
  }];
202
210
  });