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,7 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { ko } from "./locale.js";
3
3
 
4
- const STEPS = [
4
+ const ANIMATION_STEPS = [
5
5
  {
6
6
  en: "Select a character in the Outliner.",
7
7
  ko: "아웃라이너에서 캐릭터를 선택하세요.",
@@ -28,8 +28,39 @@ const STEPS = [
28
28
  },
29
29
  ];
30
30
 
31
+ const STORYBOARD_STEPS = [
32
+ {
33
+ en: "Open the Agent panel (Cmd/Ctrl+B).",
34
+ ko: "에이전트 패널을 여세요 (Cmd/Ctrl+B).",
35
+ hintEn: "The Agent panel is where you describe each panel.",
36
+ hintKo: "에이전트 패널에서 각 패널을 설명합니다.",
37
+ },
38
+ {
39
+ en: "Describe one scene in a sentence.",
40
+ ko: "장면 하나를 한 문장으로 설명하세요.",
41
+ hintEn: "One sentence per turn is enough for one panel.",
42
+ hintKo: "패널 하나에는 한 문장이면 충분합니다.",
43
+ },
44
+ {
45
+ en: "Your panel appears on the Board.",
46
+ ko: "패널이 보드에 나타납니다.",
47
+ hintEn: "Open the Board tab to see the new panel card.",
48
+ hintKo: "보드 탭에서 새 패널 카드를 확인하세요.",
49
+ },
50
+ {
51
+ en: "Stylize it, or export the panel pack.",
52
+ ko: "스타일을 입히거나 패널 팩으로 내보내세요.",
53
+ hintEn: "Use Stylize on the card, or export the panel pack.",
54
+ hintKo: "카드에서 스타일을 입히거나 패널 팩으로 내보내세요.",
55
+ },
56
+ ];
57
+
58
+ const STEPS_BY_MODE = { animation: ANIMATION_STEPS, storyboard: STORYBOARD_STEPS };
59
+
31
60
  /** A compact, dismissible first-run checklist that follows the v2 shell grammar. */
32
- export default function FirstSuccessGuide({ open, onDismiss }) {
61
+ export default function FirstSuccessGuide({ open, onDismiss, previsMode = "animation" }) {
62
+ const storyboard = previsMode === "storyboard";
63
+ const STEPS = STEPS_BY_MODE[storyboard ? "storyboard" : "animation"];
33
64
  const [step, setStep] = useState(0);
34
65
  useEffect(() => {
35
66
  if (open) setStep(0);
@@ -49,8 +80,8 @@ export default function FirstSuccessGuide({ open, onDismiss }) {
49
80
  {complete ? (
50
81
  <div className="v2-first-success-guide-complete first-success-guide-complete" id="first-success-guide-description" role="status">
51
82
  <div className="v2-first-success-guide-check first-success-guide-check">✓</div>
52
- <strong>{ko("You made your first shot.", "첫 샷을 만들었어요.")}</strong>
53
- <p>{ko("Select, move, key, and play are the core CozyClay loop. You can close this guide and keep blocking.", "선택하고, 움직이고, 키를 찍고, 재생하는 것이 CozyClay의 핵심 흐름입니다. 가이드를 닫고 계속 블로킹하세요.")}</p>
83
+ <strong>{storyboard ? ko("You made your first panel.", "첫 패널을 만들었어요.") : ko("You made your first shot.", "첫 샷을 만들었어요.")}</strong>
84
+ <p>{storyboard ? ko("Describe, review on the Board, then stylize or export. You can close this guide and keep adding panels.", "설명하고, 보드에서 확인한 뒤 스타일을 입히거나 내보내세요. 가이드를 닫고 패널을 계속 추가하세요.") : ko("Select, move, key, and play are the core CozyClay loop. You can close this guide and keep blocking.", "선택하고, 움직이고, 키를 찍고, 재생하는 것이 CozyClay의 핵심 흐름입니다. 가이드를 닫고 계속 블로킹하세요.")}</p>
54
85
  <button type="button" className="v2-first-success-guide-next first-success-guide-next btn primary" onClick={onDismiss}>{ko("Continue editing", "편집 계속하기")}</button>
55
86
  </div>
56
87
  ) : (
@@ -27,7 +27,9 @@ const CLAIM_KEYS = [
27
27
  HANDLE_PROXY_FLAG,
28
28
  "keyLightPick", // the key-light sun's grab surfaces (app-stage KeyLightPuck)
29
29
  "pathAxis", // travel path: axis-gizmo pick proxies (app-stage ObjectPathHandles)
30
- "pathIndex", // travel path: waypoint dots — index 0 is a valid value
30
+ "pathIndex", // travel path: mark dots — index 0 is a valid value
31
+ "pathRing", // travel path: the selected dot's bank / pitch rings
32
+ "pathLine", // travel path: the drawn route itself — a press near it is aimed at the route
31
33
  "craneAxis", // crane marks: axis-gizmo pick proxies (app-stage CraneHandles)
32
34
  "craneIndex", // crane marks: height dots
33
35
  ];
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import * as THREE from "three";
3
3
  import { STAGE_SIZE } from "./room.jsx";
4
- import { GRID_BACKGROUND, GRID_COLORS } from "./grid-view.js";
4
+ import { GRID_COLORS } from "./grid-view.js";
5
5
 
6
6
  /**
7
7
  * The Blender-style reference grid: one shader plane instead of a floor.
@@ -92,6 +92,3 @@ export function GridFloor({ layer = null }) {
92
92
  </mesh>
93
93
  );
94
94
  }
95
-
96
- /** the void color the mode pairs with; re-exported for the App's background swap */
97
- export { GRID_BACKGROUND };
@@ -120,6 +120,8 @@ function objectRow(object, label) {
120
120
  label,
121
121
  kind: "object",
122
122
  hidden: object.hidden === true,
123
+ // The record's kind, so a row can say it is a node with nothing to draw.
124
+ ...(object.renderer === "empty" ? { renderer: "empty" } : {}),
123
125
  };
124
126
  }
125
127
 
@@ -147,6 +149,47 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
147
149
  // stale record cannot hide a group's children behind an attached row.
148
150
  const listed = sceneObjects.filter((object) => !attachedIds.has(object.id));
149
151
 
152
+ // One row per cast member, built once: a character grouped under a listed
153
+ // object reads under that object's row (it rides the object), every other
154
+ // one stays in the cast. A parent that is missing or carried leaves the
155
+ // character in the cast, as an orphaned prop stays at the top of Props.
156
+ const listedIds = new Set(listed.map((object) => object.id));
157
+ const castRows = [];
158
+ const groupedCharacters = new Map(); // object id → character rows
159
+ if (Array.isArray(characters)) {
160
+ characters.forEach((entry, listIndex) => {
161
+ if (!entry) return;
162
+ const id = characterRowId(entry, listIndex);
163
+ // Every cast member carries its own rig subtree (#78) — ids are
164
+ // namespaced per row (#76) and IK state is per character (#77), so
165
+ // the tree no longer needs the primary-only gate. Carried props
166
+ // follow the rig so the body reads first and the luggage after it.
167
+ const children = [
168
+ ...(entry.model === "proxy-figure" ? [] : [rigSubtree(id)]),
169
+ ...(attachedRows.get(id) ?? []),
170
+ ];
171
+ const parent = typeof entry.parent === "string" && listedIds.has(entry.parent) ? entry.parent : null;
172
+ const row = {
173
+ id,
174
+ label: `Character ${listIndex + 1}`,
175
+ kind: "character",
176
+ hidden: entry.hidden === true,
177
+ ...(parent ? { grouped: true } : {}),
178
+ ...(children.length ? { children } : {}),
179
+ };
180
+ if (!parent) castRows.push(row);
181
+ else if (groupedCharacters.has(parent)) groupedCharacters.get(parent).push(row);
182
+ else groupedCharacters.set(parent, [row]);
183
+ });
184
+ }
185
+ // An object row's children: the parts grouped under it, then the cast
186
+ // members riding it.
187
+ const withRiders = (row, objectId, nested) => {
188
+ const children = [...nested, ...(groupedCharacters.get(objectId) ?? [])];
189
+ if (children.length) row.children = children;
190
+ return row;
191
+ };
192
+
150
193
  const clone = (node) => {
151
194
  const next = { ...node };
152
195
  if (node.id === "props") {
@@ -155,12 +198,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
155
198
  const childrenOf = (parentId) =>
156
199
  listed
157
200
  .filter((object) => (object.parent ?? null) === parentId)
158
- .map((object) => {
159
- const row = objectRow(object, object.name);
160
- const nested = childrenOf(object.id);
161
- if (nested.length) row.children = nested;
162
- return row;
163
- });
201
+ .map((object) => withRiders(objectRow(object, object.name), object.id, childrenOf(object.id)));
164
202
  // An object whose parent is missing still has to appear somewhere, so
165
203
  // anything unreachable from the top level is shown at the top level.
166
204
  // An attached object is not a grouping parent, so its children are
@@ -169,12 +207,7 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
169
207
  const rooted = listed.filter(
170
208
  (object) => (object.parent ?? null) === null || !ids.has(object.parent),
171
209
  );
172
- next.children = rooted.map((object) => {
173
- const row = objectRow(object, object.name);
174
- const nested = childrenOf(object.id);
175
- if (nested.length) row.children = nested;
176
- return row;
177
- });
210
+ next.children = rooted.map((object) => withRiders(objectRow(object, object.name), object.id, childrenOf(object.id)));
178
211
  } else if (node.children) {
179
212
  next.children = node.children.map(clone);
180
213
  }
@@ -186,29 +219,9 @@ export function buildHierarchyNodes(sceneObjects = [], characters = null) {
186
219
  // the inspector already route to; extras carry their character id.
187
220
  if (Array.isArray(characters)) {
188
221
  const group = nodes[0]?.children?.find((node) => node.id === "characters");
189
- if (group) {
190
- // Row ids follow the entry's LIST index so they match the viewport
191
- // pickId (A/B/charId) stamped by the Character renderer in App.jsx.
192
- group.children = characters.flatMap((entry, listIndex) => {
193
- if (!entry) return [];
194
- const id = characterRowId(entry, listIndex);
195
- // Every cast member carries its own rig subtree (#78) — ids are
196
- // namespaced per row (#76) and IK state is per character (#77), so
197
- // the tree no longer needs the primary-only gate. Carried props
198
- // follow the rig so the body reads first and the luggage after it.
199
- const children = [
200
- rigSubtree(id),
201
- ...(attachedRows.get(id) ?? []),
202
- ];
203
- return [{
204
- id,
205
- label: `Character ${listIndex + 1}`,
206
- kind: "character",
207
- hidden: entry.hidden === true,
208
- ...(children.length ? { children } : {}),
209
- }];
210
- });
211
- }
222
+ // Row ids follow the entry's LIST index so they match the viewport
223
+ // pickId (A/B/charId) stamped by the Character renderer in App.jsx.
224
+ if (group) group.children = castRows;
212
225
  }
213
226
  return nodes;
214
227
  }
@@ -194,6 +194,7 @@
194
194
  .v2-outliner .v2-outliner-chip.cast { background: var(--chip-cast, #5fb584); }
195
195
  .v2-outliner .v2-outliner-chip.cam { background: var(--camera, #a78bfa); }
196
196
  .v2-outliner .v2-outliner-chip.light { background: var(--chip-light, #d9b84a); }
197
+ .v2-outliner .v2-outliner-chip.empty { background: transparent; border: 1px dashed var(--chip-mesh, #7e8b99); }
197
198
 
198
199
  .v2-outliner .hierarchy-label {
199
200
  flex: 1 1 auto;
@@ -4,6 +4,7 @@ import { buildHierarchyNodes } from "./hierarchy-model.js";
4
4
  import { sceneObjectIdFromHierarchy } from "./scene-objects.js";
5
5
  import { CatalogueEntries, displayObjectLabel } from "./object-catalog.jsx";
6
6
  import { Dropdown } from "./ui.jsx";
7
+ import { isImeComposing } from "./ime.js";
7
8
  import "./hierarchy-panel.css";
8
9
 
9
10
  const HIERARCHY_LABELS_KO = {
@@ -58,96 +59,6 @@ export const HIERARCHY_DRAG_MIME = "application/x-cclay-hierarchy";
58
59
 
59
60
  const carriesHierarchyRow = (event) => !!event.dataTransfer?.types?.includes?.(HIERARCHY_DRAG_MIME);
60
61
 
61
- function HierarchyIcon({ kind, className = "" }) {
62
- const common = {
63
- className: `hierarchy-icon ${kind}${className ? ` ${className}` : ""}`,
64
- viewBox: "0 0 24 24",
65
- fill: "none",
66
- stroke: "currentColor",
67
- strokeWidth: 1.7,
68
- strokeLinecap: "round",
69
- strokeLinejoin: "round",
70
- "aria-hidden": true,
71
- };
72
-
73
- switch (kind) {
74
- case "scene":
75
- return (
76
- <svg {...common}>
77
- <path d="M12 3.25 21 8l-9 4.75L3 8l9-4.75Z" fill="currentColor" fillOpacity=".16" />
78
- <path d="m4.25 12 7.75 4.1 7.75-4.1M4.25 16 12 20.1l7.75-4.1" />
79
- </svg>
80
- );
81
- case "camera":
82
- return (
83
- <svg {...common}>
84
- <rect x="3" y="6.5" width="13.5" height="11" rx="2" />
85
- <path d="m16.5 10 4.5-2.25v8.5L16.5 14" />
86
- <circle cx="9.5" cy="12" r="2.4" />
87
- </svg>
88
- );
89
- case "light":
90
- return (
91
- <svg {...common}>
92
- <circle cx="12" cy="12" r="4" fill="currentColor" fillOpacity=".16" />
93
- <path d="M12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6" />
94
- </svg>
95
- );
96
- case "group":
97
- return (
98
- <svg {...common}>
99
- <circle cx="9" cy="8.25" r="2.75" />
100
- <circle cx="16.5" cy="9.25" r="2.15" />
101
- <path d="M3.75 18c.45-3.25 2.15-5 5.25-5s4.8 1.75 5.25 5M14 14.25c3.55-.55 5.65.75 6.25 3.75" />
102
- </svg>
103
- );
104
- case "character":
105
- return (
106
- <svg {...common}>
107
- <circle cx="12" cy="7.25" r="3" fill="currentColor" fillOpacity=".12" />
108
- <path d="M5.25 20c.45-5 2.7-7.5 6.75-7.5S18.3 15 18.75 20M9.25 13.25 12 16l2.75-2.75" />
109
- </svg>
110
- );
111
- case "rig":
112
- return (
113
- <svg {...common}>
114
- <circle cx="12" cy="4.5" r="1.75" fill="currentColor" fillOpacity=".18" />
115
- <circle cx="6" cy="11.5" r="1.75" />
116
- <circle cx="18" cy="11.5" r="1.75" />
117
- <circle cx="12" cy="19.5" r="1.75" />
118
- <path d="m10.8 5.8-3.6 4.4m6-4.4 3.6 4.4M7.75 12.65l3 5.4m5.5-5.4-3 5.4" />
119
- </svg>
120
- );
121
- case "bone":
122
- return (
123
- <svg {...common}>
124
- <path d="M7.4 8.25a2.5 2.5 0 1 1-3.65-3.4A2.5 2.5 0 1 1 7.2 8.5l8.3 8.3a2.5 2.5 0 1 1 3.65 3.4 2.5 2.5 0 1 1-3.4-3.65L7.4 8.25Z" />
125
- </svg>
126
- );
127
- case "environment":
128
- return (
129
- <svg {...common}>
130
- <circle cx="12" cy="12" r="8.5" fill="currentColor" fillOpacity=".08" />
131
- <path d="M3.8 13h16.4M12 3.5c2.25 2.35 3.4 5.2 3.4 8.5S14.25 18.15 12 20.5C9.75 18.15 8.6 15.3 8.6 12S9.75 5.85 12 3.5Z" />
132
- </svg>
133
- );
134
- case "props":
135
- case "object":
136
- return (
137
- <svg {...common}>
138
- <path d="m12 3.5 8 4.25v8.5l-8 4.25-8-4.25v-8.5L12 3.5Z" fill="currentColor" fillOpacity=".1" />
139
- <path d="m4.25 7.9 7.75 4.2 7.75-4.2M12 12.1v8.15" />
140
- </svg>
141
- );
142
- default:
143
- return (
144
- <svg {...common}>
145
- <circle cx="12" cy="12" r="7.5" />
146
- </svg>
147
- );
148
- }
149
- }
150
-
151
62
  function displaySceneName(name) {
152
63
  if (!isKo) return name;
153
64
  const generatedName = /^SCENE\s+(\d+)$/i.exec(name);
@@ -202,6 +113,7 @@ function displayHierarchyLabel(node) {
202
113
  }
203
114
 
204
115
  const OUTLINER_TYPES = {
116
+ empty: "Empty",
205
117
  folder: "Folder",
206
118
  mesh: "Mesh",
207
119
  cast: "Cast",
@@ -211,7 +123,7 @@ const OUTLINER_TYPES = {
211
123
 
212
124
  function outlinerTypeFor(node) {
213
125
  const key = node.kind === "object"
214
- ? "mesh"
126
+ ? node.renderer === "empty" ? "empty" : "mesh"
215
127
  : node.kind === "character" || node.kind === "bone"
216
128
  ? "cast"
217
129
  : node.kind === "camera"
@@ -219,7 +131,9 @@ function outlinerTypeFor(node) {
219
131
  : node.kind === "light"
220
132
  ? "light"
221
133
  : "folder";
222
- const label = key === "folder"
134
+ const label = key === "empty"
135
+ ? ko(OUTLINER_TYPES[key], "빈 오브젝트")
136
+ : key === "folder"
223
137
  ? ko(OUTLINER_TYPES[key], "폴더")
224
138
  : key === "mesh"
225
139
  ? ko(OUTLINER_TYPES[key], "메시")
@@ -339,9 +253,16 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
339
253
  </button>
340
254
  </>
341
255
  ) : menu.kind === "character" ? (
342
- <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("visibility", menu.id)}>
343
- {menu.hidden ? ko("Show", "표시") : ko("Hide", "숨기기")}
344
- </button>
256
+ <>
257
+ <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("visibility", menu.id)}>
258
+ {menu.hidden ? ko("Show", "표시") : ko("Hide", "숨기기")}
259
+ </button>
260
+ {menu.grouped && (
261
+ <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("ungroup-character", menu.id)}>
262
+ {ko("Remove from group", "그룹에서 빼기")}
263
+ </button>
264
+ )}
265
+ </>
345
266
  ) : menu.kind === "object" ? (
346
267
  <>
347
268
  <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("visibility", menu.id)}>
@@ -359,6 +280,14 @@ function RowContextMenu({ menu, onClose, onAction, onAddObject }) {
359
280
  <button type="button" role="menuitem" className="hierarchy-context-item" onClick={() => onAction("frame", menu.id)}>
360
281
  {ko("Frame", "프레임 맞추기")}
361
282
  </button>
283
+ <button type="button" role="menuitem" className="hierarchy-context-item" data-action="group-under-empty" onClick={() => onAction("group-under-empty", menu.id)}>
284
+ {ko("Group under new Empty", "빈 오브젝트로 묶기")}
285
+ </button>
286
+ {menu.grouped && (
287
+ <button type="button" role="menuitem" className="hierarchy-context-item" data-action="ungroup-object" onClick={() => onAction("ungroup-object", menu.id)}>
288
+ {ko("Move out to Props", "그룹에서 빼기 (Props로)")}
289
+ </button>
290
+ )}
362
291
  </>
363
292
  ) : (
364
293
  <CatalogueEntries onPick={onAddObject} />
@@ -423,10 +352,11 @@ function TreeRow({
423
352
  dropHandlers.onDrop(event);
424
353
  },
425
354
  };
426
- // Row drag: an object row is the handle, ANY row can be the landing spot —
427
- // the panel holds no policy, the caller's canDrop decides every rule.
355
+ // Row drag: an object or character row is the handle, ANY row can be the
356
+ // landing spot — the panel holds no policy, the caller's canDrop decides
357
+ // every rule.
428
358
  const [rowDropOver, setRowDropOver] = useState(false);
429
- const draggableRow = node.kind === "object" && !editing;
359
+ const draggableRow = (node.kind === "object" || node.kind === "character") && !editing;
430
360
  const rowDropTarget = !!(reparent && dragSourceId && reparent.canDrop?.(dragSourceId, node.id));
431
361
  const rowDrag = (reparent || draggableRow) && {
432
362
  ...(draggableRow
@@ -559,7 +489,7 @@ function TreeRow({
559
489
  autoFocus
560
490
  onFocus={(event) => event.currentTarget.select()}
561
491
  onKeyDown={(event) => {
562
- if (event.key === "Enter") {
492
+ if (event.key === "Enter" && !isImeComposing(event)) {
563
493
  event.preventDefault();
564
494
  event.stopPropagation(); // keep the tree hotkey quiet
565
495
  finish();
@@ -627,6 +557,8 @@ export default function HierarchyPanel({
627
557
  onDuplicateObject,
628
558
  onDeleteObject,
629
559
  onFrameObject,
560
+ onGroupObject,
561
+ renameRequest = null,
630
562
  onToggleHidden,
631
563
  propsDrop = null,
632
564
  reparent = null,
@@ -647,6 +579,10 @@ export default function HierarchyPanel({
647
579
  // Row currently in in-place rename. The panel owns it: F2/Return and the
648
580
  // row context menu are the only ways in, so app state stays out of it.
649
581
  const [editingId, setEditingId] = useState(null);
582
+ // A freshly made Empty asks to be named: open its row's rename field.
583
+ useEffect(() => {
584
+ if (renameRequest?.id) setEditingId(renameRequest.id);
585
+ }, [renameRequest?.id, renameRequest?.nonce]);
650
586
  // The row being dragged. dataTransfer.getData() is deliberately blank during
651
587
  // dragover in Chrome, so canDrop could never gate the highlight from the
652
588
  // payload alone — the id lives here from dragstart until dragend/drop.
@@ -764,13 +700,18 @@ export default function HierarchyPanel({
764
700
  event.stopPropagation(); // a row pick must not also open the create menu
765
701
  const node = findHierarchyNode(hierarchyNodes, id);
766
702
  if (node?.kind === "object" || node?.kind === "character") {
703
+ // A grouped object can leave its group from the menu too, not only by
704
+ // dragging its row onto Props.
705
+ const objectId = node.kind === "object" ? sceneObjectIdFromHierarchy(id) : null;
706
+ const inGroup = objectId ? Boolean(sceneObjects.find((entry) => entry.id === objectId)?.parent) : false;
767
707
  setContextMenu({
768
708
  x: event.clientX,
769
709
  y: event.clientY,
770
- height: node.kind === "object" ? 180 : 44,
710
+ height: node.kind === "object" ? (inGroup ? 248 : 212) : node.grouped ? 80 : 44,
771
711
  kind: node.kind,
772
712
  id,
773
713
  hidden: node.hidden === true,
714
+ grouped: node.grouped === true || inGroup,
774
715
  });
775
716
  } else if (id === SCENE_ROOT_ID) {
776
717
  // The root row is the scene document: its own verbs, never the
@@ -784,13 +725,13 @@ export default function HierarchyPanel({
784
725
  canDelete: availableScenes.length > 1,
785
726
  });
786
727
  } else {
787
- setContextMenu({ x: event.clientX, y: event.clientY, height: 344, kind: "create" });
728
+ setContextMenu({ x: event.clientX, y: event.clientY, height: 376, kind: "create" });
788
729
  }
789
730
  };
790
731
 
791
732
  const openCreateMenu = (event) => {
792
733
  event.preventDefault(); // suppress the browser menu on the tree only
793
- setContextMenu({ x: event.clientX, y: event.clientY, height: 344, kind: "create" });
734
+ setContextMenu({ x: event.clientX, y: event.clientY, height: 376, kind: "create" });
794
735
  };
795
736
 
796
737
  const deleteActiveScene = () => {
@@ -824,11 +765,22 @@ export default function HierarchyPanel({
824
765
  onToggleHidden?.(hierarchyId);
825
766
  return;
826
767
  }
768
+ if (action === "ungroup-object") {
769
+ // The same door as dropping the row on Props: out of its group, kept where it is.
770
+ if (reparent?.canDrop?.(hierarchyId, "props")) reparent.onDrop?.(hierarchyId, "props");
771
+ return;
772
+ }
773
+ if (action === "ungroup-character") {
774
+ // Out of the group is a drop on the scene root: the same policy door.
775
+ if (reparent?.canDrop?.(hierarchyId, SCENE_ROOT_ID)) reparent.onDrop?.(hierarchyId, SCENE_ROOT_ID);
776
+ return;
777
+ }
827
778
  const objectId = sceneObjectIdFromHierarchy(hierarchyId);
828
779
  if (!objectId) return;
829
780
  if (action === "duplicate") onDuplicateObject?.(objectId);
830
781
  else if (action === "delete") onDeleteObject?.(objectId);
831
782
  else if (action === "frame") onFrameObject?.(objectId);
783
+ else if (action === "group-under-empty") onGroupObject?.(objectId);
832
784
  };
833
785
 
834
786
  const commitRename = (hierarchyId, name) => {
package/src/history.js CHANGED
@@ -46,8 +46,6 @@ export function canRedo(history) {
46
46
  return history.future.length > 0;
47
47
  }
48
48
 
49
- // ---------------------------------------------------------------- tx ----
50
- //
51
49
  // `cancel` is teardown-only by contract: it must not call back into the
52
50
  // coordinator (`endTransaction`/`settleTransaction`). The coordinator owns
53
51
  // the close — it retires the token BEFORE running `cancel`, so a close
@@ -1,7 +1,4 @@
1
- export const I2V_MOTION_MODEL = "minimax/h3-max-turbo/image-to-video";
2
- export const I2V_MOTION_RESOLUTION = "480P";
3
1
  export const I2V_MOTION_MIN_DURATION = 5;
4
- export const I2V_MOTION_MAX_DURATION = 15;
5
2
  // The durations the AI motion card offers. 15 s (362 frames) is the measured upper
6
3
  // end the H3 endpoint accepts and the extractor handles (#380 sit-to-bench).
7
4
  export const I2V_MOTION_DURATIONS = Object.freeze([5, 10, 15]);
@@ -61,7 +58,7 @@ export function submitMotionJob(input, fetchImpl = fetch) {
61
58
  return request(`/v1/motion/${kind}`, body, fetchImpl);
62
59
  }
63
60
 
64
- export function getMotionJob(id, fetchImpl = fetch) {
61
+ function getMotionJob(id, fetchImpl = fetch) {
65
62
  return request(`/v1/motion/jobs/${encodeURIComponent(id)}`, undefined, fetchImpl);
66
63
  }
67
64
 
package/src/ime.js ADDED
@@ -0,0 +1,6 @@
1
+ // Browsers report an Enter key from an active input method editor as a
2
+ // composing key event. Chromium and Safari also use the legacy 229 keyCode;
3
+ // keep both signals so text commits never trigger an action first.
4
+ export function isImeComposing(event) {
5
+ return Boolean(event?.isComposing || event?.nativeEvent?.isComposing || event?.keyCode === 229 || event?.which === 229);
6
+ }
@@ -19,7 +19,7 @@ export function keyframePackName(shot) {
19
19
  return `cozyclay-shot-${shot.index}-${slugifyTitle(shot.title)}.zip`;
20
20
  }
21
21
 
22
- function readmeText({ shot, fps, hasLast, hasClip, ext }) {
22
+ function readmeText({ shot, fps, hasLast, hasClip, ext, hasStylized }) {
23
23
  const lines = [
24
24
  `CozyClay keyframe pack - shot ${shot.index}: ${shot.title}`,
25
25
  `Frame range ${shot.startFrame}-${shot.endFrame} at ${fps} fps.`,
@@ -58,30 +58,39 @@ function readmeText({ shot, fps, hasLast, hasClip, ext }) {
58
58
  "- blocking-depth.mp4: shot-wide normalised depth video; lighter pixels are closer.",
59
59
  "- prompt.txt: the text prompt describing the intended action.",
60
60
  );
61
+ if (hasStylized) lines.push("- stylized.png: the stylized picture of this panel; first.png is its greybox.");
61
62
  return lines.join("\n") + "\n";
62
63
  }
63
64
 
64
65
  /**
65
66
  * Build the ZIP entries for one shot's keyframe pack.
66
67
  * @param {object} args
67
- * @param {{ title: string, index: number, startFrame: number, endFrame: number }} args.shot
68
+ * @param {{ title: string, index: number, startFrame: number, endFrame: number, kind?: "clip" | "still" }} args.shot
69
+ * a still (storyboard panel) is one picture held for its whole range:
70
+ * camera.json gets startFrame === endFrame === the panel start, `hold`
71
+ * frames and `kind: "still"`.
72
+ * @param {string} [args.folder] folder inside the zip, default `<index>-<slug>`
68
73
  * @param {number} args.fps
69
74
  * @param {Uint8Array} args.firstFramePng
70
75
  * @param {Uint8Array | null} args.lastFramePng
71
76
  * @param {{ data: Uint8Array, ext: "mp4" | "webm" } | null} args.clip
72
77
  * @param {object} args.camera
73
78
  * @param {string} args.prompt
79
+ * @param {Uint8Array | null} [args.stylizedPng] the panel's stylized picture
74
80
  * @returns {Array<{ name: string, data: Uint8Array | string }>} entries for buildZip
75
81
  */
76
- export function keyframePackEntries({ shot, fps, firstFramePng, lastFramePng, clip, camera, prompt }) {
77
- const folder = `${shot.index}-${slugifyTitle(shot.title)}`;
82
+ export function keyframePackEntries({ shot, fps, firstFramePng, lastFramePng, clip, camera, prompt, stylizedPng = null, folder = `${shot.index}-${slugifyTitle(shot.title)}` }) {
78
83
  const entries = [{ name: `${folder}/first.png`, data: firstFramePng }];
79
84
  if (lastFramePng != null) entries.push({ name: `${folder}/last.png`, data: lastFramePng });
80
85
  if (clip != null) entries.push({ name: `${folder}/clip.${clip.ext}`, data: clip.data });
86
+ if (stylizedPng != null) entries.push({ name: `${folder}/stylized.png`, data: stylizedPng });
87
+ const range = shot.kind === "still"
88
+ ? { startFrame: shot.startFrame, endFrame: shot.startFrame, hold: shot.endFrame - shot.startFrame + 1, kind: "still" }
89
+ : { startFrame: shot.startFrame, endFrame: shot.endFrame };
81
90
  entries.push(
82
- { name: `${folder}/camera.json`, data: JSON.stringify({ ...camera, fps, startFrame: shot.startFrame, endFrame: shot.endFrame }, null, 2) + "\n" },
91
+ { name: `${folder}/camera.json`, data: JSON.stringify({ ...camera, fps, ...range }, null, 2) + "\n" },
83
92
  { name: `${folder}/prompt.txt`, data: `${prompt}\n` },
84
- { name: `${folder}/README.txt`, data: readmeText({ shot, fps, hasLast: lastFramePng != null, hasClip: clip != null, ext: clip ? clip.ext : "mp4" }) },
93
+ { name: `${folder}/README.txt`, data: readmeText({ shot, fps, hasLast: lastFramePng != null, hasClip: clip != null, ext: clip ? clip.ext : "mp4", hasStylized: stylizedPng != null }) },
85
94
  );
86
95
  return entries;
87
96
  }
package/src/line-edit.js CHANGED
@@ -116,7 +116,7 @@ export const CURVE_GRAB_RADIUS_PX = 14;
116
116
  * any amount of drift (App.jsx paints it detached and refuses new gestures
117
117
  * until the view comes back). 1e-4 is tighter than any deliberate nudge and
118
118
  * looser than float noise from re-deriving the same matrix. */
119
- export const CAMERA_DRIFT_EPSILON = 1e-4;
119
+ const CAMERA_DRIFT_EPSILON = 1e-4;
120
120
 
121
121
  /**
122
122
  * The rendering camera -> the C6 `camera` block: { fx, fy, cx, cy, R, t }.
@@ -577,7 +577,7 @@ export const DRAW_BUNCHED_WINDOW_FRAMES = 24;
577
577
  * two distinguishable PLACES on the path, and that is a screen-distance
578
578
  * question. Twice the grab radius is the app's own scale for "the same spot":
579
579
  * inside it a user could not have aimed at one end rather than the other. */
580
- export const DRAW_MATCH_MIN_SEPARATION_PX = 2 * CURVE_GRAB_RADIUS_PX;
580
+ const DRAW_MATCH_MIN_SEPARATION_PX = 2 * CURVE_GRAB_RADIUS_PX;
581
581
 
582
582
  /** Seam easing, replacing the hard 2-frame pin on the DRAW path only.
583
583
  *
@@ -608,7 +608,7 @@ export const DRAW_MATCH_MIN_SEPARATION_PX = 2 * CURVE_GRAB_RADIUS_PX;
608
608
  * (Table 5), so a dozen frames is the scale at which the sampler already thinks
609
609
  * about "near a constraint". The 2-frame floor keeps the shortest legal window
610
610
  * behaving exactly like the pin it replaces. */
611
- export const SEAM_EASE_FRACTION = 0.15;
611
+ const SEAM_EASE_FRACTION = 0.15;
612
612
  export const SEAM_EASE_MIN = 2;
613
613
  export const SEAM_EASE_MAX = 12;
614
614
 
@@ -1133,7 +1133,7 @@ export function curveToPoints2d(curve) {
1133
1133
  for (let i = 0; i < count; i += 1) {
1134
1134
  // Rounded rather than floored so the sampling is symmetric about the
1135
1135
  // middle; i = count-1 lands exactly on `last`, i = 0 exactly on 0.
1136
- const source = count === 1 ? 0 : Math.round((i * last) / (count - 1));
1136
+ const source = Math.round((i * last) / (count - 1));
1137
1137
  const point = visible[source];
1138
1138
  points2d.push([point.u, point.v]);
1139
1139
  }
@@ -10,14 +10,14 @@ const MOTION_TELEMETRY_EVENTS = new Set([
10
10
  ]);
11
11
  const LIVE_TELEMETRY_EVENTS = new Set([...MOTION_TELEMETRY_EVENTS, ...EXECUTION_TELEMETRY_EVENTS.filter((event) => event.startsWith("mcp:"))]);
12
12
 
13
- export const LIVE_CONTROL_PORT = import.meta.env?.VITE_COZYCLAY_LIVE_PORT ?? "5184";
13
+ const LIVE_CONTROL_PORT = import.meta.env?.VITE_COZYCLAY_LIVE_PORT ?? "5184";
14
14
  export const liveControlUrl = (port = LIVE_CONTROL_PORT) => `ws://127.0.0.1:${port}/live`;
15
- export const LIVE_CONTROL_URL = liveControlUrl();
15
+ const LIVE_CONTROL_URL = liveControlUrl();
16
16
  // Reconnect schedule: 1 s doubling to a 15 s cap with +-25 % jitter, so a hub
17
17
  // that comes back does not take every editor's retry in the same millisecond.
18
- export const LIVE_RECONNECT_BASE_MS = 1_000;
18
+ const LIVE_RECONNECT_BASE_MS = 1_000;
19
19
  export const LIVE_RECONNECT_MAX_MS = 15_000;
20
- export const LIVE_RECONNECT_JITTER = 0.25;
20
+ const LIVE_RECONNECT_JITTER = 0.25;
21
21
  // App-level liveness, sent only to a hub that advertises `heartbeatMs`: a TCP
22
22
  // connection to a dead hub can stay open for minutes without a single frame.
23
23
  export const LIVE_PING_INTERVAL_MS = 20_000;
@@ -25,11 +25,11 @@ export const LIVE_PONG_TIMEOUT_MS = 10_000;
25
25
  // A blocked main thread (a 30 s capture_frame) delays every timer at once. A
26
26
  // tick that late is evidence of that stall, not of a dead hub, so the round is
27
27
  // restarted instead of being counted as a missed pong.
28
- export const LIVE_HEARTBEAT_SLACK_MS = 2_000;
28
+ const LIVE_HEARTBEAT_SLACK_MS = 2_000;
29
29
  // Per-tab identity. sessionStorage is per tab and survives a reload, which is
30
30
  // exactly the lifetime the hub routes retained motion outcomes to.
31
31
  export const LIVE_WORKSPACE_ID_KEY = "cozyclay:live-workspace-id";
32
- export const LIVE_DUPLICATE_CLOSE_CODE = 1008;
32
+ const LIVE_DUPLICATE_CLOSE_CODE = 1008;
33
33
  export const LIVE_DUPLICATE_CLOSE_REASON = "Workspace id is already connected";
34
34
 
35
35
  // Storage can be absent (Node), disabled, or throw on access alone; the tab