cozyclay 2.0.2 → 2.1.0

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 (256) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +2 -3
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-B41FmPYI.js +1 -0
  26. package/dist/assets/app-Do-Og5Pu.css +1 -0
  27. package/dist/assets/app-DwP-tl2x.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
  34. package/dist/assets/workflow-Br-K7Srs.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/sitemap.xml +1 -1
  38. package/dist/workflow/index.html +4 -4
  39. package/mcp/LIVE-PROTOCOL.md +23 -2
  40. package/mcp/README.md +37 -2
  41. package/mcp/ardy-prompts.mjs +1 -1
  42. package/mcp/image-file.mjs +132 -0
  43. package/mcp/live-hub.mjs +2 -2
  44. package/mcp/package.json +1 -0
  45. package/mcp/runtime/package-lock.json +7 -0
  46. package/mcp/runtime/package.json +1 -0
  47. package/mcp/server.mjs +0 -6
  48. package/mcp/tool-handlers.mjs +289 -87
  49. package/package.json +11 -2
  50. package/src/App.jsx +789 -263
  51. package/src/agent-image-client.js +94 -0
  52. package/src/analytics.js +47 -3
  53. package/src/app-stage.jsx +129 -2745
  54. package/src/ardy/auto-physics.js +6 -16
  55. package/src/ardy/collision-blockers.js +3 -7
  56. package/src/ardy/fix-collisions.js +5 -35
  57. package/src/ardy/ik-drag.js +86 -5
  58. package/src/ardy/ik-key-json.js +3 -1
  59. package/src/ardy/ik.js +15 -13
  60. package/src/ardy/motion-calibration.js +1 -1
  61. package/src/ardy/npz.js +0 -8
  62. package/src/ardy/physics-panel.jsx +3 -2
  63. package/src/ardy/physics-review.js +3 -2
  64. package/src/ardy/physics-support.js +1 -1
  65. package/src/ardy/physics-surface.js +1 -1
  66. package/src/ardy/platform-fit-panel.jsx +2 -1
  67. package/src/ardy/playback.js +1 -5
  68. package/src/ardy/range-pin.js +2 -2
  69. package/src/ardy/root-drop.js +0 -1
  70. package/src/ardy/timeline.css +184 -6
  71. package/src/ardy/timeline.jsx +113 -28
  72. package/src/ardy/to-cskel27.js +0 -2
  73. package/src/ardy/waypoints.js +0 -3
  74. package/src/ardy-bridge-contract.js +281 -0
  75. package/src/asset-pane.jsx +19 -9
  76. package/src/auto-color.js +1 -1
  77. package/src/burn-in.js +1 -1
  78. package/src/camera-block.js +1 -1
  79. package/src/camera-follow.js +75 -32
  80. package/src/camera-move.js +0 -6
  81. package/src/camera-tutorial.jsx +6 -7
  82. package/src/character-kind.js +79 -0
  83. package/src/command-bus.js +76 -17
  84. package/src/commands/cast.js +31 -7
  85. package/src/commands/elements/object.js +1 -1
  86. package/src/commands/elements/shot.js +1 -1
  87. package/src/commands/elements.js +1 -1
  88. package/src/commands/export.js +3 -3
  89. package/src/commands/motion.js +2 -2
  90. package/src/commands/objects.js +105 -10
  91. package/src/commands/project.js +15 -2
  92. package/src/commands/sculpt-images.js +53 -0
  93. package/src/commands/shot.js +88 -10
  94. package/src/commands/view.js +10 -4
  95. package/src/coplanar-depth.js +216 -0
  96. package/src/cuts.js +7 -6
  97. package/src/domains/cast.js +60 -43
  98. package/src/domains/motion/motion-intent-codec.js +50 -0
  99. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  100. package/src/domains/motion/motion-video-capture.js +664 -0
  101. package/src/domains/motion.js +253 -742
  102. package/src/domains/objects.js +106 -5
  103. package/src/domains/scenes.js +79 -28
  104. package/src/domains/shots.js +33 -7
  105. package/src/error-boundary.jsx +1 -1
  106. package/src/execution-telemetry.js +5 -5
  107. package/src/facing-marks.js +14 -1
  108. package/src/first-success-guide.jsx +35 -4
  109. package/src/gizmo-claim.js +3 -1
  110. package/src/grid-floor.jsx +1 -4
  111. package/src/hierarchy-model.js +48 -35
  112. package/src/hierarchy-panel.css +1 -0
  113. package/src/hierarchy-panel.jsx +54 -102
  114. package/src/history.js +0 -2
  115. package/src/i2v-motion-client.js +1 -4
  116. package/src/ime.js +6 -0
  117. package/src/keyframe-pack.js +15 -6
  118. package/src/line-edit.js +4 -4
  119. package/src/live-control.js +6 -6
  120. package/src/matte-editor.js +2 -8
  121. package/src/matte.js +0 -19
  122. package/src/mesh-graph-clone.js +16 -2
  123. package/src/mesh-sniff.js +0 -1
  124. package/src/monotone-cubic.js +40 -0
  125. package/src/motion-store.js +4 -4
  126. package/src/motion-trail.js +5 -11
  127. package/src/multimodel-ingest.js +1 -3
  128. package/src/object-catalog.jsx +7 -5
  129. package/src/object-gizmo.jsx +45 -31
  130. package/src/object-path.js +310 -67
  131. package/src/object-travel.js +224 -0
  132. package/src/otio.js +8 -3
  133. package/src/panels/CharacterTransformPanel.jsx +24 -6
  134. package/src/panels/ObjectTransformPanel.jsx +4 -3
  135. package/src/panels/PoseImagePanel.jsx +205 -0
  136. package/src/panels/PromptBlocksPanel.jsx +3 -1
  137. package/src/panels/ProxyFigurePanel.jsx +56 -0
  138. package/src/panels/ReferenceImageField.jsx +1 -1
  139. package/src/panels/RigControlPanel.jsx +24 -3
  140. package/src/panels/RigPanel.jsx +2 -1
  141. package/src/panels/SculptPanel.jsx +405 -0
  142. package/src/panels/SubjectBox.jsx +1 -1
  143. package/src/panels/SubjectsPanel.jsx +0 -2
  144. package/src/panels/VideoCapturePanel.jsx +2 -1
  145. package/src/panels/pose-image.css +42 -0
  146. package/src/panels/pose.css +3 -0
  147. package/src/panels/sculpt.css +162 -0
  148. package/src/plan-diagram.js +184 -0
  149. package/src/planview.jsx +127 -80
  150. package/src/playground.js +3 -5
  151. package/src/pose-fade.js +77 -0
  152. package/src/pose-gizmo.css +23 -0
  153. package/src/pose-gizmo.js +96 -0
  154. package/src/posestudio.jsx +134 -83
  155. package/src/project-browser.css +53 -0
  156. package/src/project-browser.jsx +49 -4
  157. package/src/project.js +55 -24
  158. package/src/props.jsx +177 -36
  159. package/src/proxy-figure.jsx +69 -0
  160. package/src/range-pin-panel.jsx +0 -1
  161. package/src/receipt-findings.js +20 -0
  162. package/src/render-passes.js +0 -1
  163. package/src/resource-status.jsx +1 -1
  164. package/src/root-path.js +48 -0
  165. package/src/route-owner.js +52 -0
  166. package/src/scene-asset-cache.js +1 -2
  167. package/src/scene-assets.js +12 -4
  168. package/src/scene-fbx.js +1 -1
  169. package/src/scene-mesh-cache.js +0 -1
  170. package/src/scene-objects.js +319 -19
  171. package/src/scenes.js +58 -9
  172. package/src/sculpt-blueprint.js +65 -0
  173. package/src/sculpt-compare.js +384 -0
  174. package/src/sculpt-geometry.js +141 -0
  175. package/src/sculpt-mode.js +168 -0
  176. package/src/sculpt-recipe.js +385 -0
  177. package/src/sculpt-run.js +351 -0
  178. package/src/sculpt-stage.jsx +221 -0
  179. package/src/settings-menu.jsx +1 -1
  180. package/src/shell/BoardSlot.jsx +341 -0
  181. package/src/shell/BottomDock.jsx +58 -14
  182. package/src/shell/DetailsSlot.jsx +26 -6
  183. package/src/shell/MenuBar.jsx +78 -6
  184. package/src/shell/OutlinerSlot.jsx +3 -1
  185. package/src/shell/TopBar.jsx +17 -3
  186. package/src/shell/ViewportToolbar.jsx +18 -3
  187. package/src/shell/dock.css +239 -0
  188. package/src/shell/glass-regions.css +3 -2
  189. package/src/shell/log-store.js +4 -4
  190. package/src/shell/mode.css +20 -0
  191. package/src/shell/studio-shell-context.js +4 -1
  192. package/src/shot-authoring.js +69 -6
  193. package/src/stable-items.js +1 -1
  194. package/src/stage-attach-frames.js +169 -0
  195. package/src/stage-canvas-rigs.jsx +402 -0
  196. package/src/stage-capture.jsx +216 -0
  197. package/src/stage-characters.jsx +302 -0
  198. package/src/stage-editor-gizmos.jsx +1229 -0
  199. package/src/stage-labels-ko.js +204 -0
  200. package/src/stage-startup-defaults.js +182 -0
  201. package/src/starter-scenes.js +1 -1
  202. package/src/storyboard.js +24 -5
  203. package/src/studio-actions.js +42 -9
  204. package/src/studio-agent-commands.js +186 -23
  205. package/src/studio-agent-context.js +11 -9
  206. package/src/studio-agent-motion.js +2 -1
  207. package/src/studio-agent-protocol.js +56 -23
  208. package/src/studio-app-binding.js +52 -23
  209. package/src/studio-contact-sheet.js +3 -3
  210. package/src/studio-elements.js +11 -2
  211. package/src/studio-geometry-facts.js +114 -0
  212. package/src/styles.css +92 -9
  213. package/src/ui-scrub.js +4 -4
  214. package/src/ui.jsx +6 -4
  215. package/src/use-asset-shelf.js +60 -0
  216. package/src/use-camera-tutorial.js +50 -0
  217. package/src/use-embed-bridge.js +12 -0
  218. package/src/use-first-success-prompt.js +7 -0
  219. package/src/use-i2v-capture-modal.js +15 -0
  220. package/src/use-sculpt-mode.js +95 -0
  221. package/src/workflow/AgentPanel.jsx +106 -18
  222. package/src/workflow/WorkflowBuilder.jsx +1 -1
  223. package/src/workflow/agent-client.js +113 -45
  224. package/src/workflow/agent-panel.css +35 -0
  225. package/src/workflow/attachment-image.js +1 -1
  226. package/src/workflow/canvas-commands.js +3 -3
  227. package/src/workflow/cozy-scene-node.js +3 -3
  228. package/src/workflow/local-workflow.js +2 -2
  229. package/src/workflow/motion-input.js +3 -3
  230. package/src/workflow/node-schema.js +1 -1
  231. package/src/workflow/scene-asset-sync.js +2 -9
  232. package/src/workflow/shot-prompt-node.js +1 -1
  233. package/src/workflow/video-contract.js +1 -1
  234. package/src/workflow/workflow-resources.js +0 -8
  235. package/src/workflow-send.js +87 -0
  236. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  237. package/tools/check-eslint.mjs +57 -0
  238. package/tools/check-knip.mjs +60 -0
  239. package/tools/check-loc.mjs +54 -0
  240. package/tools/demo-worker/api-client.mjs +7 -13
  241. package/tools/demo-worker/generate.mjs +2 -4
  242. package/tools/eslint-baseline.json +23 -0
  243. package/tools/kimodo/effector-constraints.mjs +4 -4
  244. package/tools/kimodo/generate.mjs +1 -1
  245. package/tools/knip-baseline.json +331 -0
  246. package/tools/loc-baseline.json +126 -0
  247. package/tools/process-supervisor.mjs +1 -1
  248. package/tools/projflow/replay.mjs +4 -4
  249. package/tools/projflow/runner.mjs +3 -3
  250. package/tools/qa/studio-control-count.mjs +2 -0
  251. package/tools/run-tests.mjs +94 -7
  252. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  253. package/dist/assets/app-BxBLC6GT.js +0 -4876
  254. package/dist/assets/app-CJdxvnrl.css +0 -1
  255. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  256. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -20,9 +20,15 @@ import {
20
20
  duplicateCutoutOptions,
21
21
  MESH_KIND,
22
22
  duplicateMeshOptions,
23
+ createSculptObject,
24
+ duplicateSculptOptions,
25
+ SCULPT_KIND,
23
26
  objectSize,
24
27
  setSceneObjectAttach,
25
28
  setSceneObjectParent,
29
+ rigidMotionBetween,
30
+ carryPointByMotion,
31
+ tidyAngle,
26
32
  } from "../scene-objects.js";
27
33
 
28
34
  import { ko, isKo } from "../locale.js";
@@ -41,6 +47,7 @@ import { importMeshFile, compressedGlbReason, meshBoundsFromAsset, fitMeshBounds
41
47
  import { cutOutBackground, maskAsset } from "../matte.js";
42
48
  import { parseRigNodeId } from "../hierarchy-model.js";
43
49
  import { StudioProtocolError } from "../studio-agent-protocol.js";
50
+ import { assetDataUrl, sculptPlacement } from "../sculpt-mode.js";
44
51
 
45
52
  // A stable owner across scene loads. App's remaining native history readers use
46
53
  // the small store facade below; authored writes are owned by the document store.
@@ -55,12 +62,50 @@ export function createObjectsDomain(appContext, initial) {
55
62
  };
56
63
  const read = () => documentStore.read("objects");
57
64
  function write(update) {
58
- return documentStore.write("objects", before => {
65
+ let moved = null;
66
+ const result = documentStore.write("objects", before => {
59
67
  let next = typeof update === "function" ? update(before) : update;
60
68
  for (const row of next.filter(row => row.remove === true)) next = removeSceneObject(next, row.id);
61
69
  if (JSON.stringify(next) === JSON.stringify(before)) return before;
70
+ moved = { before, next };
62
71
  return next;
63
72
  });
73
+ if (moved) carryGroupedCharacters(moved.before, moved.next);
74
+ return result;
75
+ }
76
+ /** Characters grouped under an object ride it while authoring too: whatever
77
+ * motion an object took in this write (its own move or turn, or carried with
78
+ * its group), the characters under it take the same. A pure move shifts
79
+ * them by the same translation. A turn orbits them about the object's pivot
80
+ * (position by the object's Δ, like any carried child) and adds the yaw
81
+ * part of Δ to their facing — a character has only a yaw, so the pitch or
82
+ * roll of its parent moves where it stands and never tips it over.
83
+ * Joined to the open action, so one undo puts back the object and the
84
+ * riders together. */
85
+ function carryGroupedCharacters(before, next) {
86
+ const cast = appContext.storeDomain("cast");
87
+ if (!cast) return;
88
+ const was = new Map(before.map(row => [row.id, row]));
89
+ const shifts = new Map();
90
+ const turns = new Map();
91
+ for (const row of next) {
92
+ const prior = was.get(row.id);
93
+ if (!prior || row.attach || prior.attach) continue;
94
+ const turn = rigidMotionBetween(prior, row);
95
+ if (turn) { turns.set(row.id, turn); continue; }
96
+ const shift = { x: row.x - prior.x, y: (row.y ?? 0) - (prior.y ?? 0), z: row.z - prior.z };
97
+ if (shift.x || shift.y || shift.z) shifts.set(row.id, shift);
98
+ }
99
+ if (!(shifts.size || turns.size) || !cast.read().some(entry => shifts.has(entry.parent) || turns.has(entry.parent))) return;
100
+ appContext.recordAction("cast", () => cast.write(rows => rows.map(entry => {
101
+ const turn = turns.get(entry.parent);
102
+ if (turn) {
103
+ const at = carryPointByMotion({ x: entry.x, y: entry.y ?? 0, z: entry.z }, turn);
104
+ return { ...entry, x: at.x, y: Math.max(0, at.y), z: at.z, rot: tidyAngle((entry.rot ?? 0) + turn.yaw) };
105
+ }
106
+ const shift = shifts.get(entry.parent);
107
+ return shift ? { ...entry, x: entry.x + shift.x, y: Math.max(0, (entry.y ?? 0) + shift.y), z: entry.z + shift.z } : entry;
108
+ })), null, true);
64
109
  }
65
110
  const publish = () => { if (appContext.live.state) appContext.patchLive({ objects: read() }); };
66
111
  const unsubscribe = documentStore.subscribe(publish);
@@ -242,6 +287,10 @@ export function useObjects(appContext) {
242
287
 
243
288
  const [gizmoMode, setGizmoMode] = useState("move");
244
289
 
290
+ // An Empty is nothing but its name: the moment one is made, its Outliner
291
+ // row opens for naming (a nonce, so a second Empty re-opens it).
292
+ const [renameRequest, setRenameRequest] = useState(null);
293
+
245
294
  // Snap is a preference, not a law: with it on the gizmo blocks on the plan
246
295
  // board's grid, and Ctrl/Cmd during a drag gives a free one. Off, it is the
247
296
  // other way round. (docs/unity-reference.md §9.5)
@@ -255,10 +304,11 @@ export function useObjects(appContext) {
255
304
  const placement = at ?? (camera
256
305
  ? placementInFront({ x: camera.position.x, z: camera.position.z }, paneYaw)
257
306
  : {});
258
- const receipt = run("object.add", { kind, placement });
307
+ const receipt = run("object.add", { kind, placement, first: true });
259
308
  const object = domain.read().find(row => row.id === receipt.affectedIds[0]);
260
309
  appContext.shared.markCraftAction("object");
261
310
  appContext.shared.setSelectedHierarchyId(`object:${object.id}`);
311
+ if (object.renderer === "empty") setRenameRequest({ id: `object:${object.id}`, nonce: Date.now() });
262
312
  // Deliberate divergence from Unity's rename-on-create: creating an object
263
313
  // here is followed by placing it, and dropping focus into a text field
264
314
  // swallows the very next W/E/R. Renaming stays on F2/Return and the row's
@@ -385,6 +435,25 @@ export function useObjects(appContext) {
385
435
  }
386
436
  }
387
437
 
438
+ /** Sculpt mode's Place in scene (#739): the sculpt stands on the ground in
439
+ * front of the scene camera, where + Add puts a new object, and ends up
440
+ * selected. One object.update, so one undo. */
441
+ function placeSculptInScene(id) {
442
+ const camera = (appContext.shared.lookThroughShot ? appContext.shared.shotCamRef : appContext.shared.editorCamRef).current;
443
+ const yaw = (appContext.shared.lookThroughShot ? appContext.shared.look : appContext.shared.editorLook).current.yaw;
444
+ const receipt = run("object.update", { id, patch: camera ? sculptPlacement(camera.position, yaw) : { y: 0 } });
445
+ appContext.shared.setSelectedHierarchyId(`object:${id}`);
446
+ return receipt;
447
+ }
448
+
449
+ /** A sculpt reference goes into the asset store like a cutout's picture;
450
+ * the run reads it back as a data URL of the stored bytes. */
451
+ async function storeSculptReference(file) {
452
+ const prepared = await importImageFile(file);
453
+ const stored = await rememberAsset(prepared);
454
+ return { dataUrl: assetDataUrl(prepared), name: prepared.name || file.name || "reference", assetId: stored.id };
455
+ }
456
+
388
457
  function placementInFrontOfShot() {
389
458
  const camera = (appContext.shared.lookThroughShot ? appContext.shared.shotCamRef : appContext.shared.editorCamRef).current;
390
459
  return camera
@@ -528,7 +597,9 @@ export function useObjects(appContext) {
528
597
  ? createCutoutObject(duplicateCutoutOptions(object), objects, placement)
529
598
  : object.renderer === MESH_KIND
530
599
  ? createMeshObject(duplicateMeshOptions(object), objects, placement)
531
- : createSceneObject(object.renderer, objects, placement);
600
+ : object.renderer === SCULPT_KIND
601
+ ? createSculptObject(duplicateSculptOptions(object), objects, placement).object
602
+ : createSceneObject(object.renderer, objects, placement);
532
603
  if (!copy) return;
533
604
  // Unity drops the duplicate exactly on top of the original; for blocking,
534
605
  // one grid step to the side means you can see that it worked.
@@ -548,6 +619,25 @@ export function useObjects(appContext) {
548
619
  );
549
620
  }
550
621
 
622
+ /** Outliner "Group under new Empty": the object's parent node, made on the
623
+ * spot. One command, one write, so one undo takes back the Empty and the
624
+ * reparent together. Selection follows the Empty, the new handle. */
625
+ function groupObjectUnderNewEmpty(id = selectedSceneObjectId) {
626
+ if (!id) return null;
627
+ try {
628
+ const receipt = run("object.groupUnderEmpty", { id });
629
+ const emptyId = receipt.affectedIds[0];
630
+ appContext.shared.setSelectedHierarchyId(`object:${emptyId}`);
631
+ setRenameRequest({ id: `object:${emptyId}`, nonce: Date.now() });
632
+ setGizmoMode("move");
633
+ appContext.notify(ko("Grouped under a new Empty", "빈 오브젝트로 묶었어요"));
634
+ return emptyId;
635
+ } catch (error) {
636
+ appContext.notify(error?.message || ko("Could not group this object", "이 오브젝트는 묶을 수 없어요"));
637
+ return null;
638
+ }
639
+ }
640
+
551
641
  /** In-place rename commit from the hierarchy (F2 / Return / rename on
552
642
  * create). The row label lives in the tree; the object name is shared
553
643
  * state, so this is just the inspector's rename through another door. */
@@ -878,7 +968,9 @@ export function useObjects(appContext) {
878
968
  // Grouping keeps its own rules (self, cycles, unknown ids) — asking the
879
969
  // store is the only way to stay honest about them.
880
970
  if (targetObjectId) return setSceneObjectParent(sceneObjects, id, targetObjectId) !== sceneObjects;
881
- if (targetRowId === "props") return (object.attach ?? null) !== null || (object.parent ?? null) !== null;
971
+ // Props takes an object out of its group (or off a character) and lists
972
+ // it first, so the drop always shows; a top-level object moves up too.
973
+ if (targetRowId === "props") return (object.attach ?? null) !== null || (object.parent ?? null) !== null || sceneObjects[0]?.id !== id;
882
974
  const attach = attachTargetForRow(targetRowId);
883
975
  if (!attach) return false;
884
976
  const current = object.attach ?? null;
@@ -894,6 +986,14 @@ export function useObjects(appContext) {
894
986
  // group comes back to world numbers on the way, exactly as the Props
895
987
  // row would put it back.
896
988
  if (targetObjectId) return run("object.group", { parent: targetObjectId, children: [id] });
989
+ const object = sceneObjects.find((entry) => entry.id === id);
990
+ if (targetRowId === "props" && !object?.attach) {
991
+ // Out of its group and to the top of Props in one write (one undo):
992
+ // records are world-space, so clearing the parent moves nothing.
993
+ const rows = setSceneObjectParent(domain.read(), id, null);
994
+ const moved = rows.find((entry) => entry.id === id);
995
+ return run("objects.replace", { objects: [moved, ...rows.filter((entry) => entry.id !== id)] });
996
+ }
897
997
  const attach = targetRowId === "props" ? null : attachTargetForRow(targetRowId);
898
998
  appContext.shared.runStudioAction(attach ? "object.attach" : "object.detach", attach
899
999
  ? { objectId: id, characterId: attach.characterId, ...(attach.bone ? { bone: attach.bone } : {}) }
@@ -926,7 +1026,8 @@ export function useObjects(appContext) {
926
1026
  matteBrush, setMatteBrush, matteShrink, setMatteShrink, matteFeather, setMatteFeather, matteMode,
927
1027
  setMatteMode, matteStats, setMatteStats, matteBusy, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled,
928
1028
  addSceneObject, importCutout, importCutouts, spawnCutoutAt, persistMeshAsset, importMesh, importMeshes,
929
- spawnMeshAt, applyMatte, duplicateSelectedSceneObject, frameSelection, renameSceneObject,
1029
+ spawnMeshAt, applyMatte, duplicateSelectedSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest, renameSceneObject,
930
1030
  sceneObjectWorldMatrix, attachTargetForRow, attachTargetLabel, attachSceneObject,
1031
+ placeSculptInScene, storeSculptReference,
931
1032
  };
932
1033
  }
@@ -7,7 +7,7 @@ import {
7
7
  readSceneDocument,
8
8
  serializeSceneDocument,
9
9
  SCENES_VERSION,
10
- migrateStageFrames,
10
+ migrateScenesDocument as migrateToCurrentScenes,
11
11
  activeSceneIndex,
12
12
  createSceneDocument,
13
13
  createSceneStage,
@@ -37,6 +37,9 @@ import {
37
37
  requestHandlePermission,
38
38
  createWorkflowGraph,
39
39
  clearStoredProjectHandle,
40
+ DEFAULT_PREVIS_MODE,
41
+ normalizePrevisMode,
42
+ buildAnimationProjectFromStoryboard,
40
43
  } from "../project.js";
41
44
  import { playgroundSceneUrl, fetchSceneProject } from "../playground.js";
42
45
  import { openAssetDb, referencedAssetIds, getAsset, putAsset } from "../scene-assets.js";
@@ -45,7 +48,7 @@ import { encodeMotionResource } from "../motion-resources.js";
45
48
  import { openMotionDb, putMotion, sweepMotions } from "../motion-store.js";
46
49
  import { resourceManifest } from "../project-resources.js";
47
50
  import { isKo, ko } from "../locale.js";
48
- import { track, bucketCount, bucketProjectAge } from "../analytics.js";
51
+ import { track, bucketCount, bucketProjectAge, startExportAttempt } from "../analytics.js";
49
52
  import { mergeProjectCustomPoses } from "../project-poses.js";
50
53
  import { DEFAULT_WORKSPACE_LAYOUT, DEFAULT_DURATION_S, TIMELINE_FPS } from "../app-stage.jsx";
51
54
  import { saveCustomPoses } from "../poses.js";
@@ -55,9 +58,11 @@ import { createIkState } from "../ardy/ik.js";
55
58
 
56
59
  // The scene list and project identity share one history. Dirty is derived from
57
60
  // the saved checkpoint, not an authored edit, and has its own non-history slice.
58
- export function createScenesDomain(appContext, initial, name) {
61
+ // previsMode is project identity too: it is set only by a document boundary
62
+ // (blank creation, starter/file open, session restore), never by an edit.
63
+ function createScenesDomain(appContext, initial, name, previsMode = DEFAULT_PREVIS_MODE) {
59
64
  const ordered = rows => rows.map((scene, order) => ({ ...scene, order }));
60
- let native = createDocumentStore({ owned: { scenes: ordered(initial.scenes), project: { name, activeSceneId: initial.activeSceneId } } });
65
+ let native = createDocumentStore({ owned: { scenes: ordered(initial.scenes), project: { name, activeSceneId: initial.activeSceneId, previsMode: normalizePrevisMode(previsMode) } } });
61
66
  const listeners = new Set();
62
67
  const notify = () => { for (const listener of listeners) listener(); };
63
68
  let release = native.subscribe(notify);
@@ -93,7 +98,7 @@ export function createScenesDomain(appContext, initial, name) {
93
98
  appContext.publishScenes(read());
94
99
  appContext.shared.activeSceneIdRef.current = metadata().activeSceneId;
95
100
  if (appContext.live.state) appContext.patchLive({ scenes: read(), activeSceneId: metadata().activeSceneId });
96
- storeProjectSession(metadata().name);
101
+ storeProjectSession(metadata().name, metadata().previsMode);
97
102
  domain.persist?.();
98
103
  domain.refreshDirty?.();
99
104
  };
@@ -117,9 +122,9 @@ export function createScenesDomain(appContext, initial, name) {
117
122
  },
118
123
  // Session cache/file loads are explicit non-authored boundaries. They
119
124
  // retire scene history, just as opening a scene retired native history.
120
- replaceDocument(scenes, activeSceneId, name = metadata().name) {
125
+ replaceDocument(scenes, activeSceneId, name = metadata().name, previsMode = metadata().previsMode) {
121
126
  release(); native.dispose();
122
- native = createDocumentStore({ owned: { scenes: ordered(scenes), project: { name, activeSceneId } } });
127
+ native = createDocumentStore({ owned: { scenes: ordered(scenes), project: { name, activeSceneId, previsMode: normalizePrevisMode(previsMode) } } });
123
128
  release = native.subscribe(notify); notify();
124
129
  },
125
130
  setDirty(value) {
@@ -135,9 +140,13 @@ export function createScenesDomain(appContext, initial, name) {
135
140
  }
136
141
 
137
142
  export function useScenes(appContext) {
138
- const [domain] = useState(() => appContext.storeDomain("scenes") ?? createScenesDomain(appContext, appContext.shared.startup.document, loadProjectSession()?.name ?? null));
143
+ const [domain] = useState(() => {
144
+ if (appContext.storeDomain("scenes")) return appContext.storeDomain("scenes");
145
+ const session = loadProjectSession();
146
+ return createScenesDomain(appContext, appContext.shared.startup.document, session?.name ?? null, session?.previsMode);
147
+ });
139
148
  const scenes = useDocumentDomain(domain.documentStore, "scenes");
140
- const { activeSceneId, name: projectName } = useDocumentDomain(domain.documentStore, "project");
149
+ const { activeSceneId, name: projectName, previsMode } = useDocumentDomain(domain.documentStore, "project");
141
150
  const projectDirty = useDocumentDomain(domain.dirtyStore, "projectDirty");
142
151
  const runtimeValues = useRef(new Map());
143
152
  async function runProject(id, args = {}) {
@@ -185,8 +194,7 @@ export function useScenes(appContext) {
185
194
  }
186
195
  }
187
196
 
188
- /* ============================ project files ============================
189
- * Game-engine workflow: the authoring state (scenes + cast + layers,
197
+ /* Game-engine workflow: the authoring state (scenes + cast + layers,
190
198
  * workspace layout, custom poses) round-trips through a real
191
199
  * `.cclayproject` file. localStorage stays as the always-on session
192
200
  * cache; the file is the portable, user-owned document. */
@@ -222,6 +230,7 @@ export function useScenes(appContext) {
222
230
  customPoses: appContext.shared.projectStateRef.current.customPoses,
223
231
  workflow: loadWorkflowGraph(),
224
232
  name,
233
+ previsMode: domain.metadata().previsMode,
225
234
  };
226
235
  }
227
236
 
@@ -296,7 +305,7 @@ export function useScenes(appContext) {
296
305
  appContext.shared.projectSnapshotRef.current = snapshot;
297
306
  if (domain.metadata().name === previousName) domain.nameSaved(name);
298
307
  refreshProjectDirty();
299
- storeProjectSession(domain.metadata().name);
308
+ storeProjectSession(domain.metadata().name, domain.metadata().previsMode);
300
309
  }
301
310
 
302
311
  function projectProblemsNotice(problems) {
@@ -391,6 +400,40 @@ export function useScenes(appContext) {
391
400
  }
392
401
  }
393
402
 
403
+ async function exportAsAnimationProject(context) {
404
+ if (domain.metadata().previsMode !== "storyboard") return { saved: false, failure: "not-storyboard" };
405
+ const attempt = startExportAttempt({ export_kind: "animation_project", format: "cclayproject", surface: "studio" });
406
+ try {
407
+ const source = readProjectDocument(await collectProjectSerialized(domain.metadata().name ?? "Untitled"));
408
+ context.check();
409
+ if (!source.ok) throw new Error(`Could not read the storyboard project: ${source.reason}`);
410
+ const animation = buildAnimationProjectFromStoryboard(source.project);
411
+ const serialized = JSON.stringify(animation, null, 2);
412
+ let downloaded = false;
413
+ if (hasFileSystemAccess()) {
414
+ const handle = await pickProjectFileForSave(animation.name);
415
+ context.check();
416
+ await writeProjectFile(handle, serialized);
417
+ await rememberRecentProject(handle, animation.name);
418
+ } else {
419
+ downloadProjectFallback(serialized, animation.name);
420
+ downloaded = true;
421
+ }
422
+ attempt.succeed();
423
+ return {
424
+ saved: true,
425
+ name: animation.name,
426
+ fileName: `${animation.name}${PROJECT_EXTENSION}`,
427
+ downloaded,
428
+ };
429
+ } catch (error) {
430
+ attempt.fail(error, error?.name === "AbortError" ? "aborted" : "unknown");
431
+ if (error?.name === "AbortError") return { saved: false, cancelled: true };
432
+ appContext.notify(ko("Could not export the animation project", "애니메이션 프로젝트를 내보내지 못했어요"));
433
+ return { saved: false, failure: error?.code ?? error?.name ?? "error" };
434
+ }
435
+ }
436
+
394
437
  function applyProject(project, authorized = false) {
395
438
  if (!authorized) return runWithValue("project.open", "projectToken", project);
396
439
  const checked = readSceneDocument(JSON.stringify(project.scenesDocument));
@@ -408,11 +451,10 @@ export function useScenes(appContext) {
408
451
  // A project FILE carries its own scene document and never passes the
409
452
  // storage reader, so the 20 fps → 24 fps clock migration is applied here
410
453
  // too — otherwise an older .cozyclay would open a sixth too fast.
411
- const doc = Number.isInteger(source.version) && source.version < SCENES_VERSION
412
- ? { ...source, version: SCENES_VERSION, scenes: source.scenes.map((scene) => ({ ...scene, stage: migrateStageFrames(scene.stage) })) }
413
- : source;
454
+ const doc = migrateToCurrentScenes(source);
414
455
  const mergedCustomPoses = mergeProjectCustomPoses(appContext.shared.customPoses, project.customPoses);
415
- domain.replaceDocument(doc.scenes, doc.activeSceneId, project.name);
456
+ const projectPrevisMode = normalizePrevisMode(project.previsMode);
457
+ domain.replaceDocument(doc.scenes, doc.activeSceneId, project.name, projectPrevisMode);
416
458
  if (project.workspaceLayout) appContext.shared.setWorkspaceLayout({ ...DEFAULT_WORKSPACE_LAYOUT, ...project.workspaceLayout });
417
459
  appContext.bus.run('cast.setCustomPoses', { poses: mergedCustomPoses });
418
460
  const resolvedWorkflow = resolveWorkflowOutputs(normalizeWorkflowGraph(project.workflow), new Map((project.assets ?? []).map((asset) => [asset.id, asset])));
@@ -423,7 +465,7 @@ export function useScenes(appContext) {
423
465
  appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(project.name);
424
466
  domain.pendingCheckpoint = { clock: appContext.undoClock, name: project.name };
425
467
  domain.setDirty(false);
426
- storeProjectSession(project.name);
468
+ storeProjectSession(project.name, projectPrevisMode);
427
469
  setProjectStartupOpen(false);
428
470
  // Whatever document this is, it is no longer the scene the tutorial opened
429
471
  // for itself; startCameraTutorial re-arms the flag after its own open.
@@ -525,29 +567,36 @@ export function useScenes(appContext) {
525
567
  }
526
568
  }
527
569
 
528
- function requestNewProject(authorized = false) {
529
- if (!authorized) return runProject("project.new");
570
+ function requestNewProject(authorized = false, { previsMode } = {}) {
571
+ if (!authorized) return runProject("project.new", previsMode === undefined ? undefined : { previsMode });
530
572
  if (domain.dirtyStore.read("projectDirty") && !window.confirm(ko("Discard unsaved changes and start a new project?", "저장되지 않은 변경사항을 버리고 새 프로젝트를 시작할까요?"))) return;
531
- setProjectNameDialog({ kind: "new", initialName: projectName ?? "My Project" });
573
+ // The mode chosen on the start screen rides on the name dialog until its
574
+ // submit commits the project.
575
+ setProjectNameDialog({ kind: "new", initialName: projectName ?? "My Project", ...(previsMode === undefined ? {} : { previsMode }) });
532
576
  }
533
577
 
534
- function newProject(name, authorized = false) {
535
- if (!authorized) return runProject("project.new", typeof name === "string" ? { name } : {});
536
- if (typeof name !== "string") return requestNewProject(true);
578
+ function newProject(name, authorized = false, { previsMode } = {}) {
579
+ if (!authorized) {
580
+ if (previsMode === undefined && projectNameDialog?.kind === "new") previsMode = projectNameDialog.previsMode;
581
+ return runProject("project.new", { ...(typeof name === "string" ? { name } : {}), ...(previsMode === undefined ? {} : { previsMode }) });
582
+ }
583
+ if (typeof name !== "string") return requestNewProject(true, { previsMode });
537
584
  name = name.trim() || "My Project";
585
+ previsMode = normalizePrevisMode(previsMode);
538
586
  setProjectNameDialog(null);
539
587
  const fresh = createSceneDocument(ko("SCENE 01", "씬 01"));
540
588
  storeWorkflowGraph(createWorkflowGraph());
541
589
  appContext.shared.projectMotionsRef.current = new Map();
542
590
  appContext.shared.motionFullRef.current.clear();
543
- domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name);
591
+ domain.replaceDocument(fresh.scenes, fresh.activeSceneId, name, previsMode);
544
592
  persistScenes(fresh.scenes, fresh.activeSceneId);
545
593
  openScene(fresh.scenes[0], fresh.scenes);
546
594
  appContext.shared.projectHandleRef.current = null;
547
595
  clearStoredProjectHandle();
548
596
  appContext.shared.projectSnapshotRef.current = collectProjectSnapshot(name);
549
597
  domain.setDirty(false);
550
- storeProjectSession(name);
598
+ storeProjectSession(name, previsMode);
599
+ track("scene:created", { scene_source: "new_project", previs_mode: previsMode });
551
600
  setProjectStartupOpen(false);
552
601
  appContext.shared.setFirstSuccessGuideOpen(true);
553
602
  domain.pendingCheckpoint = { clock: appContext.undoClock, name };
@@ -661,7 +710,7 @@ export function useScenes(appContext) {
661
710
  const target = nextScenes[nextScenes.length - 1];
662
711
  persistScenes(nextScenes, target.id);
663
712
  openScene(target, nextScenes);
664
- track("scene:created", { scene_source: "ui" });
713
+ track("scene:created", { scene_source: "ui", previs_mode: domain.metadata().previsMode });
665
714
  }
666
715
 
667
716
  function duplicateSceneDocument(sceneId) {
@@ -778,6 +827,7 @@ export function useScenes(appContext) {
778
827
  domain.fileState = () => ({ name: domain.metadata().name, hasFile: Boolean(appContext.shared.projectHandleRef.current),
779
828
  fileAccess: hasFileSystemAccess(), gesture: globalThis.navigator?.userActivation?.isActive === true });
780
829
  domain.save = (args, context) => saveProject(args.saveAs, args.name ?? null, context);
830
+ domain.exportAsAnimationProject = context => exportAsAnimationProject(context);
781
831
  domain.loadScenes = args => loadLiveScenes(args, true);
782
832
  domain.projectAction = async (id, args, context) => {
783
833
  const runtime = key => {
@@ -787,7 +837,7 @@ export function useScenes(appContext) {
787
837
  return value;
788
838
  };
789
839
  if (id === "project.browse") { setProjectStartupOpen(false); setProjectBrowserOpen(true); return; }
790
- if (id === "project.new") return newProject(args.name, true);
840
+ if (id === "project.new") return newProject(args.name, true, { previsMode: args.previsMode });
791
841
  if (id === "project.openStarter") return openStarterScene(args.id, args.source, context, args.name);
792
842
  if (id === "project.restore") return restoreStoredProject({ handle: runtime("handleToken") }, context);
793
843
  if (args.handleToken) return openProjectByHandle(runtime("handleToken"), context);
@@ -807,12 +857,13 @@ export function useScenes(appContext) {
807
857
  domain.refreshDirty = () => refreshProjectDirty(false);
808
858
  return {
809
859
  ...domain, applyExternalScene, loadLiveScenes, refreshProjectDirty,
810
- scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName,
860
+ scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode,
811
861
  projectDirty, setProjectDirty: domain.setDirty, projectSaveState, setProjectSaveState, projectMenuOpen,
812
862
  setProjectMenuOpen, projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog,
813
863
  projectStartupOpen, setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons,
814
864
  setSaveBlockedReasons, workflowRevision, setWorkflowRevision, collectProjectSnapshot,
815
865
  collectProjectSerialized, projectProblemsNotice, rehydrateProjectAssets, saveProject, applyProject,
866
+ exportAsAnimationProject,
816
867
  openStarterScene: async (id, source = "starter", name = null) => (await runProject("project.openStarter", { id, source, ...(name ? { name } : {}) })).output?.opened === true,
817
868
  openProject, openProjectByHandle, requestNewProject, newProject, restoreOffer,
818
869
  setRestoreOffer, restoreStoredProject, flushScenes, openScene, selectSceneDocument,
@@ -30,7 +30,7 @@ function normalizeState(value) {
30
30
 
31
31
  // The stable handle survives scene loads. Camera pose belongs to the same
32
32
  // history entry as the shot it frames; only the persisted shots are projected.
33
- export function createShotsDomain(appContext, initial = {}) {
33
+ function createShotsDomain(appContext, initial = {}) {
34
34
  const initialState = value => normalizeState({ shots: [], frameCount: DEFAULT_DURATION_S * TIMELINE_FPS,
35
35
  cameraMove: CAMERA_MOVES[1], fovDeg: PRESETS.medium.fov, camera: null, manual: false, ...value });
36
36
  let native = createDocumentStore({ owned: { shot: initialState(initial) } });
@@ -140,6 +140,25 @@ export function createShotsDomain(appContext, initial = {}) {
140
140
  const before = state(), filmback = appContext.live.state.filmback;
141
141
  writeState({ ...before, fovDeg, camera: before.camera && { ...before.camera, focalMm: fovToFocalMm(fovDeg * Math.PI / 180, filmback.sensorId, filmback.aspectRatio) } });
142
142
  }
143
+ function setCastOverride(shotId, characterId, override) {
144
+ if (!read().some(shot => shot.id === shotId)) throw new StudioProtocolError('STALE_TARGET', `Shot ${shotId} is not in this scene.`);
145
+ if (!appContext.live.characters.some(entry => entry.id === characterId)) throw new StudioProtocolError('STALE_TARGET', `Character ${characterId} is not in this scene.`);
146
+ if (override !== null && (!override || !Number.isFinite(override.x) || !Number.isFinite(override.z) || !Number.isFinite(override.rot)
147
+ || (override.posture !== undefined && !['stand', 'sit', 'lie'].includes(override.posture))
148
+ || (override.pose !== undefined && (!override.pose || typeof override.pose !== 'object' || Array.isArray(override.pose))))) {
149
+ throw new StudioProtocolError('INVALID_ARGUMENT', 'Cast override requires finite x, z and rot and valid posture/pose fields.');
150
+ }
151
+ return writeState(before => ({
152
+ ...before,
153
+ shots: before.shots.map(shot => {
154
+ if (shot.id !== shotId) return shot;
155
+ const cast = { ...shot.cast };
156
+ if (override === null) delete cast[characterId];
157
+ else cast[characterId] = structuredClone(override);
158
+ return { ...shot, cast };
159
+ }),
160
+ }));
161
+ }
143
162
  function placeCamera(args) {
144
163
  const before = appContext.ports.read().camera ?? state().camera;
145
164
  const position = { ...before.position, ...Object.fromEntries(['x', 'y', 'z'].filter(key => args[key] !== undefined).map(key => [key, args[key]])) };
@@ -181,7 +200,7 @@ export function createShotsDomain(appContext, initial = {}) {
181
200
  }
182
201
  return run('run.update', { txId: gesture.txId, args });
183
202
  }
184
- const domain = { documentStore, state, read, write, writeState, beginAction, run, edit, beginGesture, finishGesture,
203
+ const domain = { documentStore, state, read, write, writeState, setCastOverride, beginAction, run, edit, beginGesture, finishGesture,
185
204
  bindRender(context) { appContext = context; },
186
205
  capture: () => appContext.shared.captureCurrentFraming(), frame, captureCamera, placeCamera, setLens, renderCamera,
187
206
  canUndo: id => documentStore.canUndo(id), stepHistory: redo => { finishGesture(); return Boolean((redo ? documentStore.redo : documentStore.undo)()); },
@@ -345,7 +364,9 @@ export function useShots(appContext) {
345
364
  const travel = Math.ceil(curve.length / speed * tlFps) + Math.round(tlFps * 0.5);
346
365
  const endFrame = Math.min(tlFrameCount - 1, activeShot.startFrame + Math.max(travel, activeShot.endFrame - activeShot.startFrame));
347
366
  setTimelineShotRange(activeShot.id, activeShot.startFrame, endFrame);
348
- appContext.shared.enterPreview(); setTlFrame(activeShot.startFrame);
367
+ // The landing tutorial's last step is pressing ▶ (#602): open the player
368
+ // on the shot camera, but leave playback to the visitor.
369
+ appContext.shared.enterPreview(); appContext.shared.setTlPlaying(false); setTlFrame(activeShot.startFrame);
349
370
  appContext.notify(isKo ? '레일 완성 — 샷 카메라 시점으로 전환했습니다. ▶ 로 재생, Esc 로 복귀' : 'Rail drawn — you are looking through the shot camera. Press ▶ to ride it; Esc goes back to flying.'); return;
350
371
  }
351
372
  appContext.notify(isKo ? `카메라 레일 완성 — ${curve ? curve.length.toFixed(1) : '?'} m, 제어점 ${simplified.length}개` : `Camera rail drawn — ${curve ? curve.length.toFixed(1) : '?'} m, ${simplified.length} control points`);
@@ -371,18 +392,23 @@ export function useShots(appContext) {
371
392
  setTlFrameCount(span); if (extent > 0) setTlFrame(frame => Math.min(frame, span - 1));
372
393
  }
373
394
  function finishCameraMove(finalFov) { setMovePlaying(false); setFovDeg(Math.round(finalFov * 10) / 10); }
374
- async function exportShotVideo({ download = true, shotId = null } = {}, commandContext = null) {
395
+ // scope "timeline" records every frame of the timeline, shot boxes and all;
396
+ // "shot" records one shot (shotId, or the one under the playhead). Without a
397
+ // scope the old rule stands: a named shot, or the playhead's shot when there
398
+ // is no motion, else the whole record.
399
+ async function exportShotVideo({ download = true, shotId = null, scope = null } = {}, commandContext = null) {
375
400
  if (appContext.shared.recRef.current) return null;
376
401
  const atPlayhead = shotIndexAtFrame(shots, tlFrame);
377
402
  const target = shotId ? shots.find(entry => entry.id === shotId) : shots[atPlayhead >= 0 ? atPlayhead : 0] ?? null;
378
403
  if (shotId && !target) return null;
379
404
  let exportShots = shots;
380
- if (target && target.cameraKeys.length === 0 && !shotId) {
405
+ if (target && target.cameraKeys.length === 0 && !shotId && scope !== 'timeline') {
381
406
  appContext.bus.run('shot.addKey', { shotId: target.id, frame: target.startFrame });
382
407
  exportShots = appContext.storeDomain('shot').read();
383
408
  }
384
- const range = target && (shotId || !appContext.shared.motion) ? { startFrame: target.startFrame, endFrame: target.endFrame }
385
- : { startFrame: 0, endFrame: Math.max(0, appContext.shared.currentRecordFrameCount() - 1) };
409
+ const oneShot = scope === 'shot' ? Boolean(target) : scope === 'timeline' ? false : Boolean(target && (shotId || !appContext.shared.motion));
410
+ const range = oneShot ? { startFrame: target.startFrame, endFrame: target.endFrame }
411
+ : { startFrame: 0, endFrame: Math.max(0, appContext.shared.currentRecordFrameCount() - 1, ...(scope === 'timeline' ? shots.map(entry => entry.endFrame) : [])) };
386
412
  return appContext.shared.executeExportRequest(appContext.shared.exportRequest('video', async job => {
387
413
  const abort = () => job.controller.abort(commandContext.signal.reason);
388
414
  commandContext?.signal.addEventListener('abort', abort, { once: true });
@@ -31,7 +31,7 @@ export default class ErrorBoundary extends Component {
31
31
  <div className="crash-screen" role="alert">
32
32
  <h1>{ko("The studio hit a render error", "스튜디오에 렌더링 오류가 발생했어요")}</h1>
33
33
  <p>{ko("Scenes autosave as you work, so reloading resumes from the last saved state.", "작업 중 장면은 자동 저장되므로, 새로고침하면 마지막 저장 상태에서 이어집니다.")}</p>
34
- <pre className="crash-detail">{String((this.state.error && this.state.error.message) || this.state.error)}</pre>
34
+ <pre className="crash-detail">{String(this.state.error.message || this.state.error)}</pre>
35
35
  <button type="button" onClick={() => window.location.reload()}>
36
36
  {ko("Reload the studio", "스튜디오 새로고침")}
37
37
  </button>
@@ -1,5 +1,5 @@
1
1
  import { bucketMs, track, EXECUTION_TELEMETRY_VALUES } from "./analytics.js";
2
- export { AGENT_TOOL_CATEGORIES, MCP_TOOL_CATEGORIES, EXECUTION_TELEMETRY_EVENTS, EXECUTION_TELEMETRY_PROPERTY_KEYS, EXECUTION_TELEMETRY_VALUES } from "./analytics.js";
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
4
  const AGENT_FAILURE_CODES = new Set(["aborted", "auth", "rate_limited", "tool_failed", "upstream", "unknown"]);
5
5
  function secureId() {
@@ -31,7 +31,7 @@ function readNow(now) {
31
31
  }
32
32
  }
33
33
 
34
- export function executionFailureCode(error, channel, fallbackCode = "unknown") {
34
+ function executionFailureCode(error, channel, fallbackCode = "unknown") {
35
35
  const allowed = channel === "agent" ? AGENT_FAILURE_CODES : WORKFLOW_FAILURE_CODES;
36
36
  try {
37
37
  if (error?.name === "AbortError") return "aborted";
@@ -48,8 +48,8 @@ export function mcpToolCategory(name) {
48
48
  if (name === "set_camera" || name === "frame_shot" || name === "mark_camera_move" || name === "describe_camera_move") return "camera";
49
49
  if (name === "set_prompt_blocks") return "prompt_authoring";
50
50
  if (name === "save_project" || name === "open_project") return "project_io";
51
- if (name === "describe_scene" || name === "describe_shot" || name === "render_prompt" || name === "live_status") return "read";
52
- if (name === "add_character" || name === "place_character" || name === "remove_character" || name === "focus_character" || name === "place_object" || name === "import_mesh" || name === "group_objects" || name === "update_object" || name === "remove_object" || name === "apply_batch" || name === "add_scene" || name === "switch_scene") return "scene_write";
51
+ if (name === "describe_scene" || name === "describe_shot" || name === "render_prompt" || name === "live_status" || name === "compare_sculpt") return "read";
52
+ 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";
53
53
  return "other";
54
54
  }
55
55
 
@@ -61,7 +61,7 @@ export function startWorkflowExecution(metadata, { capture = track, now = () =>
61
61
  const emit = (event, props) => {
62
62
  if (runId) safeCapture(capture, event, props);
63
63
  };
64
- if (runId) emit("workflow:run_requested", {
64
+ emit("workflow:run_requested", {
65
65
  surface: "workflow",
66
66
  ...(EXECUTION_TELEMETRY_VALUES.node_count_bucket.has(metadata.node_count_bucket) ? { node_count_bucket: metadata.node_count_bucket } : {}),
67
67
  run_id: runId,
@@ -30,7 +30,7 @@ import * as THREE from "three";
30
30
  * these cannot split segmentation. The COCO nose keypoint is the one the
31
31
  * estimator keys facing on; the wedge centres the face below the eyes.
32
32
  */
33
- export const EYE_SEPARATION = 6;
33
+ const EYE_SEPARATION = 6;
34
34
 
35
35
  export function facingMarkSpecs() {
36
36
  const eye = (x) => ({
@@ -73,3 +73,16 @@ export function addFacingMarks(clone) {
73
73
  head.add(mesh);
74
74
  }
75
75
  }
76
+
77
+ export function proxyFacingMark(material = null) {
78
+ const mesh = new THREE.Mesh(
79
+ new THREE.ConeGeometry(0.055, 0.16, 4),
80
+ material ?? new THREE.MeshStandardMaterial({ color: "#1C1C1C", roughness: 0.7, metalness: 0 }),
81
+ );
82
+ mesh.position.set(0, -0.015, 0.115);
83
+ mesh.rotation.set(Math.PI / 2, Math.PI / 4, 0);
84
+ mesh.castShadow = true;
85
+ mesh.frustumCulled = false;
86
+ mesh.userData.facingMark = true;
87
+ return mesh;
88
+ }