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
@@ -1,6 +1,6 @@
1
1
  import { studioActionDeclaration } from "../studio-actions.js";
2
2
  import { fail } from "./shared.js";
3
- import { PREVIS_MODES } from "../project.js";
3
+ import { CAST_STYLES, PREVIS_MODES } from "../project.js";
4
4
 
5
5
  const input = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
6
6
  const name = { type: "string", minLength: 1, maxLength: 240 };
@@ -12,8 +12,8 @@ const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
12
12
  const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
13
13
  // The deadline covers the user in the file picker, so it is the hub's ceiling, not the 30 s default.
14
14
  const opening = { ...document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token }), timeoutMs: 300_000 };
15
- const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
16
- const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name });
15
+ const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] }, castStyle: { type: "string", enum: [...CAST_STYLES] } });
16
+ const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name, castStyle: { type: "string", enum: [...CAST_STYLES] } });
17
17
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
18
18
  const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
19
19
  export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
@@ -0,0 +1,53 @@
1
+ // sculpt.compare: the editor's read-only way to score a sculpt against its
2
+ // reference, one view ({ reference, yaw }) or several weighted ones
3
+ // ({ views: [{ yaw, reference, weight }] }, #970). A job that never commits, so
4
+ // its receipt is `completed` with the score as output, authored false and no
5
+ // undo. The scoring is MCP compare_sculpt's (sculpt-compare.js, sculpt-views.js).
6
+ import { SCULPT_KIND } from '../scene-objects.js';
7
+ import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
8
+ import { scoreSculptAgainstViews } from '../sculpt-views.js';
9
+ import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
10
+ import { fail } from './shared.js';
11
+ import { StudioSchemas } from '../studio-agent-protocol.js';
12
+
13
+ const id = StudioSchemas.TargetGuard.properties.targetId;
14
+ const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
15
+ const openObject = { type: 'object', properties: {}, additionalProperties: true };
16
+ const yaw = { type: 'number', minimum: -180, maximum: 180 };
17
+ const reference = { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] };
18
+ export const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
19
+ description: 'Score a sculpt\'s silhouette against a reference image without changing anything: IoU, width-to-height delta, missing area by height band (top, middle, bottom), parts that stick out, and a reference | overlay sheet (grey both, red missing, blue extra) as sheetDataUrl. reference is { assetId } of a project image or { dataUrl }. yaw is the reference camera azimuth in degrees (0 in front, positive toward the subject\'s own left); omitted tries -45..45 and keeps the best. Instead of reference, views [{ yaw, reference, weight? }] scores several views of the subject (a blueprint\'s front 0, side 90, back 180) and blends their IoUs by weight (default front 0.6, side 0.25, back 0.15; a view whose height:width differs from the front\'s by more than 35% gets 0); the output adds one row per view. candidates (up to 12 { label, edits }) are scored from the same view(s) and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
20
+ input: input({ id, reference, yaw, elevation: { type: 'number', minimum: -60, maximum: 60 },
21
+ views: { type: 'array', minItems: 1, maxItems: 4, items: input({ yaw, reference, weight: { type: 'number', minimum: 0, maximum: 1 } }, ['yaw', 'reference']) },
22
+ candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id']) };
23
+ // A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
24
+ const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
25
+
26
+ /** The run function for sculpt.compare; `objectOf` resolves an id or fails STALE_TARGET. */
27
+ export const runSculptCompare = (ports, objectOf) => async ({ id: objectId, reference, views, yaw, elevation = 0, candidates }, context) => {
28
+ const target = objectOf(objectId);
29
+ if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
30
+ if ((reference === undefined) === (views === undefined)) fail('INVALID_ARGUMENT', 'Give exactly one of reference (one view) or views (several weighted views).');
31
+ const images = ports.sculptImages ?? browserSculptImages;
32
+ let decoded, scored;
33
+ try { decoded = views ? await Promise.all(views.map(async view => ({ ...view, image: await images.decode(view.reference) }))) : await images.decode(reference); }
34
+ catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
35
+ context?.signal?.throwIfAborted();
36
+ try { scored = views ? scoreSculptAgainstViews(target.recipe, decoded, { elevation, candidates }) : scoreSculptAgainstReference(target.recipe, decoded, { yaw, elevation, candidates }); }
37
+ catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
38
+ const { result, ranked, refused } = scored;
39
+ const output = { id: objectId, ...result.report };
40
+ if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
41
+ sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
42
+ const sheet = (ranked ? ranked[0].result : result).sheet;
43
+ const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
44
+ // The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
45
+ for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
46
+ const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
47
+ if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
48
+ }
49
+ if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
50
+ const lead = ranked ? `best ${ranked[0].label} at IoU ${ranked[0].result.report.iou} (current ${result.report.iou}), ${candidates.length} candidates scored, none applied` : `IoU ${result.report.iou}`;
51
+ const where = views ? `${views.length} weighted views (${result.report.views.map(row => `${row.view} ${row.weight}`).join(', ')})` : `the reference at yaw ${result.report.view.yaw}`;
52
+ return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with ${where}: ${lead}.` };
53
+ };
@@ -0,0 +1,36 @@
1
+ // The Content grid's tile and its grab wire, shared by the asset pane's
2
+ // folders (asset-pane.jsx) and the sculpt shelf (sculpt-shelf-tiles.jsx).
3
+
4
+ /** The grab wire every card shares: left button only, App owns the rest. */
5
+ export function grabProps(onAssetGrab, payload) {
6
+ return {
7
+ onPointerDown: (event) => {
8
+ if (event.button !== 0) return;
9
+ event.preventDefault();
10
+ onAssetGrab?.(payload, event);
11
+ },
12
+ };
13
+ }
14
+
15
+ /** The Content grid's one tile grammar: a square thumbnail, an 11px name and
16
+ * a 10px type line. Click selects (amber ring), a left-button press starts the
17
+ * App-owned drag, double-click places the asset at the origin. */
18
+ export function ContentTile({ assetKey, label, type, title, preview, selected, onSelect, grab, onPlace, failed = false }) {
19
+ return (
20
+ <button
21
+ type="button"
22
+ className={"content-tile" + (failed ? " is-failed" : "")}
23
+ data-testid="content-asset"
24
+ data-asset-key={assetKey}
25
+ aria-pressed={selected}
26
+ title={title}
27
+ {...(grab ?? {})}
28
+ onClick={() => onSelect(assetKey)}
29
+ onDoubleClick={onPlace ? () => onPlace() : undefined}
30
+ >
31
+ <span className="content-tile-thumb">{preview}</span>
32
+ <span className="content-tile-name">{label}</span>
33
+ <span className="content-tile-type">{type}</span>
34
+ </button>
35
+ );
36
+ }
@@ -221,8 +221,6 @@ export function useCast(appContext) {
221
221
  }
222
222
 
223
223
  function removeCharacter(charId) {
224
- const list = appContext.live.characters;
225
- if (list.length <= 1) return;
226
224
  domain.run('character.remove', { characterId: charId });
227
225
  // The full-take cache remains native until the motion owner migrates.
228
226
  if (!appContext.storeDomain('motion')) appContext.shared.motionFullRef.current.delete(charId);
@@ -373,6 +371,7 @@ export function useCast(appContext) {
373
371
 
374
372
  function setLayerField(key, value) {
375
373
  const entry = domain.read().find(entry => entry.id === domain.activeId) ?? domain.read()[0];
374
+ if (!entry) return;
376
375
  const next = typeof value === 'function' ? value(entry.layer[key]) : value;
377
376
  if (sameCastValue(entry.layer[key], next)) return;
378
377
  return domain.run('cast.setLayer', { characterId: entry.id, layer: { [key]: next } });
@@ -743,7 +742,8 @@ export function useCast(appContext) {
743
742
  return appContext.storeDomain('motion').switchLayer(entry.id);
744
743
  }
745
744
  function switchActiveCharacterLayer() {
746
- const entry = domain.projection().find(entry => entry.id === domain.activeId) ?? domain.projection()[0];
745
+ // An empty stage loads no layer (id null).
746
+ const entry = domain.projection().find(entry => entry.id === domain.activeId) ?? domain.projection()[0] ?? { id: null };
747
747
  const previous = appContext.shared.loadedLayerCharRef.current;
748
748
  if (previous === entry.id) return;
749
749
  switchNativeMotionLayer(previous, entry);
@@ -784,7 +784,6 @@ export function useCast(appContext) {
784
784
  const live = appContext.live.state;
785
785
  const character = characterForRef(live.characters, args.ref);
786
786
  if (!character) throw new Error("Character not found");
787
- if (live.characters.length <= 1) throw new Error("Cannot remove the final character");
788
787
  domain.run('character.remove', { characterId: character.id });
789
788
  return { id: character.id };
790
789
  }
@@ -793,8 +792,10 @@ export function useCast(appContext) {
793
792
  function toggleCharacterHidden(charId) { return domain.run('character.update', { characterId: charId, patch: { hidden: castMemberOf(charId).hidden !== true } }); }
794
793
  domain.snapshotMotion = snapshotNativeMotion;
795
794
  domain.syncLayer = () => {
795
+ // An empty stage has no layer to edit: the buffer holds no route.
796
796
  const entry = domain.read().find(entry => entry.id === appContext.shared.loadedLayerCharRef.current) ?? domain.read()[0];
797
- appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, ...entry.layer };
797
+ appContext.shared.bufferRef.current = { ...appContext.shared.bufferRef.current, ...(entry?.layer ?? { waypoints: [] }) };
798
+ if (!entry) return setActiveWaypointId(null);
798
799
 
799
800
  setActiveWaypointId(id => entry.layer.waypoints.some(waypoint => waypoint.id === id) ? id : null);
800
801
  };
@@ -809,7 +810,7 @@ export function useCast(appContext) {
809
810
  const shots = appContext.storeDomain('shot');
810
811
  if (!shots) return;
811
812
  const current = domain.projection(), active = current.find(entry => entry.id === domain.activeId) ?? current[0];
812
- const extent = timelineContentExtent(current, active.id, appContext.shared.bufferRef.current.motion, null);
813
+ const extent = timelineContentExtent(current, active?.id ?? null, appContext.shared.bufferRef.current.motion, null);
813
814
  domain.setTimeline(Math.max(24, timelineSpan(extent, shots.read(), shots.state().frameCount)));
814
815
  };
815
816
  domain.extendTimeline = domain.syncTimeline;
@@ -33,6 +33,7 @@ import { isProxyFigure } from "../scenes.js";
33
33
  import { refuseRigOnly } from "../character-kind.js";
34
34
  import { DEFAULT_POSE, restoreBindPositions, applyPose, applyHipsOffset } from "../poses.js";
35
35
  import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, clampDragTarget, ikDragRecord, ikDragTouch, solveDragTarget } from "../motion/ik-drag.js";
36
+ import { fingersOf, solveFingerGesture, restFingers } from "../motion/fingers.js";
36
37
  import { ikKeyJson, ikTrackKeyFromJson } from "../motion/ik-key-json.js";
37
38
  import { chooseIkEntryPose } from "../ik-camera.js";
38
39
  import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../motion/range-pin.js";
@@ -71,8 +72,7 @@ export function createMotionDomain(appContext, characters) {
71
72
  let release = native.subscribe(notify), viewRevision = 0;
72
73
  const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
73
74
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
74
- const read = () => documentStore.read('motion');
75
- const layer = id => read().find(row => row.id === id) ?? emptyMotionLayer(id);
75
+ const read = () => documentStore.read('motion'), layer = id => read().find(row => row.id === id) ?? emptyMotionLayer(id);
76
76
  function materialize(take) {
77
77
  if (take?.source && !takes.has(take.resourceId)) {
78
78
  try { const compiled = compileTakeSource(take.source); if (compiled) takes.set(take.resourceId, compiled); }
@@ -110,7 +110,7 @@ export function createMotionDomain(appContext, characters) {
110
110
  const resolved = resolveIkRig(rig); if (resolved) Object.assign(state, resolved, { rig });
111
111
  if (savedRigs?.has(id)) appContext.shared.restoreExportRig(savedRigs.get(id));
112
112
  else if (resolved) {
113
- if (take) applyTakeFrame(rig, take, Math.min(frame(), take.frames - 1));
113
+ restFingers(resolved.fkJoints); if (take) applyTakeFrame(rig, take, Math.min(frame(), take.frames - 1));
114
114
  else {
115
115
  const character = cast.read().find(entry => entry.id === id);
116
116
  restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...(character?.pose ?? DEFAULT_POSE).bones });
@@ -253,7 +253,7 @@ export function createMotionDomain(appContext, characters) {
253
253
  rigOnly(id, 'pose');
254
254
  const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
255
255
  if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
256
- restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
256
+ restoreBindPositions(rig); restFingers(resolved.fkJoints); applyPose(rig, { ...REST_BONES, ...pose.bones }); applyHipsOffset(rig, pose.rootY ?? 0);
257
257
  editKeys(id, state => {
258
258
  state.tracked = new Set([...resolved.chains.keys(), ...resolved.fkJoints.keys()]);
259
259
  ikBakeKeyframe(resolved.chains, state, at, resolved.fkJoints);
@@ -761,6 +761,7 @@ export function useMotion(appContext) {
761
761
  ikDragRef.current = Object.assign(ikDragRecord(ikDragRef.current, appContext.shared.tlFrame), { characterId: appContext.shared.activeChar.id });
762
762
  const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id), activeId = appContext.shared.activeChar.id;
763
763
  const clamp = () => clampDragTarget({ kind, trackId, targetWorld, bodyContact, chains: ikChains, record: ikDragRef.current, bypass: input?.bypass, open: () => ({ rigs: appContext.shared.rigs, activeId, cast: appContext.shared.characters, allow: domain.layer(activeId).take?.source?.spec?.checks?.allow, notify: appContext.notify }) });
764
+ if (kind === "finger") return solveFingerGesture(fingersOf(appContext.shared.ikStateRef.current.fkJoints)?.get(trackId), targetWorld, touch);
764
765
  if (kind === "chain") {
765
766
  const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
766
767
  if (!chain) return;
@@ -915,7 +916,7 @@ export function useMotion(appContext) {
915
916
  restoreBindPositions(appContext.shared.activeRig);
916
917
  // Reset-then-pose, the same shape the Character effect applies: unlisted
917
918
  // joints return to rest instead of keeping stale limbs from the clip.
918
- applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
919
+ restFingers(ikFkJoints); applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
919
920
  // The hips' measured height rides into the bake: the hips FK joint keys
920
921
  // its local position (p), so a crouched pose keys a crouched body.
921
922
  applyHipsOffset(appContext.shared.activeRig, pose.rootY ?? 0);
@@ -37,6 +37,8 @@ import {
37
37
  clearStoredProjectHandle,
38
38
  DEFAULT_PREVIS_MODE,
39
39
  normalizePrevisMode,
40
+ DEFAULT_CAST_STYLE,
41
+ normalizeCastStyle,
40
42
  buildAnimationProjectFromStoryboard,
41
43
  } from "../project.js";
42
44
  import { playgroundSceneUrl, fetchSceneProject } from "../playground.js";
@@ -56,11 +58,13 @@ import { createIkState } from "../motion/ik.js";
56
58
 
57
59
  // The scene list and project identity share one history. Dirty is derived from
58
60
  // the saved checkpoint, not an authored edit, and has its own non-history slice.
59
- // previsMode is project identity too: it is set only by a document boundary
60
- // (blank creation, starter/file open, session restore), never by an edit.
61
- function createScenesDomain(appContext, initial, name, previsMode = DEFAULT_PREVIS_MODE) {
61
+ // previsMode and castStyle are project identity too: they are set only by a
62
+ // document boundary (blank creation, starter/file open, session restore),
63
+ // never by an edit.
64
+ function createScenesDomain(appContext, initial, name, previsMode = DEFAULT_PREVIS_MODE, castStyle = DEFAULT_CAST_STYLE) {
62
65
  const ordered = rows => rows.map((scene, order) => ({ ...scene, order }));
63
- let native = createDocumentStore({ owned: { scenes: ordered(initial.scenes), project: { name, activeSceneId: initial.activeSceneId, previsMode: normalizePrevisMode(previsMode) } } });
66
+ const project = (fields, mode, style) => ({ ...fields, previsMode: normalizePrevisMode(mode), castStyle: normalizeCastStyle(style) });
67
+ let native = createDocumentStore({ owned: { scenes: ordered(initial.scenes), project: project({ name, activeSceneId: initial.activeSceneId }, previsMode, castStyle) } });
64
68
  const listeners = new Set();
65
69
  const notify = () => { for (const listener of listeners) listener(); };
66
70
  let release = native.subscribe(notify);
@@ -96,7 +100,7 @@ function createScenesDomain(appContext, initial, name, previsMode = DEFAULT_PREV
96
100
  appContext.publishScenes(read());
97
101
  appContext.shared.activeSceneIdRef.current = metadata().activeSceneId;
98
102
  if (appContext.live.state) appContext.patchLive({ scenes: read(), activeSceneId: metadata().activeSceneId });
99
- storeProjectSession(metadata().name, metadata().previsMode);
103
+ storeProjectSession(metadata().name, metadata());
100
104
  domain.persist?.();
101
105
  domain.refreshDirty?.();
102
106
  };
@@ -120,9 +124,9 @@ function createScenesDomain(appContext, initial, name, previsMode = DEFAULT_PREV
120
124
  },
121
125
  // Session cache/file loads are explicit non-authored boundaries. They
122
126
  // retire scene history, just as opening a scene retired native history.
123
- replaceDocument(scenes, activeSceneId, name = metadata().name, previsMode = metadata().previsMode) {
127
+ replaceDocument(scenes, activeSceneId, name = metadata().name, previsMode = metadata().previsMode, castStyle = metadata().castStyle) {
124
128
  release(); native.dispose();
125
- native = createDocumentStore({ owned: { scenes: ordered(scenes), project: { name, activeSceneId, previsMode: normalizePrevisMode(previsMode) } } });
129
+ native = createDocumentStore({ owned: { scenes: ordered(scenes), project: project({ name, activeSceneId }, previsMode, castStyle) } });
126
130
  release = native.subscribe(notify); notify();
127
131
  },
128
132
  setDirty(value) {
@@ -141,10 +145,10 @@ export function useScenes(appContext) {
141
145
  const [domain] = useState(() => {
142
146
  if (appContext.storeDomain("scenes")) return appContext.storeDomain("scenes");
143
147
  const session = loadProjectSession();
144
- return createScenesDomain(appContext, appContext.shared.startup.document, session?.name ?? null, session?.previsMode);
148
+ return createScenesDomain(appContext, appContext.shared.startup.document, session?.name ?? null, session?.previsMode, session?.castStyle);
145
149
  });
146
150
  const scenes = useDocumentDomain(domain.documentStore, "scenes");
147
- const { activeSceneId, name: projectName, previsMode } = useDocumentDomain(domain.documentStore, "project");
151
+ const { activeSceneId, name: projectName, previsMode, castStyle } = useDocumentDomain(domain.documentStore, "project");
148
152
  const projectDirty = useDocumentDomain(domain.dirtyStore, "projectDirty");
149
153
  const runtimeValues = useRef(new Map());
150
154
  async function runProject(id, args = {}) {
@@ -228,6 +232,7 @@ export function useScenes(appContext) {
228
232
  workflow: loadWorkflowGraph(),
229
233
  name,
230
234
  previsMode: domain.metadata().previsMode,
235
+ castStyle: domain.metadata().castStyle,
231
236
  };
232
237
  }
233
238
 
@@ -263,7 +268,7 @@ export function useScenes(appContext) {
263
268
  appContext.shared.projectSnapshotRef.current = snapshot;
264
269
  if (domain.metadata().name === previousName) domain.nameSaved(name);
265
270
  refreshProjectDirty();
266
- storeProjectSession(domain.metadata().name, domain.metadata().previsMode);
271
+ storeProjectSession(domain.metadata().name, domain.metadata());
267
272
  }
268
273
 
269
274
  function projectProblemsNotice(problems) {
@@ -405,8 +410,8 @@ export function useScenes(appContext) {
405
410
  // too — otherwise an older .cozyclay would open a sixth too fast.
406
411
  const doc = migrateToCurrentScenes(source);
407
412
  const mergedCustomPoses = mergeProjectCustomPoses(appContext.shared.customPoses, project.customPoses);
408
- const projectPrevisMode = normalizePrevisMode(project.previsMode);
409
- domain.replaceDocument(doc.scenes, doc.activeSceneId, project.name, projectPrevisMode);
413
+ const projectPrevisMode = normalizePrevisMode(project.previsMode), projectCastStyle = normalizeCastStyle(project.castStyle);
414
+ domain.replaceDocument(doc.scenes, doc.activeSceneId, project.name, projectPrevisMode, projectCastStyle);
410
415
  if (project.workspaceLayout) appContext.shared.setWorkspaceLayout({ ...DEFAULT_WORKSPACE_LAYOUT, ...project.workspaceLayout });
411
416
  appContext.bus.run('cast.setCustomPoses', { poses: mergedCustomPoses });
412
417
  const resolvedWorkflow = resolveWorkflowOutputs(normalizeWorkflowGraph(project.workflow), new Map((project.assets ?? []).map((asset) => [asset.id, asset])));
@@ -417,7 +422,7 @@ export function useScenes(appContext) {
417
422
  appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(project.name);
418
423
  domain.pendingCheckpoint = { clock: appContext.undoClock, name: project.name };
419
424
  domain.setDirty(false);
420
- storeProjectSession(project.name, projectPrevisMode);
425
+ storeProjectSession(project.name, { previsMode: projectPrevisMode, castStyle: projectCastStyle });
421
426
  setProjectStartupOpen(false);
422
427
  setProjectManifest(resourceManifest({ scenesDocument: doc, workflow: resolvedWorkflow, poseLibrary: mergedCustomPoses, assets: project.assets ?? [], workflowOutputRefs }));
423
428
  track("project:opened", { age_bucket: bucketProjectAge(Date.now() - (project.savedAt ?? Date.now())) });
@@ -425,7 +430,8 @@ export function useScenes(appContext) {
425
430
 
426
431
  /** Open a bundled starter scene as a fresh, saveable project. Used by the
427
432
  * first-run dialog and by `npx cozyclay --scene <id>` (`?scene=`). */
428
- async function openStarterScene(id, source = "starter", context = null, name = null) {
433
+ // A cast style chosen on the start screen overrides the starter's own.
434
+ async function openStarterScene(id, source = "starter", context = null, name = null, castStyle = undefined) {
429
435
  if (source === "starter" && domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return false;
430
436
  const url = playgroundSceneUrl(`?scene=${encodeURIComponent(id)}`);
431
437
  const project = url ? await fetchSceneProject(url) : null;
@@ -434,7 +440,7 @@ export function useScenes(appContext) {
434
440
  return false;
435
441
  }
436
442
  context?.check();
437
- applyProject({ ...project, name: name?.trim() || project.name, savedAt: null }, true);
443
+ applyProject({ ...project, name: name?.trim() || project.name, savedAt: null, ...(castStyle ? { castStyle } : {}) }, true);
438
444
  appContext.shared.projectHandleRef.current = null;
439
445
  track("scene:loaded", { scene_source: source });
440
446
  return true;
@@ -504,35 +510,39 @@ export function useScenes(appContext) {
504
510
  }
505
511
  }
506
512
 
507
- function requestNewProject(authorized = false, { previsMode } = {}) {
508
- if (!authorized) return runProject("project.new", previsMode === undefined ? undefined : { previsMode });
513
+ // The start screen's choices (previs mode, cast style); a field left out is
514
+ // not sent, so the command's own default applies.
515
+ const chosen = ({ previsMode, castStyle } = {}) => ({ ...(previsMode === undefined ? {} : { previsMode }), ...(castStyle === undefined ? {} : { castStyle }) });
516
+
517
+ function requestNewProject(authorized = false, choices = {}) {
518
+ if (!authorized) return runProject("project.new", Object.keys(chosen(choices)).length ? chosen(choices) : undefined);
509
519
  if (domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return;
510
- // The mode chosen on the start screen rides on the name dialog until its
520
+ // The choices made on the start screen ride on the name dialog until its
511
521
  // submit commits the project.
512
- setProjectNameDialog({ kind: "new", initialName: projectName ?? "My Project", ...(previsMode === undefined ? {} : { previsMode }) });
522
+ setProjectNameDialog({ kind: "new", initialName: projectName ?? "My Project", ...chosen(choices) });
513
523
  }
514
524
 
515
- function newProject(name, authorized = false, { previsMode } = {}) {
525
+ function newProject(name, authorized = false, choices = {}) {
516
526
  if (!authorized) {
517
- if (previsMode === undefined && projectNameDialog?.kind === "new") previsMode = projectNameDialog.previsMode;
518
- return runProject("project.new", { ...(typeof name === "string" ? { name } : {}), ...(previsMode === undefined ? {} : { previsMode }) });
527
+ const pending = projectNameDialog?.kind === "new" ? chosen(projectNameDialog) : {};
528
+ return runProject("project.new", { ...(typeof name === "string" ? { name } : {}), ...pending, ...chosen(choices) });
519
529
  }
520
- if (typeof name !== "string") return requestNewProject(true, { previsMode });
530
+ if (typeof name !== "string") return requestNewProject(true, choices);
521
531
  name = name.trim() || "My Project";
522
- previsMode = normalizePrevisMode(previsMode);
532
+ const previsMode = normalizePrevisMode(choices.previsMode), castStyle = normalizeCastStyle(choices.castStyle);
523
533
  setProjectNameDialog(null);
524
534
  const fresh = createSceneDocument(ko("SCENE 01", "씬 01"));
525
535
  storeWorkflowGraph(createWorkflowGraph());
526
536
  appContext.shared.motionFullRef.current.clear();
527
- domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name, previsMode);
537
+ domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name, previsMode, castStyle);
528
538
  persistScenes(fresh.scenes, fresh.activeSceneId);
529
539
  openScene(fresh.scenes[0], fresh.scenes);
530
540
  appContext.shared.projectHandleRef.current = null;
531
541
  clearStoredProjectHandle();
532
542
  appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
533
543
  domain.setDirty(false);
534
- storeProjectSession(name, previsMode);
535
- track("scene:created", { scene_source: "new_project", previs_mode: previsMode });
544
+ storeProjectSession(name, { previsMode, castStyle });
545
+ track("scene:created", { scene_source: "new_project", previs_mode: previsMode, cast_style: castStyle });
536
546
  setProjectStartupOpen(false);
537
547
  appContext.shared.setFirstSuccessGuideOpen(true);
538
548
  domain.pendingCheckpoint = { clock: appContext.undoClock, name };
@@ -768,8 +778,8 @@ export function useScenes(appContext) {
768
778
  return value;
769
779
  };
770
780
  if (id === "project.browse") { setProjectStartupOpen(false); setProjectBrowserOpen(true); return; }
771
- if (id === "project.new") return newProject(args.name, true, { previsMode: args.previsMode });
772
- if (id === "project.openStarter") return openStarterScene(args.id, args.source, context, args.name);
781
+ if (id === "project.new") return newProject(args.name, true, { previsMode: args.previsMode, castStyle: args.castStyle });
782
+ if (id === "project.openStarter") return openStarterScene(args.id, args.source, context, args.name, args.castStyle);
773
783
  if (id === "project.restore") return restoreStoredProject({ handle: runtime("handleToken") }, context);
774
784
  if (args.handleToken) return openProjectByHandle(runtime("handleToken"), context);
775
785
  if (args.projectToken) { applyProject(runtime("projectToken"), true); return true; }
@@ -788,14 +798,14 @@ export function useScenes(appContext) {
788
798
  domain.refreshDirty = () => refreshProjectDirty(false);
789
799
  return {
790
800
  ...domain, applyExternalScene, loadLiveScenes, refreshProjectDirty,
791
- scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode,
801
+ scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode, castStyle,
792
802
  projectDirty, setProjectDirty: domain.setDirty, projectSaveState, setProjectSaveState, projectMenuOpen,
793
803
  setProjectMenuOpen, projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog,
794
804
  projectStartupOpen, setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons,
795
805
  setSaveBlockedReasons, workflowRevision, setWorkflowRevision, collectProjectSnapshot,
796
806
  collectProjectSerialized, projectProblemsNotice, rehydrateProjectAssets, saveProject, applyProject,
797
807
  exportAsAnimationProject,
798
- openStarterScene: async (id, source = "starter", name = null) => (await runProject("project.openStarter", { id, source, ...(name ? { name } : {}) })).output?.opened === true,
808
+ openStarterScene: async (id, source = "starter", name = null, castStyle = undefined) => (await runProject("project.openStarter", { id, source, ...(name ? { name } : {}), ...(castStyle ? { castStyle } : {}) })).output?.opened === true,
799
809
  openProject, openProjectByHandle, requestNewProject, newProject, restoreOffer,
800
810
  setRestoreOffer, restoreStoredProject, flushScenes, openScene, selectSceneDocument,
801
811
  createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
@@ -1,7 +1,7 @@
1
1
  import { bucketMs, track, EXECUTION_TELEMETRY_VALUES } from "./analytics.js";
2
2
  export { AGENT_TOOL_CATEGORIES, EXECUTION_TELEMETRY_EVENTS, EXECUTION_TELEMETRY_PROPERTY_KEYS, EXECUTION_TELEMETRY_VALUES } from "./analytics.js";
3
3
  const WORKFLOW_FAILURE_CODES = new Set(["aborted", "capture_failed", "generation_failed", "unknown"]);
4
- const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown"]);
4
+ const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "network", "unknown"]);
5
5
  function secureId() {
6
6
  try {
7
7
  if (!globalThis.crypto?.getRandomValues) return null;
@@ -41,13 +41,17 @@ function executionFailureCode(error, channel, fallbackCode = "unknown") {
41
41
  return allowed.has(fallbackCode) ? fallbackCode : "unknown";
42
42
  }
43
43
 
44
+ /** The closed `tool_name` for an MCP tool event: a live tool name, else "other". */
45
+ export function mcpToolName(name) {
46
+ return EXECUTION_TELEMETRY_VALUES.tool_name.has(name) ? name : "other";
47
+ }
48
+
44
49
  export function mcpToolCategory(name) {
45
-
46
50
  if (name === "capture_frame") return "frame_capture";
47
51
  if (name === "set_camera" || name === "frame_shot" || name === "mark_camera_move" || name === "describe_camera_move") return "camera";
48
52
 
49
53
  if (name === "save_project" || name === "open_project") return "project_io";
50
- if (name === "describe_scene" || name === "describe_shot" || name === "render_prompt" || name === "live_status" || name === "compare_sculpt") return "read";
54
+ if (name === "describe_scene" || name === "describe_shot" || name === "render_prompt" || name === "live_status" || name === "compare_sculpt" || name === "studio_commands") return "read";
51
55
  if (name === "add_character" || name === "place_character" || name === "remove_character" || name === "focus_character" || name === "place_object" || name === "import_mesh" || name === "sculpt_object" || name === "group_objects" || name === "update_object" || name === "remove_object" || name === "apply_batch" || name === "add_scene" || name === "switch_scene") return "scene_write";
52
56
  return "other";
53
57
  }
@@ -0,0 +1,26 @@
1
+ // export:menu_opened: one event each time the Export flyout opens, by click or
2
+ // keyboard (the open state's false -> true edge), so reopening counts again.
3
+ // webcodecs is the synchronous constructor check only; whether a codec config
4
+ // is supported stays with the export attempt (unsupported_codec).
5
+ import { useEffect } from "react";
6
+ import { bucketCount, track } from "./analytics.js";
7
+
8
+ /** The closed properties for one open: surface, mode, webcodecs and the shot (storyboard: panel) count bucket. */
9
+ export function exportMenuOpenedProps({ embedMode, previsMode, shots }, scope = globalThis) {
10
+ const storyboard = previsMode === "storyboard";
11
+ const list = Array.isArray(shots) ? shots : [];
12
+ return {
13
+ surface: embedMode ? "embed" : "studio",
14
+ mode: storyboard ? "storyboard" : "animation",
15
+ webcodecs: typeof scope.VideoEncoder === "function" && typeof scope.VideoFrame === "function",
16
+ shots: bucketCount(storyboard ? list.filter((entry) => entry.kind === "still").length : list.length),
17
+ };
18
+ }
19
+
20
+ // Keyed on the open flag only: the render that opens the flyout supplies the shell it reports.
21
+ export function useExportMenuTelemetry(open, shell) {
22
+ useEffect(() => {
23
+ if (!open) return;
24
+ try { track("export:menu_opened", exportMenuOpenedProps(shell)); } catch { /* telemetry is advisory */ }
25
+ }, [open]);
26
+ }
@@ -0,0 +1,128 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { POSER_LAYER } from "./dualview.jsx";
5
+ import { fingerTip, fingersOf } from "./motion/fingers.js";
6
+ import { claimFingertip } from "./motion/fingertip-pick.js";
7
+ import { handleColor } from "./pose-gizmo.js";
8
+
9
+ // A fingertip handle is a real sphere in the scene, a little wider than the finger, so it sits on the tip,
10
+ // shades with the lights and hides behind the body by depth like the mesh does.
11
+ const TIP_R = 0.009;
12
+ // The tip bone sits inside the fingertip; the sphere rides this far further along the last bone so most of
13
+ // it shows past the skin.
14
+ const TIP_PUSH = 0.008;
15
+ // Picking reaches a few pixels past the sphere's projected edge, nearest tip first.
16
+ const PICK_SLACK_PX = 6;
17
+ // A tip may sit this far under the first body surface and still be grabbed: the IK controls' exposure rule
18
+ // (ikControlIsExposed) with their default allowance, twice the handle radius (#913).
19
+ const TIP_VISIBILITY_DEPTH = TIP_R * 2;
20
+
21
+ /** The focused hand's fingers, and fingertip editing: a mode of the focused hand, on only while a hand is focused. */
22
+ export function useFingerEdit(ikFocus, fkJoints) {
23
+ const [fingerEdit, setFingerEdit] = useState(false);
24
+ // IkHandles fills this with its exposure probe; the fingertip pick asks it before taking a press.
25
+ const exposureProbeRef = useRef(null);
26
+ const side = ikFocus === "leftHand" ? "Left" : ikFocus === "rightHand" ? "Right" : null;
27
+ const handFingers = side ? [...(fingersOf(fkJoints)?.values() ?? [])].filter((finger) => finger.side === side) : [];
28
+ return { handFingers, fingerEditing: fingerEdit && handFingers.length > 0, setFingerEdit, exposureProbeRef };
29
+ }
30
+
31
+ /**
32
+ * Fingertip handles for the focused hand. Dragging a tip solves that finger toward the pointer on a
33
+ * camera-facing plane (onSolve("finger", fingerId, point)); release bakes like any IK drag (onDragEnd).
34
+ * A tip the body hides cannot be grabbed (exposureProbeRef, the IK controls' rule); that press goes to IkHandles.
35
+ * The press is claimed in the window capture phase, ahead of IkHandles, so it never reads as an empty
36
+ * click that would drop the hand's focus.
37
+ */
38
+ export function FingerHandles({ fingers: handFingers, enabled, onSolve, onDragEnd, exposureProbeRef }) {
39
+ const { camera, gl } = useThree();
40
+ const meshes = useRef(new Map());
41
+ const solveRef = useRef(onSolve), endRef = useRef(onDragEnd);
42
+ useEffect(() => { solveRef.current = onSolve; endRef.current = onDragEnd; });
43
+ const fingers = enabled ? handFingers : [];
44
+
45
+ useFrame(() => {
46
+ const along = new THREE.Vector3();
47
+ for (const finger of fingers) {
48
+ const mesh = meshes.current.get(finger.id);
49
+ if (!mesh) continue;
50
+ fingerTip(finger, mesh.position);
51
+ finger.bones[2].getWorldPosition(along);
52
+ mesh.userData.push = along.subVectors(mesh.position, along).normalize().multiplyScalar(TIP_PUSH).clone();
53
+ mesh.position.add(mesh.userData.push);
54
+ }
55
+ });
56
+
57
+ const active = fingers.length > 0;
58
+ useEffect(() => {
59
+ if (!active) return undefined;
60
+ const el = gl.domElement, ndc = new THREE.Vector2(), hit = new THREE.Vector3(), ray = new THREE.Raycaster();
61
+ let drag = null;
62
+ const toNdc = (ev) => {
63
+ const rect = el.getBoundingClientRect();
64
+ return ndc.set(((ev.clientX - rect.left) / rect.width) * 2 - 1, -((ev.clientY - rect.top) / rect.height) * 2 + 1);
65
+ };
66
+ const onMove = (ev) => {
67
+ if (!drag) return;
68
+ ray.setFromCamera(toNdc(ev), camera);
69
+ if (!ray.ray.intersectPlane(drag.plane, hit)) return;
70
+ drag.moved = true;
71
+ solveRef.current?.("finger", drag.id, hit.clone().sub(drag.push));
72
+ };
73
+ const release = () => {
74
+ window.removeEventListener("pointermove", onMove, true);
75
+ window.removeEventListener("pointerup", onUp, true);
76
+ window.removeEventListener("pointercancel", onUp, true);
77
+ };
78
+ const onUp = () => {
79
+ const moved = drag?.moved;
80
+ drag = null;
81
+ release();
82
+ if (moved) endRef.current?.();
83
+ };
84
+ const onDown = (ev) => {
85
+ if (ev.button !== 0 || ev.target !== el) return;
86
+ const rect = el.getBoundingClientRect(), centre = new THREE.Vector3(), edge = new THREE.Vector3();
87
+ const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0);
88
+ const toScreen = (point) => { point.project(camera); return [rect.left + ((point.x + 1) / 2) * rect.width, rect.top + ((1 - point.y) / 2) * rect.height]; };
89
+ const candidates = [...meshes.current].filter(([, mesh]) => mesh).map(([id, mesh]) => {
90
+ const [cx, cy] = toScreen(centre.copy(mesh.position));
91
+ const [ex] = toScreen(edge.copy(mesh.position).addScaledVector(right, TIP_R));
92
+ return { id, mesh, gap: Math.hypot(cx - ev.clientX, cy - ev.clientY), reach: Math.abs(ex - cx) + PICK_SLACK_PX };
93
+ });
94
+ const exposed = exposureProbeRef?.current;
95
+ const picked = claimFingertip(ev, candidates, ({ mesh }) => !exposed || exposed(mesh.position, TIP_VISIBILITY_DEPTH));
96
+ if (!picked) return;
97
+ const normal = camera.getWorldDirection(new THREE.Vector3());
98
+ drag = { id: picked.id, moved: false, push: picked.mesh.userData.push?.clone() ?? new THREE.Vector3(), plane: new THREE.Plane().setFromNormalAndCoplanarPoint(normal, picked.mesh.position) };
99
+ window.addEventListener("pointermove", onMove, true);
100
+ window.addEventListener("pointerup", onUp, true);
101
+ window.addEventListener("pointercancel", onUp, true);
102
+ };
103
+ window.addEventListener("pointerdown", onDown, true);
104
+ return () => {
105
+ window.removeEventListener("pointerdown", onDown, true);
106
+ release();
107
+ };
108
+ }, [active, camera, gl, exposureProbeRef]);
109
+
110
+ if (!active) return null;
111
+ return (
112
+ <group>
113
+ {fingers.map((finger) => (
114
+ <mesh
115
+ key={finger.id}
116
+ raycast={() => {}}
117
+ ref={(mesh) => {
118
+ if (mesh) { mesh.layers.set(POSER_LAYER); meshes.current.set(finger.id, mesh); }
119
+ else meshes.current.delete(finger.id);
120
+ }}
121
+ >
122
+ <sphereGeometry args={[TIP_R, 20, 14]} />
123
+ <meshStandardMaterial color={handleColor(finger.trackIds[0])} roughness={0.45} metalness={0} />
124
+ </mesh>
125
+ ))}
126
+ </group>
127
+ );
128
+ }