cozyclay 3.1.6 → 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 (224) hide show
  1. package/CHANGELOG.md +145 -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 +25 -17
  7. package/bin/agent/agent-stream.mjs +26 -3
  8. package/bin/agent/main-prompt.mjs +1 -1
  9. package/bin/agent/openai-compatible-provider.mjs +24 -1
  10. package/bin/agent/studio-prompt.mjs +2 -2
  11. package/bin/agent/supervisor-followup.mjs +34 -0
  12. package/bin/agent/supervisor.mjs +6 -6
  13. package/bin/cozyclay.mjs +23 -60
  14. package/bin/star-prompt.mjs +15 -14
  15. package/bin/studio-routes.mjs +6 -3
  16. package/bin/telemetry-state.mjs +5 -7
  17. package/bin/update-check.mjs +61 -7
  18. package/bin/update-install.mjs +16 -3
  19. package/bin/update-restart.mjs +95 -0
  20. package/bin/update-routes.mjs +25 -3
  21. package/dist/app/index.html +13 -7
  22. package/dist/assets/TaskBoard-tgO0MNKL.js +1 -0
  23. package/dist/assets/analytics-CaTUuLPM.js +1 -0
  24. package/dist/assets/{app-CEP7jzXB.css → app-CnDkB5WO.css} +1 -1
  25. package/dist/assets/app-DJt06Oyb.js +5144 -0
  26. package/dist/assets/compile-worker-D1pl6G7x.js +6 -0
  27. package/dist/assets/landing-2Fy7DU41.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BxaM0Xwl.js +1 -0
  30. package/dist/assets/scene-asset-sync-CLGNbEBq.js +13 -0
  31. package/dist/assets/task-board-UVGTZLRF.js +15 -0
  32. package/dist/assets/workflow-CSiBlX1s.js +179 -0
  33. package/dist/cozyclay-package.json +1 -1
  34. package/dist/index.html +4 -1
  35. package/dist/privacy/index.html +9 -6
  36. package/dist/sitemap.xml +2 -2
  37. package/dist/workflow/index.html +5 -4
  38. package/mcp/README.md +1 -1
  39. package/mcp/live-hub.mjs +4 -4
  40. package/mcp/tool-handlers.mjs +14 -16
  41. package/package.json +3 -1
  42. package/src/App.jsx +42 -75
  43. package/src/analytics-controls.js +25 -3
  44. package/src/analytics-strict.js +16 -4
  45. package/src/analytics.js +32 -26
  46. package/src/app-version.js +13 -0
  47. package/src/asset-drop.js +39 -0
  48. package/src/asset-pane.css +34 -0
  49. package/src/asset-pane.jsx +4 -35
  50. package/src/bus-tasks.js +1 -1
  51. package/src/character-kind.js +8 -6
  52. package/src/click-friction.js +5 -4
  53. package/src/command-bus-telemetry.js +8 -7
  54. package/src/command-bus.js +2 -16
  55. package/src/commands/cast.js +2 -1
  56. package/src/commands/index.js +2 -1
  57. package/src/commands/motion3.js +3 -1
  58. package/src/commands/objects.js +2 -37
  59. package/src/commands/physics.js +21 -0
  60. package/src/commands/project.js +3 -3
  61. package/src/commands/sculpt-compare.js +53 -0
  62. package/src/content-tile.jsx +36 -0
  63. package/src/domains/cast.js +7 -6
  64. package/src/domains/motion.js +13 -14
  65. package/src/domains/scenes.js +41 -31
  66. package/src/execution-telemetry.js +7 -3
  67. package/src/export-menu-telemetry.js +26 -0
  68. package/src/finger-handles.jsx +128 -0
  69. package/src/friction-hint.js +82 -0
  70. package/src/live-control.js +2 -1
  71. package/src/locale-zh/panels.js +20 -0
  72. package/src/locale-zh/shell.js +4 -0
  73. package/src/locale-zh/workspace.js +6 -0
  74. package/src/motion/center-of-mass.js +5 -23
  75. package/src/motion/contact-project.js +143 -0
  76. package/src/motion/find-bone.js +13 -0
  77. package/src/motion/fingers.js +170 -0
  78. package/src/motion/fingertip-pick.js +21 -0
  79. package/src/motion/fix-collisions.js +1 -1
  80. package/src/motion/ik-drag.js +93 -1
  81. package/src/motion/ik-exposure.js +48 -0
  82. package/src/motion/ik.js +4 -11
  83. package/src/motion/timeline.css +12 -0
  84. package/src/motion/timeline.jsx +2 -2
  85. package/src/motion3/compile-job.js +3 -2
  86. package/src/motion3/compiler/compile.js +3 -3
  87. package/src/motion3/compiler/head-lead.js +4 -2
  88. package/src/motion3/compiler/spring.js +95 -0
  89. package/src/motion3/compiler/validate-objects.js +18 -2
  90. package/src/motion3/compiler/validate-parts.js +1 -0
  91. package/src/motion3/compiler/vocab.js +1 -1
  92. package/src/motion3/fix/index.js +8 -4
  93. package/src/motion3/fix/person.js +110 -0
  94. package/src/motion3/free-motion.js +49 -11
  95. package/src/motion3/hybrid-hint.js +19 -0
  96. package/src/motion3/load.js +19 -12
  97. package/src/motion3/object-pose.js +44 -2
  98. package/src/motion3/people.js +16 -1
  99. package/src/motion3/physics-frames.js +72 -0
  100. package/src/motion3/physics-trail.js +45 -0
  101. package/src/motion3/physics-view.js +79 -0
  102. package/src/motion3/pipeline.js +5 -1
  103. package/src/motion3/qa/ballistic.js +67 -0
  104. package/src/motion3/qa/com.js +140 -0
  105. package/src/motion3/qa/ground.js +10 -3
  106. package/src/motion3/qa/hints-contact.js +21 -7
  107. package/src/motion3/qa/hints-motion.js +38 -1
  108. package/src/motion3/qa/key-checks.js +5 -4
  109. package/src/motion3/qa/patch.js +32 -4
  110. package/src/motion3/qa/people-checks.js +1 -1
  111. package/src/motion3/qa/spring-pivot.js +23 -0
  112. package/src/motion3/qa/style.js +13 -30
  113. package/src/motion3/qa/verdict.js +7 -2
  114. package/src/motion3/repair.js +28 -8
  115. package/src/motion3/schema/fields.js +4 -2
  116. package/src/motion3/schema/topics.js +15 -11
  117. package/src/motion3/schema/types.js +13 -9
  118. package/src/motion3/skills/catch.js +2 -1
  119. package/src/panels/FingerPanel.jsx +38 -0
  120. package/src/panels/RigControlPanel.jsx +9 -1
  121. package/src/panels/SculptPanel.jsx +12 -12
  122. package/src/panels/SculptPartTree.jsx +46 -0
  123. package/src/panels/SubjectsPanel.jsx +1 -1
  124. package/src/panels/sculpt.css +11 -0
  125. package/src/physics-overlay-settings.js +41 -0
  126. package/src/physics-overlay.js +152 -0
  127. package/src/physics-overlay.jsx +54 -0
  128. package/src/physics-toggle.jsx +20 -0
  129. package/src/pose-fade.js +7 -3
  130. package/src/posestudio.jsx +15 -28
  131. package/src/project-browser.css +6 -0
  132. package/src/project-browser.jsx +9 -18
  133. package/src/project-start-choices.jsx +45 -0
  134. package/src/project.js +16 -12
  135. package/src/proxy-figure-shape.js +11 -0
  136. package/src/proxy-figure.jsx +25 -18
  137. package/src/scene-assets.js +17 -13
  138. package/src/scenes.js +22 -11
  139. package/src/sculpt-blueprint.js +56 -0
  140. package/src/sculpt-mode.js +5 -0
  141. package/src/sculpt-part-edit.js +115 -0
  142. package/src/sculpt-part-gizmo.jsx +159 -0
  143. package/src/sculpt-part-outline.jsx +72 -0
  144. package/src/sculpt-pick.js +53 -0
  145. package/src/sculpt-run-text.js +51 -0
  146. package/src/sculpt-run.js +10 -46
  147. package/src/sculpt-shelf-file.js +92 -0
  148. package/src/sculpt-shelf-tiles.jsx +69 -0
  149. package/src/sculpt-shelf.js +108 -0
  150. package/src/sculpt-stage.jsx +39 -2
  151. package/src/sculpt-thumb.js +72 -0
  152. package/src/sculpt-views.js +75 -0
  153. package/src/semantic-edit.js +0 -87
  154. package/src/settings-menu.jsx +2 -2
  155. package/src/shell/DetailsSlot.jsx +5 -6
  156. package/src/shell/LibrarySlot.jsx +8 -10
  157. package/src/shell/MenuBar.jsx +6 -6
  158. package/src/shell/PreferencesDialog.jsx +2 -3
  159. package/src/shell/ViewportToolbar.jsx +15 -18
  160. package/src/shell/add-character-entries.js +13 -0
  161. package/src/shell/pose-controls.js +32 -0
  162. package/src/shell/preference-pages.jsx +2 -11
  163. package/src/shell/storyboard-caption.js +21 -0
  164. package/src/shell/studio-shell-context.js +1 -0
  165. package/src/shortcut-friction.js +2 -1
  166. package/src/site/landing-analytics.js +28 -0
  167. package/src/studio-actions.js +3 -2
  168. package/src/studio-agent-protocol.js +4 -4
  169. package/src/studio-app-binding.js +4 -3
  170. package/src/update/UpdateChip.jsx +21 -2
  171. package/src/update/UpdateDialog.jsx +26 -6
  172. package/src/update/update-client.js +123 -17
  173. package/src/update/update-telemetry.js +15 -3
  174. package/src/use-sculpt-mode.js +62 -8
  175. package/src/workflow/agent-failure-codes.js +28 -9
  176. package/src/workflow/agent-turn-telemetry.js +1 -1
  177. package/src/workflow/task-board.js +2 -2
  178. package/tools/dev/page-shell.py +1 -1
  179. package/tools/dev/pages/greybox-to-video.html +21 -2
  180. package/tools/dev/pages/previs-software.html +3 -3
  181. package/tools/dev/pages/privacy.html +9 -6
  182. package/tools/dev/pages/seedance-camera-control.html +2 -2
  183. package/tools/loc-baseline.json +10 -10
  184. package/tools/run-tests.mjs +53 -84
  185. package/tools/select-tests.mjs +119 -0
  186. package/tools/test-durations.json +455 -0
  187. package/tools/test-graph.mjs +181 -0
  188. package/tools/test-shards.mjs +78 -0
  189. package/tools/tutorial/README.md +74 -0
  190. package/tools/tutorial/assemble.mjs +43 -0
  191. package/tools/tutorial/captions.mjs +43 -0
  192. package/tools/tutorial/cdp.mjs +55 -0
  193. package/tools/tutorial/cursor.mjs +159 -0
  194. package/tools/tutorial/episode.mjs +173 -0
  195. package/tools/tutorial/episodes/beginner-guide/00-install.mjs +42 -0
  196. package/tools/tutorial/episodes/beginner-guide/01-start.mjs +50 -0
  197. package/tools/tutorial/episodes/beginner-guide/02-layout.mjs +67 -0
  198. package/tools/tutorial/episodes/beginner-guide/03-stage.mjs +58 -0
  199. package/tools/tutorial/episodes/beginner-guide/04-motion.mjs +98 -0
  200. package/tools/tutorial/episodes/beginner-guide/05-camera.mjs +47 -0
  201. package/tools/tutorial/episodes/beginner-guide/06-export.mjs +59 -0
  202. package/tools/tutorial/episodes/beginner-guide/steps.mjs +32 -0
  203. package/tools/tutorial/episodes/beginner-guide.mjs +17 -0
  204. package/tools/tutorial/episodes/cast.mjs +12 -0
  205. package/tools/tutorial/episodes/first-video.mjs +121 -0
  206. package/tools/tutorial/episodes/stub-tour.mjs +97 -0
  207. package/tools/tutorial/launch.mjs +63 -0
  208. package/tools/tutorial/mux.mjs +61 -0
  209. package/tools/tutorial/overlay.mjs +142 -0
  210. package/tools/tutorial/record.mjs +156 -0
  211. package/tools/tutorial/scenes/terminal.html +63 -0
  212. package/tools/tutorial/steps.mjs +111 -0
  213. package/tools/tutorial/terminal.mjs +43 -0
  214. package/tools/tutorial/voice.mjs +49 -0
  215. package/tools/update-test-durations.mjs +42 -0
  216. package/tools/verify-package-tarball.mjs +18 -12
  217. package/dist/assets/TaskBoard-DwXhsJXz.js +0 -1
  218. package/dist/assets/app-BLS1Zaru.js +0 -5144
  219. package/dist/assets/compile-worker-or7djI2v.js +0 -6
  220. package/dist/assets/motion3-compile-Du7UXrWn.js +0 -1
  221. package/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
  222. package/dist/assets/scene-asset-sync-t45pWCyq.js +0 -13
  223. package/dist/assets/task-board-CkLZGPkv.js +0 -15
  224. package/dist/assets/workflow-DJwOUzqJ.js +0 -179
@@ -5,8 +5,7 @@ import { elementSetSchema, registerElementSet } from './elements.js';
5
5
  import './elements/object.js';
6
6
  import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject, groupUnderNewEmpty, objectPatchFields, createSculptObject, SCULPT_KIND } from '../scene-objects.js';
7
7
  import { expandSculptParts, normalizeSculptRecipe, patchSculptRecipe } from '../sculpt-recipe.js';
8
- import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
9
- import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
8
+ import { runSculptCompare, sculptCompare } from './sculpt-compare.js';
10
9
  import { clampWarning } from '../receipt-findings.js';
11
10
  import { effectPresetSet } from '../effects/effect-record.js';
12
11
  import { withLightWarnings } from '../light-blockers.js';
@@ -52,16 +51,6 @@ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', d
52
51
  input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
53
52
  input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
54
53
  ] } };
55
- // Read-only: a job that never commits, so its receipt is `completed` with the score as output,
56
- // authored false and no undo. The scoring is MCP compare_sculpt's (sculpt-compare.js).
57
- const openObject = { type: 'object', properties: {}, additionalProperties: true };
58
- const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
59
- 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. candidates (up to 12 { label, edits }) are scored from the same view and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
60
- input: input({ id, reference: { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] },
61
- yaw: { type: 'number', minimum: -180, maximum: 180 }, elevation: { type: 'number', minimum: -60, maximum: 60 },
62
- candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id', 'reference']) };
63
- // A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
64
- const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
65
54
  const matte = { id: 'object.matte', label: 'Apply object matte', description: 'Prepare derived assets, then publish one fenced object edit.', kind: 'job', domain: 'objects', exposure: 'ui-only', input: input({ objectId: id }) };
66
55
  export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, sculptCompare, assetImport]);
67
56
 
@@ -229,31 +218,7 @@ export function register(registry, ports) {
229
218
  await owned().applyMatte(objectId, context);
230
219
  return { affectedIds: [objectId], summary: 'Applied the object matte.' };
231
220
  } });
232
- registry.register({ ...sculptCompare, available, run: async ({ id: objectId, reference, yaw, elevation = 0, candidates }, context) => {
233
- const target = objectOf(objectId);
234
- if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
235
- const images = ports.sculptImages ?? browserSculptImages;
236
- let image, scored;
237
- try { image = await images.decode(reference); }
238
- catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
239
- context?.signal?.throwIfAborted();
240
- try { scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates }); }
241
- catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
242
- const { result, ranked, refused } = scored;
243
- const output = { id: objectId, ...result.report };
244
- if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
245
- sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
246
- const sheet = (ranked ? ranked[0].result : result).sheet;
247
- const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
248
- // The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
249
- for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
250
- const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
251
- if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
252
- }
253
- if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
254
- 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}`;
255
- return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with the reference at yaw ${result.report.view.yaw}: ${lead}.` };
256
- } });
221
+ registry.register({ ...sculptCompare, available, run: runSculptCompare(ports, objectOf) });
257
222
  registry.register({ ...assetImport, available: () => true,
258
223
  run: async (args, context) => {
259
224
  const { source, ...options } = args;
@@ -0,0 +1,21 @@
1
+ // motion.physicsOverlay: the physics overlay's viewer setting (physics-overlay-settings.js), the agent's door to the
2
+ // motion controls' Physics box. Transient: never saved, never undone.
3
+ import { describePhysicsOverlay, setPhysicsOverlay } from "../physics-overlay-settings.js";
4
+
5
+ const flag = description => ({ type: "boolean", description });
6
+
7
+ export const declarations = Object.freeze([
8
+ { id: "motion.physicsOverlay", label: "Physics overlay", kind: "transient",
9
+ input: { type: "object", required: [], additionalProperties: false, properties: {
10
+ com: flag("the centre of mass: a 4 cm ball with a line down to the ground, green inside the feet, red past the balance limit (what QA calls BALANCE), grey off the ground"),
11
+ support: flag("the support polygon: the outline of the soles on the ground"),
12
+ arc: flag("per air run: the hips' path (white, dashed) and the gravity parabola through the same takeoff and landing (orange); far apart is a FLOAT"),
13
+ trail: flag("the last 24 frames of each spring object's tip"),
14
+ all: flag("every part at once, before the named ones; { all: false } turns the overlay off"),
15
+ } },
16
+ description: "Draw the physics QA measures over the active character's installed take, in the editor views and in captures, contact sheets and exports (capture_frame, verify_result frames, export.shotVideo) until it is turned off: com, support, arc and trail (see each field). Updated every frame, the same in scrub, playback and export. No field turns every part on; { all: false } turns it off. Turn it on before a screenshot to see why QA reports BALANCE or FLOAT. A viewer setting: off by default, never saved or undone." },
17
+ ]);
18
+
19
+ export function register(registry) {
20
+ registry.register({ ...declarations[0], available: () => true, run: args => ({ affectedIds: [], summary: describePhysicsOverlay(setPhysicsOverlay(args)) }) });
21
+ }
@@ -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;
@@ -12,10 +12,7 @@ import {
12
12
  ikKeyframes,
13
13
  ikRemoveKeyframe,
14
14
  ikSeedTargets,
15
- clampIkTargetToFloor,
16
- solveIk,
17
15
  MID_TRACKS,
18
- solveMidJoint,
19
16
  solveEffectorSwing,
20
17
  ikPlantFeet,
21
18
  solveHipsTranslateToFloor,
@@ -35,7 +32,8 @@ import { ko } from "../locale.js";
35
32
  import { isProxyFigure } from "../scenes.js";
36
33
  import { refuseRigOnly } from "../character-kind.js";
37
34
  import { DEFAULT_POSE, restoreBindPositions, applyPose, applyHipsOffset } from "../poses.js";
38
- import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, ikDragRecord, ikDragTouch } from "../motion/ik-drag.js";
35
+ import { bakeIkDragKey, bakeIkDragRange, chainsChangedBy, clampDragTarget, ikDragRecord, ikDragTouch, solveDragTarget } from "../motion/ik-drag.js";
36
+ import { fingersOf, solveFingerGesture, restFingers } from "../motion/fingers.js";
39
37
  import { ikKeyJson, ikTrackKeyFromJson } from "../motion/ik-key-json.js";
40
38
  import { chooseIkEntryPose } from "../ik-camera.js";
41
39
  import { applyRangePin, captureRangePinTarget, normalizeRangePin, rangePinTargetWorld, rangePinTracks, removeRangePinKeys } from "../motion/range-pin.js";
@@ -74,8 +72,7 @@ export function createMotionDomain(appContext, characters) {
74
72
  let release = native.subscribe(notify), viewRevision = 0;
75
73
  const documentStore = { ...Object.fromEntries(Object.keys(native).map(key => [key, (...args) => native[key](...args)])),
76
74
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); } };
77
- const read = () => documentStore.read('motion');
78
- 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);
79
76
  function materialize(take) {
80
77
  if (take?.source && !takes.has(take.resourceId)) {
81
78
  try { const compiled = compileTakeSource(take.source); if (compiled) takes.set(take.resourceId, compiled); }
@@ -113,7 +110,7 @@ export function createMotionDomain(appContext, characters) {
113
110
  const resolved = resolveIkRig(rig); if (resolved) Object.assign(state, resolved, { rig });
114
111
  if (savedRigs?.has(id)) appContext.shared.restoreExportRig(savedRigs.get(id));
115
112
  else if (resolved) {
116
- 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));
117
114
  else {
118
115
  const character = cast.read().find(entry => entry.id === id);
119
116
  restoreBindPositions(rig); applyPose(rig, { ...REST_BONES, ...(character?.pose ?? DEFAULT_POSE).bones });
@@ -256,7 +253,7 @@ export function createMotionDomain(appContext, characters) {
256
253
  rigOnly(id, 'pose');
257
254
  const rig = rigFor(id), resolved = rig && resolveIkRig(rig);
258
255
  if (!resolved) throw new StudioProtocolError('TARGET_NOT_READY', 'The character rig is not loaded.');
259
- 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);
260
257
  editKeys(id, state => {
261
258
  state.tracked = new Set([...resolved.chains.keys(), ...resolved.fkJoints.keys()]);
262
259
  ikBakeKeyframe(resolved.chains, state, at, resolved.fkJoints);
@@ -741,7 +738,7 @@ export function useMotion(appContext) {
741
738
  // chain toward the target; mid joints reposition the elbow/knee with both
742
739
  // ends pinned (the handle snaps to the clamped position); FK joints swing
743
740
  // toward the pointer. The solved pose stays a preview until Apply.
744
- function ikSolve(kind, trackId, targetWorld) {
741
+ function ikSolve(kind, trackId, targetWorld, input = null) {
745
742
  if (ikDragRef.current && ikDragRef.current.characterId !== appContext.shared.activeChar.id) return;
746
743
  const state = appContext.shared.ikStateRef.current;
747
744
  const ikChains = state.chains;
@@ -762,14 +759,16 @@ export function useMotion(appContext) {
762
759
  ikDragRef.current = { frame: appContext.shared.tlFrame, ids: new Set(pending?.ids ?? []) };
763
760
  }
764
761
  ikDragRef.current = Object.assign(ikDragRecord(ikDragRef.current, appContext.shared.tlFrame), { characterId: appContext.shared.activeChar.id });
765
- const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id);
762
+ const touch = (id) => ikDragTouch(appContext.shared.ikStateRef.current, ikDragRef.current, id), activeId = appContext.shared.activeChar.id;
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);
766
765
  if (kind === "chain") {
767
766
  const chain = appContext.shared.ikStateRef.current.chains?.get(trackId);
768
767
  if (!chain) return;
769
768
  touch(trackId);
770
- const clampedTarget = bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, ikChains?.get(trackId)?.contactHeights ?? ikChains?.values().next().value?.contactHeights) : targetWorld;
769
+ const clampedTarget = clamp();
771
770
  appContext.shared.ikStateRef.current.targets.set(trackId, clampedTarget.clone());
772
- solveIk(chain, clampedTarget);
771
+ solveDragTarget(kind, chain, clampedTarget);
773
772
  return;
774
773
  }
775
774
  if (kind === "mid") {
@@ -778,7 +777,7 @@ export function useMotion(appContext) {
778
777
  const chain = midDef ? appContext.shared.ikStateRef.current.chains?.get(midDef.chain) : null;
779
778
  if (!chain) return;
780
779
  touch(chain.track.id);
781
- solveMidJoint(chain, bodyContact ? clampIkTargetToFloor(trackId, targetWorld, 0, chain.contactHeights) : targetWorld);
780
+ solveDragTarget(kind, chain, clamp());
782
781
  return;
783
782
  }
784
783
  // Effector swing: the rotation ring on a focused hand/foot. Rotates
@@ -917,7 +916,7 @@ export function useMotion(appContext) {
917
916
  restoreBindPositions(appContext.shared.activeRig);
918
917
  // Reset-then-pose, the same shape the Character effect applies: unlisted
919
918
  // joints return to rest instead of keeping stale limbs from the clip.
920
- applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
919
+ restFingers(ikFkJoints); applyPose(appContext.shared.activeRig, { ...REST_BONES, ...pose.bones });
921
920
  // The hips' measured height rides into the bake: the hips FK joint keys
922
921
  // its local position (p), so a crouched pose keys a crouched body.
923
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
+ }