cozyclay 2.0.2 → 2.1.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 (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { useFrame, useThree } from "@react-three/fiber";
3
- import { Line } from "@react-three/drei";
3
+ import { Html, Line } from "@react-three/drei";
4
4
  import * as THREE from "three";
5
5
  import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
6
6
  import { poseThumbnail, warmThumbnailModels } from "./pose-thumbs.js";
@@ -8,6 +8,8 @@ import { IK_TRACKS, MID_TRACKS, ikControlIsExposed } from "./ardy/ik.js";
8
8
  import { shouldRefreshIkExposure } from "./use-render-activity.js";
9
9
  import { POSER_LAYER } from "./dualview.jsx";
10
10
  import { ko, isKo } from "./locale.js";
11
+ import { ROTATION_AXES, backFaceFade, bindQuaternionOf, handleColor as sideColor, ringAngle, rotationFromBindDeg, rotationReadout } from "./pose-gizmo.js";
12
+ import "./pose-gizmo.css";
11
13
 
12
14
  /** Same normalised-name match rule as poses.js: equal, or one is a suffix of
13
15
  * the other, so `mixamorig:LeftArm`, `mixamorigLeftArm` and `LeftArm` all hit. */
@@ -38,7 +40,7 @@ function handleColor(entry) {
38
40
  * best matches the drag direction in screen space). Each move calls
39
41
  * `onChange()`.
40
42
  */
41
- export function PoseHandles({ root, enabled, onChange }) {
43
+ export function PoseHandles({ root, enabled, onChange, onCommit }) {
42
44
  const { camera, gl, raycaster } = useThree();
43
45
  const meshRefs = useRef([]);
44
46
  const jointsRef = useRef(new Map());
@@ -46,6 +48,10 @@ export function PoseHandles({ root, enabled, onChange }) {
46
48
  const dragHandlersRef = useRef(null);
47
49
  const onChangeRef = useRef(onChange);
48
50
  onChangeRef.current = onChange;
51
+ // Called once when a drag that actually moved a joint ends: the pose the
52
+ // rig now stands in is the author's, and the host keeps it.
53
+ const onCommitRef = useRef(onCommit);
54
+ onCommitRef.current = onCommit;
49
55
  const tmp = useRef({
50
56
  origin: new THREE.Vector3(),
51
57
  limb: new THREE.Vector3(),
@@ -114,6 +120,7 @@ export function PoseHandles({ root, enabled, onChange }) {
114
120
  bone.quaternion.copy(tmp.qNew);
115
121
  bone.updateMatrixWorld(true);
116
122
  }
123
+ d.moved = true;
117
124
  onChangeRef.current?.(before, d.writable.map((bone) => bone.quaternion.toArray()));
118
125
  };
119
126
  const onUp = () => {
@@ -124,7 +131,9 @@ export function PoseHandles({ root, enabled, onChange }) {
124
131
  window.removeEventListener("pointermove", onMove);
125
132
  window.removeEventListener("pointerup", onUp);
126
133
  window.removeEventListener("pointercancel", onUp);
134
+ const moved = dragRef.current?.moved === true;
127
135
  dragRef.current = null;
136
+ if (moved) onCommitRef.current?.();
128
137
  gl.domElement.style.cursor = "";
129
138
  };
130
139
  dragHandlersRef.current = { onMove, onUp };
@@ -231,28 +240,18 @@ export function PoseHandles({ root, enabled, onChange }) {
231
240
  );
232
241
  }
233
242
 
234
- /** IK handle colour by limb, matching the FK handle coding: arms orange,
235
- * legs blue. */
236
- function ikHandleColor(track) {
237
- return track.kind === "arm" ? "#ff8a3d" : "#4dd2ff";
238
- }
239
-
240
- /** World-axis gizmo colours (Blender / Cascadeur convention). */
241
- const AXIS_DEFS = [
242
- { axis: "X", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
243
- { axis: "Y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
244
- { axis: "Z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
245
- ];
243
+ /** World-axis gizmo colours (Blender / Cascadeur convention), shared with the rotation rings. */
244
+ const AXIS_DEFS = ROTATION_AXES.map(({ axis, dir, color }) => ({ axis: axis.toUpperCase(), dir, color }));
246
245
  const GIZMO_LEN = 0.22;
247
- const GIZMO_SHAFT_R = 0.012;
248
- const GIZMO_TIP_R = 0.03;
246
+ const GIZMO_SHAFT_R = 0.0065;
247
+ const GIZMO_TIP_R = 0.022;
249
248
  const GIZMO_PICK_SHAFT_R = 0.045;
250
249
  const GIZMO_PICK_TIP_R = 0.055;
251
250
  const HANDLE_R = 0.055; // chain target spheres
252
251
  const JOINT_R = 0.042; // mid-joint + FK swing spheres
253
- const SWING_RING_R = 0.12; // effector rotation ring
254
- const SWING_RING_TUBE = 0.012; // visible tube
255
- const SWING_RING_PICK_TUBE = 0.045; // invisible grab tube
252
+ const SWING_RING_R = 0.17; // rotation ring radius, clear of the hand/foot mesh
253
+ const SWING_RING_TUBE = 0.0045; // visible ring line
254
+ const SWING_RING_PICK_TUBE = 0.035; // invisible grab tube
256
255
  /** Unfocused handles fade back so the focused one reads clearly. */
257
256
  const OPACITY_FOCUSED = 1;
258
257
  const OPACITY_UNFOCUSED = 0.5;
@@ -299,7 +298,11 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
299
298
  const skinnedBlockerProxiesRef = useRef(new Map());
300
299
  const occlusionRay = useRef(new THREE.Raycaster()).current;
301
300
  const gizmoRef = useRef(null);
302
- const ringRef = useRef(null); // swing ring around the focused effector
301
+ const ringRef = useRef(null); // rotation rings around the focused joint
302
+ const readoutRef = useRef(null);
303
+ const [hoverAxis, setHoverAxis] = useState(null);
304
+ const [rotationDrag, setRotationDrag] = useState(null); // { axis, angle } while a ring drags
305
+ const [rotationDeg, setRotationDeg] = useState(null);
303
306
  const dragRef = useRef(null);
304
307
  const handlersRef = useRef(null);
305
308
  const solveRef = useRef(onSolve);
@@ -359,6 +362,13 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
359
362
  );
360
363
  raycaster.setFromCamera(tmp.ndc, camera);
361
364
  if (!raycaster.ray.intersectPlane(d.plane, tmp.hit)) return;
365
+ if ((d.kind === "fk" || d.kind === "swing") && d.axisDir) {
366
+ const angle = ringAngle(d.axisDir, d.targetStart, d.hitStart, tmp.hit);
367
+ if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
368
+ solveRef.current?.(d.kind, d.trackId, { axis: d.axisDir, angle, startQuat: d.startQuat, startParentQuat: d.startParentQuat });
369
+ setRotationDrag({ axis: d.axisName, angle });
370
+ return;
371
+ }
362
372
  if (d.kind === "fk" || d.kind === "swing" || (d.kind === "body" && !d.axisDir)) {
363
373
  // Swing = trackball rotation (TransformControls rotate model):
364
374
  // axis = offset × eye, angle = (offset · tangent) × speed/camDist,
@@ -407,6 +417,7 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
407
417
  const d = dragRef.current;
408
418
  dragRef.current = null;
409
419
  gl.domElement.style.cursor = "";
420
+ setRotationDrag(null);
410
421
  if (!d) return;
411
422
  // Click (never dragged): the focused sphere toggles focus OFF; a
412
423
  // not-yet-focused sphere just took focus (nothing to bake). An
@@ -739,11 +750,12 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
739
750
  : clickable
740
751
  ? THREE.MathUtils.lerp(OPACITY_FAR, OPACITY_NEAR, emphasis)
741
752
  : THREE.MathUtils.lerp(OPACITY_DISABLED_FAR, OPACITY_DISABLED_NEAR, emphasis);
742
- mesh.material.emissiveIntensity = focused
743
- ? 2.6
744
- : clickable
745
- ? THREE.MathUtils.lerp(1.5, 2.35, emphasis)
746
- : THREE.MathUtils.lerp(0.45, 0.8, emphasis);
753
+ const [core, halo] = mesh.children;
754
+ if (core) core.material.opacity = mesh.material.opacity;
755
+ if (halo) {
756
+ halo.quaternion.copy(camera.quaternion);
757
+ halo.material.opacity = mesh.material.opacity * (focused ? 0.6 : 0.4);
758
+ }
747
759
  }
748
760
  const focusExposed = !!focus && visibilityRef.current.get(focus) !== false;
749
761
  if (gizmoRef.current) gizmoRef.current.visible = focusExposed;
@@ -773,10 +785,16 @@ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus,
773
785
  // the camera: a trackball ring, not a bone-aligned one — the drag math
774
786
  // is trackball, so the affordance must match.
775
787
  if (ringRef.current && focus) {
776
- const chain = chains?.get(focus);
777
- if (chain) {
778
- ringRef.current.position.copy(chain.bones[2].getWorldPosition(tmp.pos));
779
- ringRef.current.quaternion.copy(camera.getWorldQuaternion(new THREE.Quaternion()));
788
+ const bone = chains?.get(focus)?.bones[2] ?? fkJoints?.get(focus)?.bone ?? null;
789
+ if (bone) {
790
+ bone.getWorldPosition(tmp.pos);
791
+ ringRef.current.position.copy(tmp.pos);
792
+ const deg = rotationFromBindDeg(bindQuaternionOf(bone), bone.quaternion);
793
+ if (!rotationDeg || deg.x !== rotationDeg.x || deg.y !== rotationDeg.y || deg.z !== rotationDeg.z) setRotationDeg(deg);
794
+ if (readoutRef.current) {
795
+ readoutRef.current.position.copy(tmp.pos).add(tmp.delta.set(SWING_RING_R * 0.8, SWING_RING_R * 0.8, 0).applyQuaternion(camera.quaternion));
796
+ readoutRef.current.visible = focusExposed;
797
+ }
780
798
  }
781
799
  }
782
800
  });
@@ -842,7 +860,11 @@ const CLICK_PX = 4;
842
860
  }
843
861
  raycaster.setFromCamera(ndc, camera);
844
862
  let plane;
845
- if (axisDir) {
863
+ const ringDrag = !!axisDir && (kind === "fk" || kind === "swing");
864
+ if (ringDrag) {
865
+ // A ring turns about its axis: the pointer is read on the ring's own plane.
866
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(axisDir, origin);
867
+ } else if (axisDir) {
846
868
  // TransformControls: plane normal = axis × (eye × axis) — the plane
847
869
  // contains the axis and is as view-perpendicular as possible.
848
870
  const eye = camera.getWorldDirection(new THREE.Vector3());
@@ -883,6 +905,7 @@ const CLICK_PX = 4;
883
905
  dragRef.current = null;
884
906
  return;
885
907
  }
908
+ if (ringDrag) dragRef.current.axisName = ROTATION_AXES.find((a) => a.dir === axisDir)?.axis ?? "x";
886
909
  dragRef.current.startQuat = bone.quaternion.clone();
887
910
  dragRef.current.startParentQuat = bone.parent.getWorldQuaternion(new THREE.Quaternion());
888
911
  dragRef.current.eye = origin.clone().sub(camera.getWorldPosition(new THREE.Vector3())).negate().normalize();
@@ -950,10 +973,24 @@ const CLICK_PX = 4;
950
973
  };
951
974
  // The handles live on POSER_LAYER so they render only in the poser
952
975
  // (IK working) view; the raycaster must see that layer to hit them.
976
+ const onHover = (ev) => {
977
+ if (ev.buttons || dragRef.current) return;
978
+ const rings = pickRefs.current.filter((p) => p.part === "ring" && p.mesh?.parent && p.track.id === focusIdRef.current);
979
+ let axis = null;
980
+ if (rings.length) {
981
+ const rect = el.getBoundingClientRect();
982
+ raycaster.setFromCamera(new THREE.Vector2(((ev.clientX - rect.left) / rect.width) * 2 - 1, -((ev.clientY - rect.top) / rect.height) * 2 + 1), camera);
983
+ const hit = raycaster.intersectObjects(rings.map((p) => p.mesh), false)[0];
984
+ axis = hit ? ROTATION_AXES.find((a) => a.dir === rings.find((p) => p.mesh === hit.object)?.axisDir)?.axis ?? null : null;
985
+ }
986
+ setHoverAxis((current) => (current === axis ? current : axis));
987
+ };
953
988
  raycaster.layers.enable(POSER_LAYER);
954
989
  el.addEventListener("pointerdown", onDown, true);
990
+ el.addEventListener("pointermove", onHover);
955
991
  return () => {
956
992
  el.removeEventListener("pointerdown", onDown, true);
993
+ el.removeEventListener("pointermove", onHover);
957
994
  raycaster.layers.disable(POSER_LAYER);
958
995
  };
959
996
  });
@@ -983,19 +1020,17 @@ const CLICK_PX = 4;
983
1020
  register(track, kind)(m);
984
1021
  }}
985
1022
  renderOrder={999}
986
- scale={focused ? 1.2 : 1}
987
1023
  >
988
- <sphereGeometry args={[radius, 16, 16]} />
989
- <meshStandardMaterial
990
- color="#000000"
991
- emissive={color}
992
- emissiveIntensity={focused ? 2.6 : 1.4}
993
- toneMapped={false}
994
- depthTest={false}
995
- depthWrite={false}
996
- transparent
997
- opacity={focused ? OPACITY_FOCUSED : OPACITY_UNFOCUSED}
998
- />
1024
+ <sphereGeometry args={[radius, 12, 8]} />
1025
+ <meshBasicMaterial colorWrite={false} depthTest={false} depthWrite={false} transparent opacity={focused ? OPACITY_FOCUSED : OPACITY_UNFOCUSED} />
1026
+ <mesh renderOrder={999} ref={(m) => m?.layers.set(POSER_LAYER)} raycast={() => {}}>
1027
+ <sphereGeometry args={[radius * (focused ? 0.62 : 0.52), 20, 14]} />
1028
+ <meshBasicMaterial color={color} toneMapped={false} depthTest={false} depthWrite={false} transparent />
1029
+ </mesh>
1030
+ <mesh renderOrder={998} ref={(m) => m?.layers.set(POSER_LAYER)} raycast={() => {}}>
1031
+ <ringGeometry args={[radius * (focused ? 0.86 : 0.8), radius * (focused ? 1.0 : 0.92), 48]} />
1032
+ <meshBasicMaterial color={color} toneMapped={false} depthTest={false} depthWrite={false} transparent side={THREE.DoubleSide} />
1033
+ </mesh>
999
1034
  </mesh>
1000
1035
  );
1001
1036
  };
@@ -1008,17 +1043,17 @@ const CLICK_PX = 4;
1008
1043
  MID_TRACKS.find((t) => t.id === focus) ||
1009
1044
  (focus === "hips" ? (fkJoints?.get("hips")?.track ?? null) : null);
1010
1045
  const gizmoKind = IK_TRACKS.find((t) => t.id === focus) ? "chain" : MID_TRACKS.find((t) => t.id === focus) ? "mid" : focus === "hips" ? "body" : null;
1011
- // The focused chain (hand/foot) also gets a rotation ring: arrows move
1012
- // the wrist/ankle, the ring turns the hand/foot itself.
1013
- const swingTrack = IK_TRACKS.find((t) => t.id === focus) ?? null;
1046
+ // The focused hand/foot gets rotation rings beside its move arrows (the
1047
+ // rings turn the hand/foot itself); a focused FK joint gets rings only.
1048
+ const rotateTrack = IK_TRACKS.find((t) => t.id === focus) ?? (focus && focus !== "hips" ? fkJoints?.get(focus)?.track ?? null : null);
1049
+ const rotateKind = IK_TRACKS.find((t) => t.id === focus) ? "swing" : "fk";
1014
1050
 
1015
1051
  return (
1016
1052
  <group>
1017
- {IK_TRACKS.map((track) => (chains.has(track.id) ? sphereFor(track, "chain", ikHandleColor(track), HANDLE_R) : null))}
1018
- {MID_TRACKS.map((track) => (chains.has(track.chain) ? sphereFor(track, "mid", ikHandleColor({ kind: track.chain.includes("Hand") ? "arm" : "leg" }), JOINT_R) : null))}
1019
- {[...(fkJoints ?? [])].map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", joint.track.color, id === "hips" ? HANDLE_R : JOINT_R))}
1053
+ {IK_TRACKS.map((track) => (chains.has(track.id) ? sphereFor(track, "chain", sideColor(track.id), HANDLE_R) : null))}
1054
+ {MID_TRACKS.map((track) => (chains.has(track.chain) ? sphereFor(track, "mid", sideColor(track.id), JOINT_R) : null))}
1055
+ {[...(fkJoints ?? [])].map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", sideColor(id), id === "hips" ? HANDLE_R : JOINT_R))}
1020
1056
 
1021
- {/* Mini move-gizmo on the focused position handle: world-axis arrows. */}
1022
1057
  {gizmoTrack && (
1023
1058
  <group ref={gizmoRef} renderOrder={999}>
1024
1059
  {AXIS_DEFS.map(({ axis, dir, color }) => {
@@ -1033,7 +1068,7 @@ const CLICK_PX = 4;
1033
1068
  }}
1034
1069
  >
1035
1070
  <cylinderGeometry args={[GIZMO_SHAFT_R, GIZMO_SHAFT_R, GIZMO_LEN, 8]} />
1036
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.9} />
1071
+ <meshBasicMaterial color={color} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.85} />
1037
1072
  </mesh>
1038
1073
  <mesh
1039
1074
  position={[0, GIZMO_LEN + 0.04, 0]}
@@ -1042,8 +1077,8 @@ const CLICK_PX = 4;
1042
1077
  register(gizmoTrack, gizmoKind, dir, "tip")(m);
1043
1078
  }}
1044
1079
  >
1045
- <coneGeometry args={[GIZMO_TIP_R, 0.08, 12]} />
1046
- <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.4} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
1080
+ <coneGeometry args={[GIZMO_TIP_R, 0.06, 16]} />
1081
+ <meshBasicMaterial color={color} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={0.95} />
1047
1082
  </mesh>
1048
1083
  {/* Invisible forgiving hit volumes keep the target
1049
1084
  at least as easy to grab as the visible arrow. */}
@@ -1073,44 +1108,60 @@ const CLICK_PX = 4;
1073
1108
  </group>
1074
1109
  )}
1075
1110
 
1076
- {/* Trackball rotation ring on the focused hand/foot: turns the
1077
- effector itself while the position gizmo keeps moving it. A thin
1078
- visible torus plus a fat invisible one — a 3 px tube raycasts
1079
- terribly, so the pick target is generous. */}
1080
- {swingTrack && (
1111
+ {rotateTrack && (
1081
1112
  <group ref={(g) => { ringRef.current = g; }} renderOrder={999}>
1082
- <mesh
1083
- ref={(m) => {
1084
- if (m) m.layers.set(POSER_LAYER);
1085
- }}
1086
- >
1087
- <torusGeometry args={[SWING_RING_R, SWING_RING_TUBE, 10, 48]} />
1088
- <meshStandardMaterial
1089
- color="#000000"
1090
- emissive={ikHandleColor(swingTrack)}
1091
- emissiveIntensity={2.2}
1092
- toneMapped={false}
1093
- depthTest={false}
1094
- depthWrite={false}
1095
- transparent
1096
- opacity={0.9}
1097
- />
1098
- </mesh>
1099
- <mesh
1100
- ref={(m) => {
1101
- if (m) m.layers.set(POSER_LAYER);
1102
- register(swingTrack, "swing", null, "ring")(m);
1103
- }}
1104
- >
1105
- <torusGeometry args={[SWING_RING_R, SWING_RING_PICK_TUBE, 8, 32]} />
1106
- <meshBasicMaterial transparent opacity={0} depthWrite={false} />
1107
- </mesh>
1113
+ {ROTATION_AXES.map(({ axis, dir, color }) => {
1114
+ const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), dir);
1115
+ const active = rotationDrag?.axis === axis;
1116
+ const hot = active || (!rotationDrag && hoverAxis === axis);
1117
+ return (
1118
+ <group key={axis} quaternion={quat}>
1119
+ <mesh ref={(m) => m?.layers.set(POSER_LAYER)}>
1120
+ <torusGeometry args={[SWING_RING_R, hot ? SWING_RING_TUBE * 2.2 : SWING_RING_TUBE, 6, 96]} />
1121
+ <meshBasicMaterial ref={(m) => m && backFaceFade(m)} color={color} toneMapped={false} depthTest={false} depthWrite={false} transparent opacity={rotationDrag && !active ? 0.15 : hot ? 1 : 0.7} />
1122
+ </mesh>
1123
+ <mesh
1124
+ ref={(m) => {
1125
+ if (m) m.layers.set(POSER_LAYER);
1126
+ register(rotateTrack, rotateKind, dir, "ring")(m);
1127
+ }}
1128
+ >
1129
+ <torusGeometry args={[SWING_RING_R, SWING_RING_PICK_TUBE, 8, 48]} />
1130
+ <meshBasicMaterial transparent opacity={0} depthTest={false} depthWrite={false} />
1131
+ </mesh>
1132
+ </group>
1133
+ );
1134
+ })}
1135
+ </group>
1136
+ )}
1137
+ {rotateTrack && rotationDeg && (
1138
+ <group ref={readoutRef}>
1139
+ <Html zIndexRange={[20, 0]} style={{ pointerEvents: "none" }}>
1140
+ <RotationReadout readout={rotationReadout(rotationDeg, rotationDrag)} />
1141
+ </Html>
1108
1142
  </group>
1109
1143
  )}
1110
1144
  </group>
1111
1145
  );
1112
1146
  }
1113
1147
 
1148
+ function RotationReadout({ readout }) {
1149
+ return (
1150
+ <div className="pose-rotation-readout" data-testid="pose-rotation-readout">
1151
+ {readout.delta && (
1152
+ <div className="pose-rotation-delta" data-axis={readout.delta.axis}>
1153
+ <span>{readout.delta.axis.toUpperCase()}</span>{readout.delta.value}
1154
+ </div>
1155
+ )}
1156
+ <div className="pose-rotation-axes">
1157
+ {readout.axes.map(({ axis, value, active }) => (
1158
+ <span key={axis} data-axis={axis} className={active ? "active" : undefined}><i>{axis.toUpperCase()}</i>{value}</span>
1159
+ ))}
1160
+ </div>
1161
+ </div>
1162
+ );
1163
+ }
1164
+
1114
1165
  /** A poser-only target marker for the active range pin. The target is supplied
1115
1166
  * in world space; the line is rebuilt every render frame so object travel and
1116
1167
  * the effector's keyed pose stay visually connected while the playhead moves. */
@@ -219,6 +219,59 @@
219
219
  color: var(--text-selected, #fff);
220
220
  }
221
221
 
222
+ /* Project mode (previs flag): a two-up segmented switch above the tabs. Each
223
+ segment carries its label and the one-line promise of what the mode makes. */
224
+ .v2-start-screen .v2-start-mode {
225
+ display: grid;
226
+ grid-template-columns: repeat(2, minmax(0, 1fr));
227
+ gap: var(--start-mode-gap, 4px);
228
+ max-width: var(--start-mode-max-width, 640px);
229
+ padding: var(--start-mode-pad, 4px);
230
+ border-radius: var(--r-2, 6px);
231
+ background: var(--field, #0f0f11);
232
+ box-shadow: inset 0 0 0 var(--start-border-width, 1px) var(--row-divider, #1d1d20);
233
+ }
234
+
235
+ .v2-start-screen .v2-start-mode-option {
236
+ min-width: var(--start-template-min-width, 0);
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: var(--start-copy-gap, 3px);
240
+ padding: var(--sp-1, 8px) var(--sp-2, 12px);
241
+ border: 0;
242
+ border-radius: var(--r-1, 4px);
243
+ background: var(--transparent, transparent);
244
+ color: var(--text-2, #9b9ba1);
245
+ text-align: left;
246
+ }
247
+
248
+ .v2-start-screen .v2-start-mode-option strong {
249
+ color: inherit;
250
+ font-size: var(--start-template-title-size, 12px);
251
+ font-weight: 500;
252
+ }
253
+
254
+ .v2-start-screen .v2-start-mode-option span {
255
+ color: var(--text-3, #636369);
256
+ font-size: var(--start-template-meta-size, 11px);
257
+ line-height: 1.35;
258
+ }
259
+
260
+ .v2-start-screen .v2-start-mode-option:hover {
261
+ background: var(--surface-raised, #1e1e21);
262
+ color: var(--text-1, #e4e4e7);
263
+ }
264
+
265
+ .v2-start-screen .v2-start-mode-option.active {
266
+ background: var(--surface-raised, #1e1e21);
267
+ box-shadow: inset 0 0 0 var(--start-selected-width, 1.5px) var(--select, #e8a33d);
268
+ color: var(--text-selected, #fff);
269
+ }
270
+
271
+ .v2-start-screen .v2-start-mode-option.active span {
272
+ color: var(--text-2, #9b9ba1);
273
+ }
274
+
222
275
  .v2-start-screen .v2-start-template-grid {
223
276
  display: grid;
224
277
  grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -12,6 +12,7 @@ import {
12
12
  removeRecentProject,
13
13
  requestHandlePermission,
14
14
  storeProjectsDirectory,
15
+ DEFAULT_PREVIS_MODE,
15
16
  } from "./project.js";
16
17
  import "./project-browser.css";
17
18
 
@@ -26,6 +27,21 @@ const BLANK_TEMPLATE = Object.freeze({
26
27
  code: "NEW",
27
28
  });
28
29
 
30
+ // The two project modes the New view offers.
31
+ // Storyboard starts from an empty stage only; Animation keeps every template.
32
+ const MODE_OPTIONS = [
33
+ {
34
+ id: "storyboard",
35
+ label: () => ko("Storyboard", "스토리보드"),
36
+ blurb: () => ko("Describe scenes in words and direct one panel at a time. Output: image references.", "말로 장면을 설명하면 한 장씩 패널을 연출합니다. 결과물: 이미지 레퍼런스."),
37
+ },
38
+ {
39
+ id: "animation",
40
+ label: () => ko("Animation", "애니메이션"),
41
+ blurb: () => ko("Block and shoot continuous motion. Output: clip references.", "연속 동작을 블로킹하고 촬영합니다. 결과물: 클립 레퍼런스."),
42
+ },
43
+ ];
44
+
29
45
  // App.jsx keeps the project mutation behind its named requestNewProject/newProject
30
46
  // path. The v2 chooser already has the name, so this short-lived handoff seeds
31
47
  // the existing ProjectNameDialog and lets its normal submit path commit it.
@@ -91,9 +107,10 @@ export function ProjectNameDialog({ open, initialName = "My Project", onCancel,
91
107
  */
92
108
  export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew, onClose, startup = false, starters = [], onStarter }) {
93
109
  const starterTemplates = useMemo(() => starters.map(sampleTemplate), [starters]);
110
+ const [previsMode, setPrevisMode] = useState(DEFAULT_PREVIS_MODE);
94
111
  const templates = useMemo(
95
- () => [BLANK_TEMPLATE, ...starterTemplates],
96
- [starterTemplates],
112
+ () => (previsMode === "storyboard" ? [BLANK_TEMPLATE] : [BLANK_TEMPLATE, ...starterTemplates]),
113
+ [previsMode, starterTemplates],
97
114
  );
98
115
  const defaultTemplate = starterTemplates[0] ?? BLANK_TEMPLATE;
99
116
  const [activeNav, setActiveNav] = useState(startup ? "new" : "recent");
@@ -186,16 +203,26 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
186
203
  setName((current) => current.trim() ? current : template.name);
187
204
  };
188
205
 
206
+ const choosePrevisMode = (mode) => {
207
+ setPrevisMode(mode);
208
+ // Storyboard offers the blank stage only, so a starter selection cannot
209
+ // survive the switch.
210
+ if (mode !== "storyboard" || selectedTemplate?.id === BLANK_TEMPLATE.id) return;
211
+ setSelectedTemplateId(BLANK_TEMPLATE.id);
212
+ // A name the starter card filled in would now describe the wrong stage.
213
+ setName((current) => (current.trim() === selectedTemplate?.name ? BLANK_TEMPLATE.name : current));
214
+ };
215
+
189
216
  const createProject = (event) => {
190
217
  event.preventDefault();
191
218
  const projectName = name.trim();
192
219
  if (!projectName) return;
193
220
  if (selectedTemplate?.starterId) {
194
- onStarter?.(selectedTemplate.starterId, projectName);
221
+ onStarter?.(selectedTemplate.starterId, projectName, { previsMode: "animation" });
195
222
  return;
196
223
  }
197
224
  pendingProjectName = projectName;
198
- onNew(projectName);
225
+ onNew(projectName, { previsMode });
199
226
  };
200
227
 
201
228
  const recentEntries = [
@@ -240,6 +267,24 @@ export default function ProjectBrowser({ currentName, onOpen, onOpenFile, onNew,
240
267
  <h1>{activeNav === "recent" ? ko("Recent", "최근") : activeNav === "samples" ? ko("Samples", "샘플") : activeNav === "learn" ? ko("Learn", "배우기") : ko("New Project", "새 프로젝트")}</h1>
241
268
  {activeNav !== "new" && <button type="button" className="v2-start-close x" onClick={onClose} aria-label={ko("Close", "닫기")}>×</button>}
242
269
  </div>
270
+ {activeNav === "new" && (
271
+ <div className="v2-start-mode" role="radiogroup" aria-label={ko("Project mode", "프로젝트 모드")} data-testid="start-previs-mode">
272
+ {MODE_OPTIONS.map((option) => (
273
+ <button
274
+ type="button"
275
+ key={option.id}
276
+ role="radio"
277
+ aria-checked={previsMode === option.id}
278
+ className={`v2-start-mode-option${previsMode === option.id ? " active" : ""}`}
279
+ data-previs-mode={option.id}
280
+ onClick={() => choosePrevisMode(option.id)}
281
+ >
282
+ <strong>{option.label()}</strong>
283
+ <span>{option.blurb()}</span>
284
+ </button>
285
+ ))}
286
+ </div>
287
+ )}
243
288
  {(activeNav === "new" || activeNav === "samples") && (
244
289
  <div className="v2-start-tabs" role="tablist" aria-label={ko("Template categories", "템플릿 카테고리")}>
245
290
  {CATEGORIES.map((entry) => (