cozyclay 3.2.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/INSTALL_FOR_AGENTS.md +4 -4
  3. package/README.md +4 -2
  4. package/bin/agent/agent-failure-codes.mjs +34 -8
  5. package/bin/agent/agent-routes.mjs +6 -6
  6. package/bin/agent/agent-runner.mjs +16 -13
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/studio-prompt.mjs +2 -2
  10. package/bin/agent/supervisor-followup.mjs +34 -0
  11. package/bin/agent/supervisor.mjs +6 -6
  12. package/bin/cozyclay.mjs +23 -60
  13. package/bin/star-prompt.mjs +15 -14
  14. package/bin/studio-routes.mjs +6 -3
  15. package/bin/telemetry-state.mjs +5 -7
  16. package/bin/update-check.mjs +61 -7
  17. package/bin/update-install.mjs +16 -3
  18. package/bin/update-restart.mjs +95 -0
  19. package/bin/update-routes.mjs +25 -3
  20. package/dist/app/index.html +13 -7
  21. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  22. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  23. package/dist/assets/app-CnDkB5WO.css +1 -0
  24. package/dist/assets/app-DJt06Oyb.js +5144 -0
  25. package/dist/assets/{compile-worker-DRWRa9pr.js → compile-worker-D1pl6G7x.js} +5 -5
  26. package/dist/assets/landing-2Fy7DU41.js +1 -0
  27. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  28. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  29. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  30. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  31. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  32. package/dist/cozyclay-package.json +1 -1
  33. package/dist/index.html +4 -1
  34. package/dist/privacy/index.html +9 -6
  35. package/dist/sitemap.xml +2 -2
  36. package/dist/workflow/index.html +5 -4
  37. package/mcp/README.md +1 -1
  38. package/mcp/live-hub.mjs +4 -4
  39. package/mcp/tool-handlers.mjs +14 -16
  40. package/package.json +2 -1
  41. package/src/App.jsx +37 -70
  42. package/src/analytics-controls.js +25 -3
  43. package/src/analytics-strict.js +16 -4
  44. package/src/analytics.js +32 -26
  45. package/src/app-version.js +13 -0
  46. package/src/asset-drop.js +39 -0
  47. package/src/asset-pane.css +34 -0
  48. package/src/asset-pane.jsx +4 -35
  49. package/src/bus-tasks.js +1 -1
  50. package/src/character-kind.js +8 -6
  51. package/src/click-friction.js +5 -4
  52. package/src/command-bus-telemetry.js +8 -7
  53. package/src/command-bus.js +2 -16
  54. package/src/commands/cast.js +2 -1
  55. package/src/commands/motion3.js +3 -1
  56. package/src/commands/objects.js +2 -37
  57. package/src/commands/project.js +3 -3
  58. package/src/commands/sculpt-compare.js +53 -0
  59. package/src/content-tile.jsx +36 -0
  60. package/src/domains/cast.js +7 -6
  61. package/src/domains/motion.js +6 -5
  62. package/src/domains/scenes.js +41 -31
  63. package/src/execution-telemetry.js +7 -3
  64. package/src/export-menu-telemetry.js +26 -0
  65. package/src/finger-handles.jsx +128 -0
  66. package/src/friction-hint.js +82 -0
  67. package/src/live-control.js +2 -1
  68. package/src/locale-zh/panels.js +20 -0
  69. package/src/locale-zh/shell.js +4 -0
  70. package/src/locale-zh/workspace.js +6 -0
  71. package/src/motion/find-bone.js +13 -0
  72. package/src/motion/fingers.js +170 -0
  73. package/src/motion/fingertip-pick.js +21 -0
  74. package/src/motion/ik-exposure.js +48 -0
  75. package/src/motion/ik.js +4 -11
  76. package/src/motion3/compiler/head-lead.js +4 -2
  77. package/src/motion3/hybrid-hint.js +19 -0
  78. package/src/motion3/load.js +19 -12
  79. package/src/motion3/qa/hints-contact.js +12 -0
  80. package/src/motion3/qa/patch.js +4 -1
  81. package/src/motion3/qa/verdict.js +7 -2
  82. package/src/motion3/repair.js +28 -8
  83. package/src/motion3/skills/catch.js +2 -1
  84. package/src/panels/FingerPanel.jsx +38 -0
  85. package/src/panels/RigControlPanel.jsx +9 -1
  86. package/src/panels/SculptPanel.jsx +12 -12
  87. package/src/panels/SculptPartTree.jsx +46 -0
  88. package/src/panels/SubjectsPanel.jsx +1 -1
  89. package/src/panels/sculpt.css +11 -0
  90. package/src/pose-fade.js +7 -3
  91. package/src/posestudio.jsx +14 -27
  92. package/src/project-browser.css +6 -0
  93. package/src/project-browser.jsx +9 -18
  94. package/src/project-start-choices.jsx +45 -0
  95. package/src/project.js +16 -12
  96. package/src/proxy-figure-shape.js +11 -0
  97. package/src/proxy-figure.jsx +25 -18
  98. package/src/scene-assets.js +17 -13
  99. package/src/scenes.js +22 -11
  100. package/src/sculpt-blueprint.js +56 -0
  101. package/src/sculpt-mode.js +5 -0
  102. package/src/sculpt-part-edit.js +115 -0
  103. package/src/sculpt-part-gizmo.jsx +159 -0
  104. package/src/sculpt-part-outline.jsx +72 -0
  105. package/src/sculpt-pick.js +53 -0
  106. package/src/sculpt-run-text.js +51 -0
  107. package/src/sculpt-run.js +10 -46
  108. package/src/sculpt-shelf-file.js +92 -0
  109. package/src/sculpt-shelf-tiles.jsx +69 -0
  110. package/src/sculpt-shelf.js +108 -0
  111. package/src/sculpt-stage.jsx +39 -2
  112. package/src/sculpt-thumb.js +72 -0
  113. package/src/sculpt-views.js +75 -0
  114. package/src/semantic-edit.js +0 -87
  115. package/src/settings-menu.jsx +2 -2
  116. package/src/shell/DetailsSlot.jsx +5 -6
  117. package/src/shell/LibrarySlot.jsx +8 -10
  118. package/src/shell/MenuBar.jsx +6 -6
  119. package/src/shell/PreferencesDialog.jsx +2 -3
  120. package/src/shell/ViewportToolbar.jsx +15 -18
  121. package/src/shell/add-character-entries.js +13 -0
  122. package/src/shell/pose-controls.js +32 -0
  123. package/src/shell/preference-pages.jsx +2 -11
  124. package/src/shell/storyboard-caption.js +21 -0
  125. package/src/shell/studio-shell-context.js +1 -0
  126. package/src/shortcut-friction.js +2 -1
  127. package/src/site/landing-analytics.js +28 -0
  128. package/src/studio-actions.js +3 -2
  129. package/src/studio-agent-protocol.js +4 -4
  130. package/src/studio-app-binding.js +4 -3
  131. package/src/update/UpdateChip.jsx +21 -2
  132. package/src/update/UpdateDialog.jsx +26 -6
  133. package/src/update/update-client.js +123 -17
  134. package/src/update/update-telemetry.js +15 -3
  135. package/src/use-sculpt-mode.js +62 -8
  136. package/src/workflow/agent-failure-codes.js +28 -9
  137. package/src/workflow/agent-turn-telemetry.js +1 -1
  138. package/src/workflow/task-board.js +2 -2
  139. package/tools/dev/page-shell.py +1 -1
  140. package/tools/dev/pages/greybox-to-video.html +21 -2
  141. package/tools/dev/pages/previs-software.html +3 -3
  142. package/tools/dev/pages/privacy.html +9 -6
  143. package/tools/dev/pages/seedance-camera-control.html +2 -2
  144. package/tools/loc-baseline.json +10 -10
  145. package/tools/run-tests.mjs +45 -69
  146. package/tools/select-tests.mjs +119 -0
  147. package/tools/test-durations.json +455 -0
  148. package/tools/test-graph.mjs +181 -0
  149. package/tools/test-shards.mjs +78 -0
  150. package/tools/tutorial/README.md +74 -0
  151. package/tools/tutorial/assemble.mjs +43 -0
  152. package/tools/tutorial/captions.mjs +43 -0
  153. package/tools/tutorial/cdp.mjs +55 -0
  154. package/tools/tutorial/cursor.mjs +159 -0
  155. package/tools/tutorial/episode.mjs +173 -0
  156. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  157. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  158. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  159. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  160. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  161. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  162. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  163. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  164. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  165. package/tools/tutorial/episodes/cast.mjs +12 -0
  166. package/tools/tutorial/episodes/first-video.mjs +121 -0
  167. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  168. package/tools/tutorial/launch.mjs +63 -0
  169. package/tools/tutorial/mux.mjs +61 -0
  170. package/tools/tutorial/overlay.mjs +142 -0
  171. package/tools/tutorial/record.mjs +156 -0
  172. package/tools/tutorial/scenes/terminal.html +63 -0
  173. package/tools/tutorial/steps.mjs +111 -0
  174. package/tools/tutorial/terminal.mjs +43 -0
  175. package/tools/tutorial/voice.mjs +49 -0
  176. package/tools/update-test-durations.mjs +42 -0
  177. package/tools/verify-package-tarball.mjs +18 -12
  178. package/dist/assets/TaskBoard-BUjjuvm5.js +0 -1
  179. package/dist/assets/app-Dige7q3X.css +0 -1
  180. package/dist/assets/app-DxoI941o.js +0 -5144
  181. package/dist/assets/motion3-compile-B4SX4TGx.js +0 -1
  182. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  183. package/dist/assets/scene-asset-sync-LeI3eSnO.js +0 -13
  184. package/dist/assets/task-board-5PKn7ZaA.js +0 -15
  185. package/dist/assets/workflow-Dg9jCKOq.js +0 -179
@@ -6,12 +6,11 @@ import { PRESETS, SHOT_ASPECT_PRESETS } from "../app-stage.jsx";
6
6
  import { CAMERA_PRESETS } from "../camera-move.js";
7
7
  import { PRIME_SET } from "../shot.js";
8
8
  import { CatalogueEntries } from "../object-catalog.jsx";
9
- import { CHARACTER_MODEL_IDS } from "../scenes.js";
10
9
  import { elementByPath } from "../studio-elements.js";
11
10
  import { saveAutoColor } from "../auto-color.js";
12
11
  import { trackFeature } from "../analytics.js";
13
12
  import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
14
- import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js";
13
+ import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js"; import { addCharacterEntries } from "./add-character-entries.js";
15
14
  import "./viewport.css";
16
15
 
17
16
  // G1: the single mode+tool pill. Keys 1-5 and W/E/R are bound in App's
@@ -122,7 +121,7 @@ export default function ViewportToolbar() {
122
121
  autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
123
122
  agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
124
123
  ikEditTool, setIkEditTool, motion, setRangePinPartPick,
125
- previsMode, sculptView, requestSculptView, appContext, setToast,
124
+ previsMode, castStyle, sculptView, requestSculptView, sculptParts, appContext, setToast,
126
125
  } = useStudioShell();
127
126
  const menu = useOverlayMenu();
128
127
 
@@ -154,11 +153,11 @@ export default function ViewportToolbar() {
154
153
  ],
155
154
  // Motion is compiled by the agent (motion.compile); the department has no tools of its own.
156
155
  motion: [],
157
- // Sculpt mode's W/E/R swing the turntable camera to a reference view.
158
- sculpt: [
159
- { key: "W", id: "front", name: ko("Front view", "정면 보기"), active: sculptView?.view === "front", pick: () => requestSculptView("front") },
160
- { key: "E", id: "side", name: ko("Side view", "측면 보기"), active: sculptView?.view === "side", pick: () => requestSculptView("side") },
161
- { key: "R", id: "back", name: ko("Back view", "뒷면 보기"), active: sculptView?.view === "back", pick: () => requestSculptView("back") },
156
+ // Sculpt mode's W/E/R swing the turntable camera to a reference view; with a part selected they drive its gizmo.
157
+ sculpt: sculptParts?.selected ? sculptParts.tools : [
158
+ { key: "W", id: "front", name: ko("Front view (with a part selected, W moves it)", "정면 보기 (부품을 선택하면 W는 이동)"), active: sculptView?.view === "front", pick: () => requestSculptView("front") },
159
+ { key: "E", id: "side", name: ko("Side view (with a part selected, E rotates it)", "측면 보기 (부품을 선택하면 E는 회전)"), active: sculptView?.view === "side", pick: () => requestSculptView("side") },
160
+ { key: "R", id: "back", name: ko("Back view (with a part selected, R scales it)", "뒷면 보기 (부품을 선택하면 R은 크기)"), active: sculptView?.view === "back", pick: () => requestSculptView("back") },
162
161
  ],
163
162
  }[workflowMode] ?? [];
164
163
 
@@ -277,16 +276,14 @@ export default function ViewportToolbar() {
277
276
  <CatalogueEntries onPick={(kind) => { addSceneObject(kind); menu.close(); }} />
278
277
  <div className="add-object-group">
279
278
  <span className="add-object-heading">{ko("Scene", "장면")}</span>
280
- <button
281
- type="button"
282
- role="menuitem"
283
- className="add-object-item"
284
- data-add="character"
285
- onClick={() => { spawnCharacter(CHARACTER_MODEL_IDS[0], characterSpawnX(characters.length), 0); menu.close(); }}
286
- >
287
- <span className="add-object-swatch vp-add-swatch-cast" aria-hidden="true" />
288
- <span>{ko("Character", "캐릭터")}</span>
289
- </button>
279
+ {addCharacterEntries(castStyle).map((entry) => (
280
+ <button key={entry.add} type="button" role="menuitem" className="add-object-item" data-add={entry.add}
281
+ onClick={() => { spawnCharacter(entry.model, characterSpawnX(characters.length), 0); menu.close(); }}
282
+ >
283
+ <span className="add-object-swatch vp-add-swatch-cast" aria-hidden="true" />
284
+ <span>{entry.label()}</span>
285
+ </button>
286
+ ))}
290
287
  <button
291
288
  type="button"
292
289
  role="menuitem"
@@ -0,0 +1,13 @@
1
+ // Add > Character offers both bodies in every project (ViewportToolbar). The
2
+ // project's cast style only decides which one comes first: never hide either.
3
+ import { ko } from "../locale.js";
4
+ import { CHARACTER_MODEL_IDS, PROXY_FIGURE_MODEL } from "../scenes.js";
5
+
6
+ const ENTRIES = Object.freeze([
7
+ Object.freeze({ add: "character", model: CHARACTER_MODEL_IDS[0], label: () => "Y Bot" }),
8
+ Object.freeze({ add: "capsule", model: PROXY_FIGURE_MODEL, label: () => ko("Capsule figure", "캡슐 인물") }),
9
+ ]);
10
+
11
+ export function addCharacterEntries(castStyle) {
12
+ return castStyle === "capsule" ? [ENTRIES[1], ENTRIES[0]] : ENTRIES;
13
+ }
@@ -0,0 +1,32 @@
1
+ import { ko } from "../locale.js";
2
+ import { DEFAULT_POSE } from "../poses.js";
3
+ import { FINGER_PRESETS } from "../motion/fingers.js";
4
+
5
+ // The Pose panel's reset and the focused hand's finger controls, read off the studio shell.
6
+ export function posePanelControls(shell) {
7
+ const { motion, characters, runStudioAction, castDomain, setStudioPick, setToast, cancelPendingIkEdit } = shell;
8
+ // Over a take the default is the take itself: drop the IK edits and keep the motion.
9
+ // Without one, back to the default pose; clearMotion also drops the IK keys (one undo step).
10
+ const resetPose = (characterId) => {
11
+ cancelPendingIkEdit?.();
12
+ const current = characters.find((entry) => entry.id === characterId)?.pose;
13
+ // Already on the default pose: only the IK edits go. A pose write would re-apply the
14
+ // bare pose on Undo over the restored IK keys.
15
+ if (motion || !current || current.id === DEFAULT_POSE.id) {
16
+ runStudioAction("character.clearIkKeys", { characterId });
17
+ if (motion) {
18
+ setToast(ko("Pose edits cleared — the motion stays", "포즈 수정을 지웠어요 — 모션은 그대로예요"));
19
+ return;
20
+ }
21
+ } else castDomain.run('character.setPose', { characterId, pose: DEFAULT_POSE.id, clearMotion: true });
22
+ setStudioPick(DEFAULT_POSE.id);
23
+ setToast(ko("Back to the default pose", "기본 포즈로 돌아왔어요"));
24
+ };
25
+ const { handFingers: fingers, ikTick, ikSolve, ikDragEnd, fingerEditing, setFingerEdit } = shell;
26
+ const fingerPanel = {
27
+ fingers, tick: ikTick, editing: fingerEditing, onToggleEdit: () => setFingerEdit(!fingerEditing),
28
+ onCurl: (id, curl) => ikSolve("finger", id, { curl }), onCommit: ikDragEnd,
29
+ onPreset: (name) => { for (const finger of fingers) ikSolve("finger", finger.id, { curl: FINGER_PRESETS[name][finger.name] }); ikDragEnd(); },
30
+ };
31
+ return { resetPose, fingerPanel };
32
+ }
@@ -1,4 +1,3 @@
1
- import { getAnalyticsOptOut, setAnalyticsOptOut } from "../analytics.js";
2
1
  import { loadAutoColor, saveAutoColor } from "../auto-color.js";
3
2
  import { readStoredGridView, writeStoredGridView } from "../grid-view.js";
4
3
  import { ko } from "../locale.js";
@@ -13,7 +12,7 @@ import {
13
12
  } from "./preference-controls.jsx";
14
13
 
15
14
  export function preferenceSection(section, props) {
16
- const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
15
+ const { autoColor, setAutoColor, gridView, setGridView } = props;
17
16
 
18
17
  switch (section) {
19
18
  case "Appearance":
@@ -31,14 +30,6 @@ export function preferenceSection(section, props) {
31
30
  <PreferenceRow title={ko("Auto colour", "자동 색")} description={ko("Give scene objects stable display colours.", "씬 오브젝트에 고정된 표시 색을 사용해요.")}>
32
31
  <ToggleControl pressed={autoColor} onChange={(value) => { setAutoColor(value); saveAutoColor(value); }} label={ko("Auto colour", "자동 색")} testid="preferences-auto-color" />
33
32
  </PreferenceRow>
34
- <PreferenceRow title={ko("Anonymous analytics", "익명 사용 통계")} description={ko("Help improve CozyClay with anonymous product events.", "익명 제품 이벤트로 CozyClay 개선을 도와요.")}>
35
- <ToggleControl
36
- pressed={!analyticsOptOut}
37
- onChange={async (value) => setAnalyticsOptOutState(await setAnalyticsOptOut(!value))}
38
- label={ko("Anonymous analytics", "익명 사용 통계")}
39
- testid="settings-analytics"
40
- />
41
- </PreferenceRow>
42
33
  </PreferenceSection>
43
34
  </>
44
35
  );
@@ -69,4 +60,4 @@ export function preferenceSection(section, props) {
69
60
  }
70
61
 
71
62
  // Kept here so the dialog can load a new setting's initial value lazily.
72
- export { getAnalyticsOptOut, loadAutoColor, readStoredGridView };
63
+ export { loadAutoColor, readStoredGridView };
@@ -0,0 +1,21 @@
1
+ // The storyboard contact sheet is drawn on a bare 2d canvas, which has no
2
+ // stylesheet to inherit from: it gets the studio's own type stack explicitly
3
+ // so the sheet reads like the app it came out of.
4
+ export const STORYBOARD_FONT = '12px Inter, "Pretendard", "Noto Sans KR", system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif';
5
+ // One unwrapped caption line at 12px inside a 480px cell holds about 64
6
+ // characters before it runs into the next column.
7
+ const STORYBOARD_CAPTION_CHARS = 64;
8
+
9
+ /** Cut a caption to what one unwrapped cell line holds. */
10
+ export function storyboardLine(text) {
11
+ return text.length > STORYBOARD_CAPTION_CHARS ? `${text.slice(0, STORYBOARD_CAPTION_CHARS - 1)}\u2026` : text;
12
+ }
13
+
14
+ /** Fold a labelled shot prompt into the one line a board cell can hold. */
15
+ export function storyboardCaption(prompt) {
16
+ return storyboardLine(prompt
17
+ .split("\n")
18
+ .filter((line) => line.startsWith("SHOT:") || line.startsWith("LENS:"))
19
+ .map((line) => line.slice(line.indexOf(":") + 1).trim())
20
+ .join(" · "));
21
+ }
@@ -13,6 +13,7 @@ import { playbackFrame, usePlaybackSnapshot } from "../motion/playback-frame-sto
13
13
  // - agentOpen/toggleAgent, preferencesOpen/setPreferencesOpen;
14
14
  // - statusText/setStatusText, previewRanges/setPreviewRanges, liveHubStatus,
15
15
  // flySpeed (the navigation multiplier, not metres per second);
16
+ // - castStyle ("rig" | "capsule" | "hybrid"), which orders Add > Character;
16
17
  // - previsMode ("storyboard" | "animation"), sceneRevision (bumps on every
17
18
  // authored edit) and capturePanelThumbnail(shot) (the shot's first frame as
18
19
  // a PNG data URL, or null) for the Board dock tab.
@@ -6,6 +6,7 @@
6
6
  import { track } from "./analytics.js";
7
7
  import { admit, bump } from "./analytics-friction.js";
8
8
  import { isImeComposing } from "./ime.js";
9
+ import { hintNoSelection } from "./friction-hint.js";
9
10
 
10
11
  const IGNORED_PER_CLASS = 5, UNBOUND_PER_COMBO = 3, HELP_PER_SESSION = 3;
11
12
  let send = track, defer = (run) => setTimeout(run, 0), listening = false;
@@ -52,7 +53,7 @@ export function noteUnselectedKey(event) {
52
53
  : event.code === "KeyD" && mod && !event.altKey && !event.shiftKey ? "duplicate"
53
54
  : (event.key === "Delete" || event.key === "Backspace") && !mod ? "delete" : null;
54
55
  if (!keyClass) return;
55
- defer(() => { if (!event.defaultPrevented) noteShortcutIgnored(keyClass, "no_selection"); });
56
+ defer(() => { if (!event.defaultPrevented) { noteShortcutIgnored(keyClass, "no_selection"); hintNoSelection(); } });
56
57
  }
57
58
 
58
59
  const modOnly = (event) => (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey;
@@ -0,0 +1,28 @@
1
+ // Landing page (index.html) analytics: one $pageview and landing:cta_clicked.
2
+ // Same hosted consent rules as /app/ (no cookies, DNT respected, no autocapture);
3
+ // the lite init skips session, error, long-task and device signals.
4
+ import { initAnalytics, track } from "../analytics.js";
5
+ import { LANDING_CTA_TARGETS } from "../analytics-strict.js";
6
+
7
+ const COPY_TARGETS = [["--via site", "copy_npx"], ["git clone", "copy_clone"], ["mcpServers", "copy_mcp"], ["cclay live", "copy_terminal"]];
8
+
9
+ /** The landing:cta_clicked target for a clicked element, or null when it is not a call to action. */
10
+ function ctaTarget(element) {
11
+ const copy = element.closest?.("button.copy[data-copy]");
12
+ if (copy) return COPY_TARGETS.find(([needle]) => copy.dataset.copy.includes(needle))?.[1] ?? null;
13
+ const href = element.closest?.("a[href]")?.getAttribute("href") ?? "";
14
+ if (href === "/app/" || href.startsWith("/app/?")) return "app";
15
+ if (href.includes("INSTALL_FOR_AGENTS")) return "agent_install_doc";
16
+ if (href.startsWith("https://github.com/NomaDamas/CozyClay")) return "github";
17
+ if (href.startsWith("https://discord.gg/")) return "discord";
18
+ if (href.startsWith("https://www.npmjs.com/package/cozyclay")) return "npm";
19
+ return null;
20
+ }
21
+
22
+ if (typeof document !== "undefined") {
23
+ void initAnalytics({ lite: true });
24
+ document.addEventListener("click", (event) => {
25
+ const target = event.target instanceof Element ? ctaTarget(event.target) : null;
26
+ if (target && LANDING_CTA_TARGETS.includes(target)) track("landing:cta_clicked", { target });
27
+ }, { capture: true });
28
+ }
@@ -41,7 +41,8 @@ const waypointFrame = { ...frame, minimum: 1 };
41
41
  /** The tracks an IK key stores (src/motion/ik.js IK_TRACKS and FK_TRACKS). A
42
42
  * chain track keys its three bones (upper, lower, end); a joint track one. */
43
43
  export const STUDIO_IK_CHAIN_TRACKS = freezeStudioData(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
44
- export const STUDIO_IK_JOINT_TRACKS = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "rightShoulder"]);
44
+ export const STUDIO_IK_JOINT_TRACKS = freezeStudioData(["hips", "spine", "chest", "neck", "head", "leftShoulder", "rightShoulder",
45
+ ...["left", "right"].flatMap(side => ["Thumb", "Index", "Middle", "Ring", "Pinky"].flatMap(finger => [1, 2, 3].map(joint => `${side}${finger}${joint}`)))]);
45
46
  const num = { type: "number" };
46
47
  const perBone = items => ({ type: "array", items, minItems: 1, maxItems: 3 });
47
48
  const quaternion = input({ x: num, y: num, z: num, w: num });
@@ -102,7 +103,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
102
103
  { id: "character.clearWaypoints", label: "Clear root path", kind: "mutation", undoDomain: "cast", input: input(characterId),
103
104
  description: "Remove every root waypoint of the character, leaving its motion unconstrained by a path." },
104
105
  { id: "character.setIkKey", label: "Set IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame, tracks: ikTracks }),
105
- description: "Key a character's IK correction layer at frame, the same key a pose drag bakes. tracks maps a track id to its key: q is the bones' LOCAL rotations as unit quaternions {x,y,z,w}, three for a chain track (leftHand, rightHand, leftFoot, rightFoot: upper, lower and end bone) and one for a joint track (hips, spine, chest, neck, head, leftShoulder, rightShoulder); p is a joint's local position {x,y,z} (the hips' height and lean). Optional baseQ/basePos give the take's own pose the key was made over, so it applies as a delta on the take; chainP gives a chain's three local bone positions; keepTranslations keeps them over the take's; blend (frames, 1-240) is how far this key's correction eases into the take around it, 6 when omitted; pin (1-64 characters) tags the key as baked by that range pin. A key needs q or p. Each named track replaces its key at frame and starts evaluating; other tracks at that frame stay. Keyed frames are in inspect_studio { scope: \"motion\" } ikKeyFrames." },
106
+ description: "Key a character's IK correction layer at frame, the same key a pose drag bakes. tracks maps a track id to its key: q is the bones' LOCAL rotations as unit quaternions {x,y,z,w}, three for a chain track (leftHand, rightHand, leftFoot, rightFoot: upper, lower and end bone) and one for a joint track (hips, spine, chest, neck, head, leftShoulder, rightShoulder, and the finger bones <side><Thumb|Index|Middle|Ring|Pinky><1-3>, e.g. leftIndex2); p is a joint's local position {x,y,z} (the hips' height and lean). Optional baseQ/basePos give the take's own pose the key was made over, so it applies as a delta on the take; chainP gives a chain's three local bone positions; keepTranslations keeps them over the take's; blend (frames, 1-240) is how far this key's correction eases into the take around it, 6 when omitted; pin (1-64 characters) tags the key as baked by that range pin. A key needs q or p. Each named track replaces its key at frame and starts evaluating; other tracks at that frame stay. Keyed frames are in inspect_studio { scope: \"motion\" } ikKeyFrames." },
106
107
  { id: "character.removeIkKey", label: "Delete IK key", kind: "mutation", undoDomain: "motion", input: input({ ...characterId, frame }),
107
108
  description: "Delete the character's whole IK key at frame (every track keyed there), like the Full-Body lane's delete." },
108
109
  { id: "character.clearIkKeys", label: "Clear IK keys", kind: "mutation", undoDomain: "motion", input: input(characterId),
@@ -4,9 +4,9 @@ import { STUDIO_ERROR_CODES, refusalCode } from "./studio-error-codes.js";
4
4
  import { EFFECT_VALUE_SCHEMA } from "./effects/effect-record.js";
5
5
  import { TEXT_VALUE_SCHEMA } from "./text/text-record.js";
6
6
  import { wrongType, unknownField, missingField, arrayRefusal, duplicateItem, stringRefusal, enumRefusal, numberRefusal, unionRefusal } from "./studio-schema-errors.js";
7
- // Mirrors PREVIS_MODES (project.js) and POSTURES (scenes.js). Neither is imported:
8
- // both pull in three.js, which the agent sidecar must not load. The protocol test pins the lists to their owners.
9
- const PREVIS_MODES = ["storyboard", "animation"], POSTURES = ["stand", "sit", "lie"];
7
+ // Mirrors PREVIS_MODES and CAST_STYLES (project.js) and POSTURES (scenes.js). Neither is imported:
8
+ // both pull in three.js, which the agent sidecar must not load. The protocol tests pin the lists to their owners.
9
+ const PREVIS_MODES = ["storyboard", "animation"], CAST_STYLES = ["rig", "capsule", "hybrid"], POSTURES = ["stand", "sit", "lie"];
10
10
 
11
11
  // Sized for the compact index of up to 400 entities (~100 bytes each) beside
12
12
  // 24 detailed rows; a small scene stays far below it.
@@ -232,7 +232,7 @@ const jobSummary = object({ id, characterId: id, state: choices(STUDIO_VARIANTS.
232
232
  const contextSchema = object({
233
233
  schema: literal("studio-context-v1"), host, revision,
234
234
  units: object({ distance: literal("m"), angle: literal("deg"), up: literal("+Y"), yawZero: literal("+Z"), yawPositiveToward: literal("+X"), pivot: literal("base"), fps: literal(24), rangeEnd: literal("exclusive") }),
235
- scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES) }),
235
+ scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES), castStyle: choices(CAST_STYLES) }),
236
236
  selection, activeCharacterId: nullable(id), view, shot: nullable(currentShot), camera: nullable(camera),
237
237
  entities: array(entity, 24), entityPage: object({ returned: integer(0, 24), total: integer(), truncated: bool, nextCursor: nullable(text(512)) }),
238
238
  shots: array(shotSummary, 8), shotsTruncated: bool, assets: array(assetSummary, STUDIO_CONTEXT_LIMITS.assets),
@@ -137,13 +137,14 @@ export function createStudioAppBinding(ports) {
137
137
  const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
138
138
  function context() {
139
139
  const s = refresh(), entities = entityProjection(s), registry = ports.actions?.();
140
- const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame);
140
+ const shot = s.shots.find(row => row.id === s.selectedShotId) ?? shotAtFrame(s.shots, s.view.frame), cast = id => s.characters.some(c => c.id === id);
141
141
  const range = frameRange;
142
142
  return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
143
143
  revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
144
144
  units: { distance: "m", angle: "deg", up: "+Y", yawZero: "+Z", yawPositiveToward: "+X", pivot: "base", fps: 24, rangeEnd: "exclusive" },
145
- scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}) },
146
- selection: s.selection, activeCharacterId: s.activeCharacterId, view: s.view,
145
+ scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}), ...(s.castStyle ? { castStyle: s.castStyle } : {}) },
146
+ // A removed character (the last one, too) is neither selected nor active.
147
+ selection: ["character", "rig"].includes(s.selection?.kind) && !cast(s.selection.id) ? null : s.selection, activeCharacterId: cast(s.activeCharacterId) ? s.activeCharacterId : null, view: s.view,
147
148
  shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
148
149
  // buildStudioContext selects the detailed rows and writes the real page.
149
150
  entities, entityPage: { returned: 0, total: 0, truncated: false, nextCursor: null },
@@ -1,7 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { ko } from "../locale.js";
3
- import { createUpdatePoller } from "./update-client.js";
4
- import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled } from "./update-telemetry.js";
3
+ import { useStudioShell } from "../shell/studio-shell-context.js";
4
+ import { createUpdatePoller, reloadAfterRestart } from "./update-client.js";
5
+ import { noteNoticeShown, noteUpdateClicked, noteUpdatePolled, noteUpdateRestarted } from "./update-telemetry.js";
5
6
  import UpdateDialog from "./UpdateDialog.jsx";
6
7
  import "./update.css";
7
8
 
@@ -13,6 +14,9 @@ function chipLabel(state) {
13
14
  switch (state.view) {
14
15
  case "updating": return ko("Updating…", "업데이트 중…", "正在更新…");
15
16
  case "restart_needed": return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
17
+ case "restarting": return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
18
+ case "restarted": return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
19
+ case "restart_timeout": return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
16
20
  case "failed": return ko("Update failed", "업데이트 실패", "更新失败");
17
21
  default: return ko(`Update ${v}`, `${v} 업데이트`, `更新到 ${v}`);
18
22
  }
@@ -22,10 +26,20 @@ export default function UpdateChip() {
22
26
  const [state, setState] = useState(null);
23
27
  const [open, setOpen] = useState(false);
24
28
  const poller = useRef(null);
29
+ const { projectDirty } = useStudioShell() ?? {};
30
+ const dirty = useRef(projectDirty);
31
+ useEffect(() => { dirty.current = projectDirty; }, [projectDirty]);
25
32
  useEffect(() => {
33
+ let reloaded = false;
26
34
  const instance = createUpdatePoller({
27
35
  onState: ({ state: next, info }) => {
28
36
  setState(next);
37
+ // Decided once, when the new launcher answers: a saved project reloads, a dirty one asks first.
38
+ if (next.view === "restarted" && !reloaded) {
39
+ reloaded = true;
40
+ // A task later, so this tab's update:restarted is handed over first.
41
+ if (!reloadAfterRestart({ dirty: dirty.current, reload: () => setTimeout(() => window.location.reload(), 0) })) setOpen(true);
42
+ }
29
43
  noteNoticeShown(next);
30
44
  noteUpdatePolled(info);
31
45
  },
@@ -45,6 +59,10 @@ export default function UpdateChip() {
45
59
  if (state) noteUpdateClicked("update_now", state.kind);
46
60
  void poller.current?.update();
47
61
  }, [state]);
62
+ const restart = useCallback(() => {
63
+ if (state) noteUpdateClicked("restart", state.kind);
64
+ void poller.current?.restart().then((outcome) => { if (outcome) noteUpdateRestarted(outcome); });
65
+ }, [state]);
48
66
  if (view === "hidden") return null;
49
67
  return (
50
68
  <span className="update-chip-wrap">
@@ -78,6 +96,7 @@ export default function UpdateChip() {
78
96
  onClose={close}
79
97
  onLater={() => dismiss("later")}
80
98
  onUpdate={update}
99
+ onRestart={restart}
81
100
  onAction={(action) => noteUpdateClicked(action, state.kind)}
82
101
  />
83
102
  )}
@@ -22,6 +22,9 @@ function titleOf(state) {
22
22
  if (state.view === "updating") return ko("Updating…", "업데이트 중…", "正在更新…");
23
23
  if (state.view === "restart_needed") return ko("Restart to finish", "다시 시작하면 끝나요", "重启以完成");
24
24
  if (state.view === "failed") return ko("Update failed", "업데이트 실패", "更新失败");
25
+ if (state.view === "restarting") return ko("Restarting…", "다시 시작하는 중…", "正在重启…");
26
+ if (state.view === "restarted") return ko(`Updated to ${v}`, `${v}로 업데이트됨`, `已更新到 ${v}`);
27
+ if (state.view === "restart_timeout") return ko("Restart didn't finish", "다시 시작하지 못했어요", "重启未完成");
25
28
  return ko(`CozyClay ${v} is available`, `CozyClay ${v} 버전이 나왔어요`, `CozyClay ${v} 已发布`);
26
29
  }
27
30
 
@@ -29,11 +32,18 @@ function bodyOf(state) {
29
32
  const v = state.latest;
30
33
  if (state.view === "updating") return ko(`Installing ${v}. This takes about a minute.`, `${v} 설치 중이에요. 1분쯤 걸려요.`, `正在安装 ${v},大约需要一分钟。`);
31
34
  if (state.view === "restart_needed") {
32
- return ko(`Updated to ${v}. Quit CozyClay in the terminal (Ctrl+C), then run \`cclay\` again.`,
33
- `${v} 설치 완료. 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
34
- `已更新到 ${v}。请在终端中退出 CozyClay(Ctrl+C),然后重新运行 \`cclay\`。`);
35
+ return ko(`Updated to ${v}. Restart now, or quit CozyClay in the terminal (Ctrl+C) and run \`cclay\` again.`,
36
+ `${v} 설치 완료. 지금 다시 시작하거나, 터미널에서 CozyClay를 종료(Ctrl+C)하고 \`cclay\`를 다시 실행하세요.`,
37
+ `已更新到 ${v}。请立即重启,或在终端中退出 CozyClay(Ctrl+C)后重新运行 \`cclay\`。`);
35
38
  }
36
39
  if (state.view === "failed") return (failureCopy[state.errorClass] ?? failureCopy.other)();
40
+ if (state.view === "restarting") return ko(`Starting ${v}. The Studio reloads when it answers.`, `${v}를 시작하는 중이에요. 응답하면 Studio가 다시 로드돼요.`, `正在启动 ${v}。它响应后 Studio 会重新加载。`);
41
+ if (state.view === "restarted") return ko("Updated. Save your project, then reload.", "업데이트됐어요. 프로젝트를 저장한 뒤 다시 로드하세요.", "已更新。请先保存项目,然后重新加载。");
42
+ if (state.view === "restart_timeout") {
43
+ return ko("CozyClay didn't come back within 30 seconds. Quit it in the terminal (Ctrl+C), then run `cclay` again.",
44
+ "CozyClay가 30초 안에 돌아오지 않았어요. 터미널에서 종료(Ctrl+C)하고 `cclay`를 다시 실행하세요.",
45
+ "CozyClay 未在 30 秒内恢复。请在终端中退出(Ctrl+C),然后重新运行 `cclay`。");
46
+ }
37
47
  if (state.kind === "npx") {
38
48
  return ko("Quit CozyClay in the terminal and run `npx cozyclay` again. npx fetches the newest version each time (internet needed).",
39
49
  "터미널에서 CozyClay를 종료하고 `npx cozyclay`를 다시 실행하세요. npx가 매번 최신 버전을 받아요 (인터넷 필요).",
@@ -65,7 +75,7 @@ async function copyText(text) {
65
75
  }
66
76
  }
67
77
 
68
- export default function UpdateDialog({ state, onClose, onLater, onUpdate, onAction }) {
78
+ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onRestart, onAction }) {
69
79
  const closeRef = useRef(null);
70
80
  const titleId = useId();
71
81
  // "Copied" belongs to the view it was pressed in: a later phase shows its own command afresh.
@@ -89,7 +99,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
89
99
  const view = state.view;
90
100
  setCopiedView((await copyText(state.command)) ? view : null);
91
101
  };
92
- const startsInstall = state.actions.includes("update_now") || state.actions.includes("retry");
102
+ const startsInstall = ["update_now", "retry", "restart"].some((action) => state.actions.includes(action));
93
103
  const body = bodyOf(state);
94
104
  return createPortal(
95
105
  <div className="menubar-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
@@ -127,6 +137,16 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
127
137
  {ko("Try again", "다시 시도", "重试")}
128
138
  </button>
129
139
  )}
140
+ {state.actions.includes("restart") && (
141
+ <button type="button" className="update-dialog-primary" data-testid="update-restart" disabled={blocked} onClick={onRestart}>
142
+ {ko("Restart now", "지금 다시 시작", "立即重启")}
143
+ </button>
144
+ )}
145
+ {state.actions.includes("reload") && (
146
+ <button type="button" className="update-dialog-primary" data-testid="update-reload" onClick={() => window.location.reload()}>
147
+ {ko("Reload", "다시 로드", "重新加载")}
148
+ </button>
149
+ )}
130
150
  {state.actions.includes("copy_command") && (
131
151
  <button type="button" data-testid="update-copy" data-copied={copied || undefined} onClick={() => void copy()}>
132
152
  {copied ? ko("Copied", "복사했어요", "已复制") : ko("Copy command", "명령 복사", "复制命令")}
@@ -135,7 +155,7 @@ export default function UpdateDialog({ state, onClose, onLater, onUpdate, onActi
135
155
  {state.actions.includes("later") && (
136
156
  <button type="button" data-testid="update-later" onClick={onLater}>{ko("Later", "나중에", "稍后")}</button>
137
157
  )}
138
- {state.view === "restart_needed" && (
158
+ {["restart_needed", "restart_timeout"].includes(state.view) && (
139
159
  <button type="button" onClick={onClose}>{ko("Close", "닫기", "关闭")}</button>
140
160
  )}
141
161
  </div>